@sanoma-ai/workflows 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/README.md +169 -52
  2. package/dist/apply-steps.js +5 -2
  3. package/dist/apply-steps.js.map +1 -1
  4. package/dist/apply.d.ts +3 -0
  5. package/dist/apply.js +35 -21
  6. package/dist/apply.js.map +1 -1
  7. package/dist/approvals.d.ts +1 -0
  8. package/dist/approvals.js +6 -1
  9. package/dist/approvals.js.map +1 -1
  10. package/dist/apps.d.ts +7 -24
  11. package/dist/apps.js +51 -67
  12. package/dist/apps.js.map +1 -1
  13. package/dist/ast.d.ts +9 -7
  14. package/dist/ast.js +2 -0
  15. package/dist/ast.js.map +1 -1
  16. package/dist/authentication.d.ts +24 -0
  17. package/dist/authentication.js +68 -0
  18. package/dist/authentication.js.map +1 -0
  19. package/dist/call-workflow.d.ts +5 -0
  20. package/dist/call-workflow.js +4 -3
  21. package/dist/call-workflow.js.map +1 -1
  22. package/dist/call.d.ts +3 -2
  23. package/dist/call.js +34 -7
  24. package/dist/call.js.map +1 -1
  25. package/dist/cedar.js +14 -1
  26. package/dist/cedar.js.map +1 -1
  27. package/dist/change-steps.js +7 -5
  28. package/dist/change-steps.js.map +1 -1
  29. package/dist/change.d.ts +7 -0
  30. package/dist/change.js +7 -0
  31. package/dist/change.js.map +1 -1
  32. package/dist/client.d.ts +10 -6
  33. package/dist/client.js +96 -43
  34. package/dist/client.js.map +1 -1
  35. package/dist/collection.d.ts +7 -0
  36. package/dist/collection.js +5 -2
  37. package/dist/collection.js.map +1 -1
  38. package/dist/config.d.ts +14 -3
  39. package/dist/config.js +55 -14
  40. package/dist/config.js.map +1 -1
  41. package/dist/connection.d.ts +168 -0
  42. package/dist/connection.js +327 -0
  43. package/dist/connection.js.map +1 -0
  44. package/dist/credentials.d.ts +7 -53
  45. package/dist/credentials.js +84 -247
  46. package/dist/credentials.js.map +1 -1
  47. package/dist/cucumber.d.ts +4 -2
  48. package/dist/cucumber.js +8 -3
  49. package/dist/cucumber.js.map +1 -1
  50. package/dist/db.js +21 -2
  51. package/dist/db.js.map +1 -1
  52. package/dist/define.d.ts +9 -2
  53. package/dist/define.js.map +1 -1
  54. package/dist/definitions.d.ts +20 -0
  55. package/dist/definitions.js +16 -0
  56. package/dist/definitions.js.map +1 -0
  57. package/dist/deployment-key.d.ts +2 -0
  58. package/dist/deployment-key.js +22 -0
  59. package/dist/deployment-key.js.map +1 -0
  60. package/dist/describe.d.ts +9 -177
  61. package/dist/describe.js +71 -34
  62. package/dist/describe.js.map +1 -1
  63. package/dist/description.d.ts +532 -0
  64. package/dist/description.js +381 -0
  65. package/dist/description.js.map +1 -0
  66. package/dist/direct.js +3 -0
  67. package/dist/direct.js.map +1 -1
  68. package/dist/drift.d.ts +1 -1
  69. package/dist/drift.js +1 -1
  70. package/dist/drift.js.map +1 -1
  71. package/dist/env.d.ts +2 -0
  72. package/dist/env.js +2 -2
  73. package/dist/env.js.map +1 -1
  74. package/dist/index.d.ts +10 -2
  75. package/dist/index.js +10 -2
  76. package/dist/index.js.map +1 -1
  77. package/dist/ledger.d.ts +2 -0
  78. package/dist/ledger.js +1 -1
  79. package/dist/ledger.js.map +1 -1
  80. package/dist/lint.d.ts +0 -9
  81. package/dist/lint.js +1 -16
  82. package/dist/lint.js.map +1 -1
  83. package/dist/load.d.ts +74 -0
  84. package/dist/load.js +357 -0
  85. package/dist/load.js.map +1 -0
  86. package/dist/lockfile.d.ts +2 -0
  87. package/dist/lockfile.js +8 -3
  88. package/dist/lockfile.js.map +1 -1
  89. package/dist/op.d.ts +10 -1
  90. package/dist/op.js +1 -1
  91. package/dist/op.js.map +1 -1
  92. package/dist/outline.js +13 -4
  93. package/dist/outline.js.map +1 -1
  94. package/dist/paths.d.ts +18 -0
  95. package/dist/paths.js +21 -3
  96. package/dist/paths.js.map +1 -1
  97. package/dist/plugin.d.ts +0 -11
  98. package/dist/plugin.js +3 -18
  99. package/dist/plugin.js.map +1 -1
  100. package/dist/policy.d.ts +2 -0
  101. package/dist/policy.js.map +1 -1
  102. package/dist/resource.d.ts +27 -4
  103. package/dist/resource.js +49 -4
  104. package/dist/resource.js.map +1 -1
  105. package/dist/resources.d.ts +33 -76
  106. package/dist/resources.js +219 -227
  107. package/dist/resources.js.map +1 -1
  108. package/dist/run.d.ts +2 -0
  109. package/dist/run.js.map +1 -1
  110. package/dist/sandbox.d.ts +1 -1
  111. package/dist/sandbox.js +7 -3
  112. package/dist/sandbox.js.map +1 -1
  113. package/dist/scenario.d.ts +7 -0
  114. package/dist/scenario.js +33 -3
  115. package/dist/scenario.js.map +1 -1
  116. package/dist/scenarios.js +7 -1
  117. package/dist/scenarios.js.map +1 -1
  118. package/dist/schema.js +8 -0
  119. package/dist/schema.js.map +1 -1
  120. package/dist/secret-store-check.d.ts +11 -0
  121. package/dist/secret-store-check.js +76 -0
  122. package/dist/secret-store-check.js.map +1 -0
  123. package/dist/secret-store.d.ts +106 -0
  124. package/dist/secret-store.js +228 -0
  125. package/dist/secret-store.js.map +1 -0
  126. package/dist/shared.d.ts +17 -11
  127. package/dist/shared.js +34 -7
  128. package/dist/shared.js.map +1 -1
  129. package/dist/sites.d.ts +4 -93
  130. package/dist/sites.js +61 -84
  131. package/dist/sites.js.map +1 -1
  132. package/dist/source.d.ts +6 -0
  133. package/dist/source.js +13 -0
  134. package/dist/source.js.map +1 -1
  135. package/dist/steps.d.ts +3 -1
  136. package/dist/steps.js.map +1 -1
  137. package/dist/version.d.ts +2 -1
  138. package/dist/version.js +2 -1
  139. package/dist/version.js.map +1 -1
  140. package/dist/wakeup-driver.js +15 -6
  141. package/dist/wakeup-driver.js.map +1 -1
  142. package/dist/worker.js +12 -8
  143. package/dist/worker.js.map +1 -1
  144. package/dist/writer.d.ts +53 -14
  145. package/dist/writer.js +113 -43
  146. package/dist/writer.js.map +1 -1
  147. package/oxlint.json +0 -5
  148. package/package.json +6 -2
  149. package/src/apply-steps.ts +5 -2
  150. package/src/apply.ts +70 -29
  151. package/src/approvals.ts +7 -1
  152. package/src/apps.ts +54 -85
  153. package/src/ast.ts +6 -6
  154. package/src/authentication.ts +74 -0
  155. package/src/call-workflow.ts +5 -4
  156. package/src/call.ts +53 -9
  157. package/src/cedar.ts +14 -1
  158. package/src/change-steps.ts +7 -4
  159. package/src/change.ts +10 -0
  160. package/src/client.ts +108 -41
  161. package/src/collection.ts +10 -2
  162. package/src/config.ts +72 -13
  163. package/src/connection.ts +446 -0
  164. package/src/credentials.ts +94 -281
  165. package/src/cucumber.ts +16 -3
  166. package/src/db.ts +21 -2
  167. package/src/define.ts +7 -2
  168. package/src/definitions.ts +31 -0
  169. package/src/deployment-key.ts +23 -0
  170. package/src/describe.ts +102 -230
  171. package/src/description.ts +426 -0
  172. package/src/direct.ts +3 -0
  173. package/src/drift.ts +10 -2
  174. package/src/env.ts +4 -2
  175. package/src/index.ts +42 -1
  176. package/src/ledger.ts +3 -1
  177. package/src/lint.ts +1 -16
  178. package/src/load.ts +437 -0
  179. package/src/lockfile.ts +10 -3
  180. package/src/op.ts +10 -1
  181. package/src/outline.ts +18 -5
  182. package/src/paths.ts +24 -2
  183. package/src/plugin.ts +3 -24
  184. package/src/policy.ts +2 -0
  185. package/src/resource.ts +67 -6
  186. package/src/resources.ts +246 -301
  187. package/src/run.ts +2 -0
  188. package/src/sandbox.ts +11 -3
  189. package/src/scenario.ts +45 -3
  190. package/src/scenarios.ts +7 -1
  191. package/src/schema.ts +10 -0
  192. package/src/secret-store-check.ts +93 -0
  193. package/src/secret-store.ts +314 -0
  194. package/src/shared.ts +35 -10
  195. package/src/sites.ts +70 -180
  196. package/src/source.ts +14 -0
  197. package/src/steps.ts +3 -1
  198. package/src/version.ts +2 -1
  199. package/src/wakeup-driver.ts +22 -6
  200. package/src/worker.ts +12 -8
  201. package/src/writer.ts +145 -56
  202. package/dist/datafile.d.ts +0 -97
  203. package/dist/datafile.js +0 -479
  204. package/dist/datafile.js.map +0 -1
  205. package/src/datafile.ts +0 -601
package/README.md CHANGED
@@ -53,9 +53,9 @@ export const shop = withSetup(defineConnector("shop", specs, info), {
53
53
 
54
54
  `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.
55
55
 
56
- A connector that signs visitors in to a public site attaches a login half too, as `login`: `withSetup(connector, { login: (context) => half })`. The app calls it once per public site whose `site.ts` names the connector (`defineSite({ public: true, login: "<vendor>" })`), when it starts, with a `LoginContext` (the database, a key of its own derived from `SANOMA_SECRETS_KEY` to sign its cookies with, a vendor's secrets, the site's origins); a login that keeps tables in the database migrates them there, serialized and idempotent. The `LoginHalf` it makes (see its type) answers the provider's stable subject for a request, never a principal: the app makes the visitor's principal from it, so a connector cannot set a visitor's groups. A visitor's address, for rate limits, is the `CLIENT_IP_HEADER` header the app sets, never `X-Forwarded-For`. One vendor's connectors attach one `login`, as for a driver. [`@sanoma-ai/connector-better-auth`](https://www.npmjs.com/package/@sanoma-ai/connector-better-auth) is one, email and password on the company's database. The app makes it as it starts and sends it every request to the site's own host ([public sites](https://www.npmjs.com/package/@sanoma-ai/app#public-sites)).
56
+ A connector that signs visitors in to a public site attaches a login half too, as `login`: `withSetup(connector, { login: (context) => half })`. The app calls it once per public site whose `site.ts` names the connector (`defineSite({ public: true, login: "<vendor>" })`), when it starts, with a `LoginContext` (the database, a key of its own derived from `SANOMA_SIGNING_KEY` to sign its cookies with, a vendor's secrets, the site's origins); a login that keeps tables in the database migrates them there, serialized and idempotent. The `LoginHalf` it makes (see its type) answers the provider's stable subject for a request, never a principal: the app makes the visitor's principal from it, so a connector cannot set a visitor's groups. A visitor's address, for rate limits, is the `CLIENT_IP_HEADER` header the app sets, never `X-Forwarded-For`. One vendor's connectors attach one `login`, as for a driver. [`@sanoma-ai/connector-better-auth`](https://www.npmjs.com/package/@sanoma-ai/connector-better-auth) is one, email and password on the company's database. The app makes it as it starts and sends it every request to the site's own host ([public sites](https://www.npmjs.com/package/@sanoma-ai/app#public-sites)).
57
57
 
58
- A model connector attaches a model half, as `model`: `withSetup(connector, { model: (context) => provider })`, an AI SDK provider (`ProviderV4` from `@ai-sdk/provider`) with the vendor's adapter as the transport. The app makes it for its model registry, once per resolved config and client (again after one that throws), where its models are `<vendor>/<model-id>`; it answers `languageModel("default")` with the connector's own default. Its `ModelContext` gives it the secrets as a login's does (the environment's, then the stored credentials), which it reads when a model is called, so a key set on the connector page is used without a restart (unless the environment sets one, which wins); its driver declares the key in `env`, with no operations if it needs none. One vendor's connectors attach one `model`. [`@sanoma-ai/connector-anthropic`](https://www.npmjs.com/package/@sanoma-ai/connector-anthropic) is one.
58
+ A model connector attaches a model half, as `model`: `withSetup(connector, { model: (context) => provider })`, an AI SDK provider (`ProviderV4` from `@ai-sdk/provider`) with the vendor's adapter as the transport. The app makes it for its model registry, once per resolved config and client (again after one that throws), where its models are `<vendor>/<model-id>`; it answers `languageModel("default")` with the connector's own default. Its `ModelContext` gives it the secrets as a login's does (the selected store and explicit bindings), which it reads when a model is called; its driver declares the key in `env`, with no operations if it needs none. One vendor's connectors attach one `model`. [`@sanoma-ai/connector-anthropic`](https://www.npmjs.com/package/@sanoma-ai/connector-anthropic) is one.
59
59
 
60
60
  A git host's connector attaches a remote half, as `remote`: `withSetup(connector, { remote: () => half })`, a `RemoteHalf` made once per resolved config and kept in `ResolvedConfig.remotes` by vendor; `remoteFor(remotes, url)` finds the one whose `matches` reads a clone URL, with the repository. `open(repo, { secrets })` gives a `SourceHost`: the repository's pull requests (find, open, squash-merge at a head, close), the `sanoma` commit status, and `enforcement(base)`, whether the host refuses a change to the branch that is not a merge (`HostEnforcement`); its `token(prefer)` is what a push signs in with. `webhook(request, { secrets })` verifies a delivery and says what happened (`RemoteEvent`: a branch pushed, a pull request closed). It reads its tokens through `secrets` when called, as a model half does. One vendor's connectors attach one `remote`. [`@sanoma-ai/connector-github`](https://www.npmjs.com/package/@sanoma-ai/connector-github#remote-half) is one.
61
61
 
@@ -78,7 +78,7 @@ That is the whole config; the rest is convention, from its `root` (`defineConfig
78
78
  - **The [scenarios](#scenarios-and-sandbox-runs)** are the `.feature` files of `<root>/scenarios/` and of each [app's](#apps) `scenarios/`, named by their path from the root (`scenarios/x.feature`, `apps/crm/scenarios/y.feature`); without any such directory the config has none.
79
79
  - **The [policy](#policies)** is `<root>/policies/**/*.cedar` and each [app's](#apps) `policies/**/*.cedar`, decided as the [people](#people) the data files declare; without any, every call and start is allowed (`allowAll`).
80
80
 
81
- The [data files](#data-files) are `resources/` in the root and in each [app](#apps). `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.
81
+ The [data files](#data-files) are `resources/` in the root and in each [app](#apps). `ledger` and `databaseUrl` configure persistence; `secrets`, `connections` and `connectionRules` configure [credential storage and account identity](#credentials). `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
82
 
83
83
  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`, the policies it is made from as `policies`, and what is wrong in them 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
84
 
@@ -116,17 +116,17 @@ The worker, the client and the app find Postgres at the config's `databaseUrl`,
116
116
 
117
117
  ### Apps
118
118
 
119
- A company's apps are the folders of `apps/`, each holding what the root does: `workflows/`, `resources/` (its `data.ts` too), `policies/`, `scenarios/` and `sites/`. An app is merged into the repo from a source repository, or made there; its `app.ts` says which, `export default defineApp({ source: "https://github.com/acme/crm" })` (`defineApp({})` for the company's own), read with oxc and never run, as `readApps(root)` reads each (`AppEntry`, `{ name, dir, source? }`, by name). A folder's name is the app's (`APP_NAME`: a lowercase letter, then lowercase letters and digits); `describeConfig` refuses another as a problem, and lists the apps as `apps`.
119
+ A company's apps are the folders of `apps/`, each holding what the root does: `workflows/`, `resources/` (its `data.ts` too), `policies/`, `scenarios/` and `sites/`. An app is merged into the repo from a source repository, or made there; its `app.ts` says which, `export default defineApp({ source: "https://github.com/acme/crm" })` (`defineApp({})` for the company's own), which `readApps(root)` loads (`AppEntry`, `{ name, dir, source?, at? }`, by name; `defineApp` served by the [loader](#data-files)): a default export that is not `defineApp({ source })` with `source` a string, or an `app.ts` that throws, is a problem. A folder's name is the app's (`APP_NAME`: a lowercase letter, then lowercase letters and digits); `describeConfig` refuses another as a problem, and lists the apps as `apps`.
120
120
 
121
121
  What an app's folder declares is named with the folder, from where its file is (`appOfFile`: the nearest directory with a `sanoma.config.ts`, then `apps/<app>/`), never written: a collection `customers` in `apps/crm/resources/data.ts` is `crm.customers`, its operations `data.crm.customers.get` and so on, and a workflow `follow-up` in `apps/crm/workflows/` is `crm.follow-up`, each with `app: "crm"`; what the root declares keeps its name. So two apps may declare one name. A file is an app's while the nearest directory above it with a `sanoma.config.ts` is the company's root (an app may keep its own `package.json`; a `node_modules` ends the search). A workflow calls an app's collection as `ctx.data.crm.customers.get(…)`, and a site as `api.data.crm.customers.list()`; an app's workflow is `api.crmFollowUp` (`apiNameOf`), and its Cedar action `workflow:crm.follow-up`. A site in `apps/crm/sites/pipeline/` is `crm/pipeline`, at `/sites/crm/pipeline`. `describeConfig`'s workflows, collections and sites carry their `app`.
122
122
 
123
123
  The people are the company's: an app's data files declare none (`readPeople` refuses one, naming its file). A workflow may import the root's `resources/data.ts` and any app's. A relative import from one app's files into another's folder is a warning (`crossAppImports(root, apps)`, from `@sanoma-ai/workflows/describe`, which `sanoma check` prints), since a company may take one app without the other: reach the other through `ctx` or `api` instead. A collection or site at the root named as an app is a problem: `api.data.<app>` and `/sites/<app>/` are the app's.
124
124
 
125
- Taking an app is a git merge into a draft of the company's `Source` (the `sanoma` package's `localGitSource`). `draft.take({ url, ref?, folder?, author })` fetches the source repository, a public company repo holding one `apps/<folder>/` (`folder` names it when it holds more), by https alone, and merges it with its history as one merge commit. `draft.update({ folder, author })` merges the new commits of the default branch of the URL the app's `app.ts` names, and `source.behind(folder)` counts them, fetched now (both refuse a source that shares no history with the checkout). Either merge takes `apps/<folder>/` (a take, the source's folder whole) and `package.json` (its connectors, both sides kept); every other file stays the company's, as does a file one side deleted, since the source's root is the starter's. Both return an `AppMerge` (`{ folder, revision, merged, conflicts }`): a line both sides changed is committed with git's markers (diff3: ours, the base, theirs) and its file named in `conflicts`, so the draft's checks fail until the agent resolves it. An update needs the take's merge commit in the history: a draft that takes an app is merged into `main` as a merge, not squashed.
125
+ Taking an app is a git merge into a draft of the company's `Source` (the `sanoma` package's `localGitSource`). `draft.take({ url, ref?, folder?, author })` fetches the source repository, a public company repo holding one `apps/<folder>/` (`folder` names it when it holds more), by https alone, and merges it with its history as one merge commit. `draft.update({ folder, author })` merges the new commits of the default branch of the URL the app's `app.ts` names, and `source.behind(folder)` counts them, fetched now (both refuse a source that shares no history with the checkout). An update reads the source from `main`'s `app.ts`, never the draft's, since a draft's files are never loaded: an app only in a draft is refused until its take is published. Either merge takes `apps/<folder>/` (a take, the source's folder whole) and `package.json` (its connectors, both sides kept); every other file stays the company's, as does a file one side deleted, since the source's root is the starter's. Both return an `AppMerge` (`{ folder, revision, merged, conflicts }`): a line both sides changed is committed with git's markers (diff3: ours, the base, theirs) and its file named in `conflicts`, so the draft's checks fail until the agent resolves it. An update needs the take's merge commit in the history: a draft that takes an app is merged into `main` as a merge, not squashed.
126
126
 
127
127
  ### People
128
128
 
129
- 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:
129
+ People, groups and agents are resources of the built-in `sanoma` connector, declared in a [data file](#data-files) (`resources/people.ts` by convention), loaded as any other:
130
130
 
131
131
  ```ts
132
132
  import { sanoma } from "@sanoma-ai/workflows/resources"; // `sanoma/resources` in a company repo
@@ -137,7 +137,7 @@ export const refundBot = sanoma.agent({ id: "refund-bot", onBehalfOf: bo });
137
137
  export const finance = sanoma.group({ name: "finance", members: [ada] });
138
138
  ```
139
139
 
140
- 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`, `wakeup`, which registers a wakeup) 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.
140
+ 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`, `wakeup`, which registers a wakeup) are declared by no file. They have no vendor object: no driver, no drift. `readPeople(root)` loads them from the files that may declare them (`mayDeclarePeople`: those importing `sanoma` from `sanoma/resources`), again only when one of them changed, so a request costs a `stat` per file, 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`, `clearCredential` and `clearSecret` 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.
141
141
 
142
142
  A visitor of a public site is a principal too, which no data file declares: `visitorPrincipal(login, subject)` is one signed in through a visitor login (`visitor:<login>:<subject>`, in the group `visitors`), and `ANONYMOUS_PRINCIPAL` the one a page with nobody signed in runs as (`anonymous`, in no group); `isVisitor(principal)` tells either by its id. `admit` takes a visitor's id whatever the files say and stamps its groups, never the ones given; the staff logins (a sign-in) admit none, and a visitor sets no credential and decides no approval. So `visitors` and `anonymous` are no group's name, and `anonymous` and the `visitor:…` form no person's or agent's id (`readPeople` reports them, as it does `agents`).
143
143
 
@@ -163,7 +163,7 @@ permit (principal == Sanoma::Principal::"anonymous", action == Sanoma::Action::"
163
163
  - **The schema**, namespace `Sanoma`: `Principal` in `Group` (an enum of the declared groups, `agents` and `visitors`, 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`; `sanoma.change.merge` on a `Change` instead, whose attributes are the change's facts; `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`.
164
164
  - **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.
165
165
  - **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. A request with `inRun: true` asks about an operation inside a run, not a direct call, which only a visitor's answer tells apart.
166
- - **A change** to the company's repository is decided on its merge, `sanoma.change.merge`, one of four operations of the built-in connector `sanoma` (`changeOps`: `sanoma.change.open`, `.review`, `.merge` and `.close`, effect `write`) that every config has and only the built-in `change` workflow may call, so a policy names it in every repo. The connector's fifth operation, `sanoma.apps.list` (`APPS_LIST`, a read), lists the apps on GitHub (the repositories with the topic `sanoma-app`) with Sanoma's claims about each (`claims.ts`: tier, review, blocked commits, the solutions holding it); a page calls it directly, as the person, so the policy decides it like any direct call, and every config has the built-in `call` workflow for it. Its resource is a `Sanoma::Change` whose attributes are the facts of the change (`ChangeFacts`): `touches` (each path it edits and each parent directory, `touchesOf`: `resources/`, `resources/finance/`, `resources/finance/cards.ts`), `strandedRuns` and `stranded` (the waiting runs of a workflow it edits), `checksOk` and `rules`. Cedar has no quantifier over a set and `like` reads one string, so a rule asks `contains` of a directory, and an app's files are under its own (`apps/crm/resources/`), which a rule on `resources/` does not cover: `forbid (principal, action == Sanoma::Action::"sanoma.change.merge", resource is Sanoma::Change) when { resource.touches.contains("resources/finance/") } unless { principal in Sanoma::Group::"finance" };`. A preview asks with the facts unknown: a rule on any change holds, a rule on the facts depends. Before any policy, the runtime's own rule (`runtimeHold`): a merge of a change to the rules (`rules`: a policy, the root's or an app's, or a people file) waits for the group `admin`, with the reason `sanoma:rules-wait-for-admin`, under `allowAll` too, and is denied while no data file declares `admin`; the people are the running instance's, so a change cannot add its author to `admin`. The company's policy is asked once an approval addressed to `admin` covering the merge lifts it. The template's `policies/changes.cedar` holds every change for `admin`.
166
+ - **A change** to the company's repository is decided on its merge, `sanoma.change.merge`, one of four operations of the built-in connector `sanoma` (`changeOps`: `sanoma.change.open`, `.review`, `.merge` and `.close`, effect `write`) that every config has and only the built-in `change` workflow may call, so a policy names it in every repo. The connector's fifth operation, `sanoma.apps.list` (`APPS_LIST`, a read), lists the apps on GitHub (the repositories with the topic `sanoma-app`) with Sanoma's claims about each (`claims.ts`: tier, review, blocked commits, the solutions holding it); a page calls it directly, as the person, so the policy decides it like any direct call, and every config has the built-in `call` workflow for it. Its resource is a `Sanoma::Change` whose attributes are the facts of the change (`ChangeFacts`): `touches` (each path it edits and each parent directory, `touchesOf`: `resources/`, `resources/finance/`, `resources/finance/cards.ts`), `strandedRuns` and `stranded` (the waiting runs of a workflow it edits), `checksOk` and `rules`. Cedar has no quantifier over a set and `like` reads one string, so a rule asks `contains` of a directory, and an app's files are under its own (`apps/crm/resources/`), which a rule on `resources/` does not cover: `forbid (principal, action == Sanoma::Action::"sanoma.change.merge", resource is Sanoma::Change) when { resource.touches.contains("resources/finance/") } unless { principal in Sanoma::Group::"finance" };`. A preview asks with the facts unknown: a rule on any change holds, a rule on the facts depends. Before any policy, the runtime's own rule (`runtimeHold`): a merge of a change to the rules (`rules`: a policy, the root's or an app's, a people file, or a company file `main`'s people files import, `peopleSources`) waits for the group `admin`, with the reason `sanoma:rules-wait-for-admin`, under `allowAll` too, and is denied while no data file declares `admin`; the people are the running instance's, so a change cannot add its author to `admin`. The company's policy is asked once an approval addressed to `admin` covering the merge lifts it. The template's `policies/changes.cedar` holds every change for `admin`.
167
167
  - **Problems** (a file that does not parse, two policies with one id, a template, what Cedar's strict validation finds, a `permit` for anyone but the visitors or `anonymous`, or with a `@hold`, a `@hold` naming no declared group or person, `visitors` among them) 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.
168
168
 
169
169
  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.
@@ -204,7 +204,7 @@ A change is how an edit to the company's repository goes live once the repositor
204
204
  - **New commits** on the draft start a new round at the new head. The agent tells the change after it commits, which withdraws Publish; a push to the branch on the host, told by its webhook, is read when Publish is decided, which then plans again instead of merging. An approval given in an earlier round lifts no hold on the new head.
205
205
  - **Publish**, approved, squash-merges the pull request at the head the round planned, titled `<title> (#<number>)`, sets the status to success and deletes the branch (`sanoma.change.merge`); a head that moved meanwhile is planned again, and a merge the host refuses waits. Then the deploy: the checkout follows `main` (a fast-forward, skipped while another branch is checked out, the checkout has uncommitted changes or its `main` has commits the remote's lacks), apply runs on the declared hosts as the run `<run>:deploy`, drift reads them back as `<run>:verify`, and the draft is closed. The page says Published, with the merge commit and what the deploy did. The change run outlives the deploy's restart of the worker on the merged code ([Versions](#versions)).
206
206
  - **Reject** on Publish closes the pull request and the change. A pull request merged on the host deploys as Publish does, and one closed there closes the change: the run checks either on the host before it believes it.
207
- - **Who publishes.** A change to the rules (a policy, the root's or an app's, or a people file) waits for `admin` whatever the policies say ([Policies](#policies)). A new company's `policies/changes.cedar` holds every other change for `admin` too; replace it with rules on the change's facts when another group should own part of the repository.
207
+ - **Who publishes.** A change to the rules (a policy, the root's or an app's, a people file, or a helper one imports) waits for `admin` whatever the policies say ([Policies](#policies)). A new company's `policies/changes.cedar` holds every other change for `admin` too; replace it with rules on the change's facts when another group should own part of the repository.
208
208
  - **The Permissions line.** Under the Permissions grid the app says what the repository's host enforces on `main` (`SourceHost.enforcement`): a pull request, and the `sanoma` check. Where it enforces neither (a private repository on GitHub Free), a push to `main` that does not go through Sanoma is neither reviewed nor decided by the policy.
209
209
 
210
210
  ### Calls run one at a time
@@ -237,17 +237,132 @@ Every call is a step with a name, which a test and the graph name it by: `ctx.gi
237
237
 
238
238
  ### Drivers
239
239
 
240
- 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)).
240
+ 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 the authenticated `principal` and selected `connection` metadata, and `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)).
241
241
 
242
242
  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.
243
243
 
244
244
  ### Credentials
245
245
 
246
- A driver reads its credentials from the environment when it is called, and declares them in `defineDriver`'s third argument: `defineDriver(acme, impl, { env: z.object({ ACME_API_KEY: z.string().describe("an API key, from Settings, API keys"), ACME_REGION: z.enum(["us", "eu"]).optional().describe("the region; default us") }) })`. Each key is a variable's name, its `.describe()` the one-line manual, `.optional()` marks one that may be unset. The driver reads the raw environment, so `.default()` and `.transform()` are not applied to what it reads (use `.optional()` and name the default in `.describe()`), and a `.refine()` or `.superRefine()` on the `z.object` is not evaluated, only its fields. Only the declared names are read, an empty value counts as unset, and a value is never kept: each variable is set, missing or invalid, with the schema's reason.
246
+ The runtime separates a secret's storage, a connector's authentication method, and the named account used by a call. `secret("NAME")` remains a logical reference. `SanomaConfig.secrets` selects the store and maps names to locations; `connections` and `connectionRules` declare account requirements. Credential values stay outside code, workflow inputs, durable steps, descriptions and ledger records.
247
247
 
248
- The environment stays the only interface a driver sees, but a value need not come from a file. Locally, set values live in the `sanoma_credentials` table in the config's database (`databaseUrl`), which the runtime creates on first use, encrypted with AES-256-GCM under `SANOMA_SECRETS_KEY`, bound to its name (a value moved to another row does not decrypt) (32 random bytes, base64; `sanoma` writes one to `.env` when there is none): storing a value without the key fails, naming it. A value stored before encryption is read as it is, and encrypted on its next write. Set one from the app's connector page, or with `client.setCredential("GHOST_ADMIN_API_KEY", value, { by: "alice" })`, and delete it with `client.clearCredential(name, { by })`; `client.credentials()` gives each vendor's statuses, each with its `source` (`environment` or `stored`) and, for a stored one, who set it and when. A name no driver declares, or a value its schema refuses, is refused with the reason, never the value. `client.setSecret(name, value, { by })` stores a value under any variable's name, declared or not (a data file's `secret()`, or the app's own, such as its GitHub App's keys), checking a declared one as `setCredential` does, and `client.secrets(names)` reads values back for server code, the environment's first, with their `source` and who stored them. `startWorker` loads the stored values of the declared variables its environment leaves unset into `process.env` and listens for changes, so a value set or cleared is live without a restart: drivers read `process.env` when they are called. A worker whose listener drops reconnects and loads them again. A variable the process environment sets wins over a stored one, always. The worker starts whether or not every variable is there: it prints one line naming each vendor with a variable missing (and not optional) or invalid, `sanoma: 2 of 5 connectors not configured yet: ghost, resend; set their variables on the connector pages or in the environment` (`unconfigured` names them too; `quiet: true` prints nothing, as `runScenarios` does, its runs calling only the fakes). A call to that vendor then fails, as the call's own failure: a `DriverError` that is not retried, saying why, `<vendor> is not configured: <NAME> (<description>), <NAME> is invalid: <why>; set it on the connector page` (or `set in the environment`, when an invalid value is there: the environment wins over a stored value), checked against the environment at the moment of the call, so a value set on the connector page (or in `process.env`) takes effect on the next call, with no restart. The call's `op.called` record carries it, and the run fails with it like any other final driver failure. `startApp` starts the same way, and its connector pages show and set them.
248
+ The store resolver is used by the worker's `call.secrets`, resource secret resolution, `SanomaClient` reads and mutations, and the app's login/model/remote secret lookup. It does not copy values into `process.env` or fall back to another store. Some shipped connector drivers still declare an `env` schema and read process variables themselves; migrating those drivers and their SDK/bridge sessions to connection-scoped authentication is a separate step. Registering a named connection does not by itself migrate such a driver.
249
249
 
250
- The values are plain text in the local database: the same exposure as a `.env` file on the same disk. Keep `.env` (`node --env-file-if-exists=.env`) for the bootstrap values a worker needs before it can read the database: the database URL, the bridge binary, the port. A value never reaches the ledger or the description; see [Secrets](#secrets) for why one must never be an input either.
250
+ `client.setCredential(name, value, { by })` validates a driver-declared name and writes through the configured binding. `client.setSecret(name, value, { by })` also accepts undeclared logical names. `client.clearCredential(name, { by })` conditionally deletes a declared value; `client.clearSecret(name, { by })` also accepts undeclared logical names. These operations refuse an externally managed binding or a read-only store. `client.credentials()` returns statuses, storage location, ownership and available change metadata without values; `client.secrets(names)` returns values to trusted server code only. The provider is validated when the client or worker starts; missing connector credentials can still be entered later.
251
+
252
+ #### Secret store configuration
253
+
254
+ ```ts
255
+ // sanoma.config.ts
256
+ import { defineConfig, type SecretConfiguration } from "sanoma";
257
+
258
+ const secrets = {
259
+ namespace: "local",
260
+ bindings: {
261
+ SUPPORT_AUTH: { path: "mail/support", management: "sanoma" },
262
+ FINANCE_AUTH: { path: "mail/finance", management: "external" },
263
+ BUILD_TOKEN: { env: "CI_BUILD_TOKEN" },
264
+ SANOMA_SIGNING_KEY: { bootstrap: "SANOMA_SIGNING_KEY" },
265
+ SANOMA_SECRETS_KEY: { bootstrap: "SANOMA_SECRETS_KEY" },
266
+ },
267
+ } satisfies SecretConfiguration;
268
+
269
+ export default defineConfig({ connectors: [], secrets });
270
+ ```
271
+
272
+ Without `store`, the runtime uses its encrypted Postgres adapter. Without `namespace`, it uses `default`. An unbound name resolves to the same path in that namespace, managed by Sanoma. A `{ path, management: "external" }` binding is read-only to Sanoma even when its store can write. An `{ env }` binding reads that exact variable, is read-only, and does not fall back when absent. A `{ bootstrap }` binding explicitly exposes one deployment key to trusted host-secret resolution; it reads the corresponding environment or mounted-file input and never stores the key in Postgres. Bootstrap keys and recovery are described [below](#bootstrap-keys).
273
+
274
+ An external adapter is a `SecretStore` instance supplied as `secrets.store`. It authenticates to one deployment's authorized storage before Sanoma can read values. Prefer a scoped workload identity where the adapter supports it; otherwise deliver its bootstrap credential through the host. Do not make that credential depend on reading the same store it unlocks. AWS Secrets Manager, Supabase Vault, HashiCorp Vault, Cloudflare Secrets Store, managed KMS storage and transfer tooling are not shipped by this foundation.
275
+
276
+ Keep deployment composition in config code, outside workflows. For example, given an adapter instance already authenticated as `liveStore`:
277
+
278
+ ```ts
279
+ import type { SecretConfiguration, SecretStore } from "sanoma";
280
+
281
+ export function stores(liveStore: SecretStore) {
282
+ return {
283
+ local: { namespace: "local" },
284
+ preview: {
285
+ namespace: "preview",
286
+ bindings: { SUPPORT_AUTH: { env: "PREVIEW_MAIL_AUTH" } },
287
+ },
288
+ live: {
289
+ store: liveStore,
290
+ namespace: "live",
291
+ bindings: { SUPPORT_AUTH: { path: "mail/support", management: "external" } },
292
+ },
293
+ } satisfies Record<"local" | "preview" | "live", SecretConfiguration>;
294
+ }
295
+ ```
296
+
297
+ Pass exactly the selected configuration to `defineConfig`. A namespace is a routing boundary, not authorization: preview identities must be unable to read production values, and a built-in preview should have its own database and encryption key. Cloning the code grants no storage access. The config description exposes store ID, namespace and bindings, never the provider object or its credentials.
298
+
299
+ The adapter contract uses `read({ namespace, path })`, `write(address, value, { expectedVersion, by })`, `delete(address, { expectedVersion, by })` and `validate(namespace)`. A read returns an opaque version beside the value; `expectedVersion: null` creates only when absent, and a version replaces or deletes only that version. `ensure` uses create-if-absent and reads the winner of a concurrent creation. Providers advertise writable access, a byte limit, conditional-write guarantees (`atomic`, `coordinated` or `none`), deletion behavior and change detection. `coordinated` covers cooperating Sanoma writers only; it cannot promise safety against external writers. Unsupported conditional writes are refused, not emulated with an unsafe read followed by a write.
300
+
301
+ The built-in adapter stores one encrypted value or credential bundle per namespace/path, with a fresh version on each write. AES-256-GCM authenticates the complete namespace/path identity. It rejects plaintext rows and ciphertext moved to another address. `SecretStoreError` returns fixed codes without provider responses or secret values. Connector authentication declares its own versioned schema with `defineAuthentication`: `api-key`, `fields`, `oauth2` or `signed`, with storage requirements; `workload-identity` needs no persisted credential bundle. Connector authors use `checkAuthenticationStore`, `encodeCredentials` and `decodeCredentials` inside trusted code. These contracts do not implement OAuth consent, refresh or vendor account verification.
302
+
303
+ Adapter authors can run `checkSecretStore(store, disposableNamespace)` from `@sanoma-ai/workflows`. It checks missing values, ownership, contention, stale versions, namespace separation, size limits and the advertised deletion behavior. It mutates test records; use a scoped test identity and clean up retained versions according to the provider. `memorySecretStore()` is the in-process reference implementation, not persistent deployment storage.
304
+
305
+ #### Named connections
306
+
307
+ A connector declares authentication methods in `withSetup(connector, { authentication: { methodName: definition } })`. Code then declares each account independently, sharing the connector and its driver factory. For a connector `mail` that declares an `apiKey` method:
308
+
309
+ ```ts
310
+ import { defineConfig, defineConnection, defineConnectionRule } from "sanoma";
311
+ import { secret } from "sanoma/resources";
312
+ import { mail } from "@acme/mail";
313
+
314
+ const support = defineConnection(mail, {
315
+ name: "support",
316
+ method: "apiKey",
317
+ credentials: secret("SUPPORT_AUTH"),
318
+ scopes: ["messages:read"],
319
+ account: "support@example.test",
320
+ });
321
+ const sales = defineConnection(mail, {
322
+ name: "sales",
323
+ method: "apiKey",
324
+ credentials: secret("SALES_AUTH"),
325
+ scopes: ["messages:read"],
326
+ });
327
+ const customer = defineConnectionRule(mail, {
328
+ name: "customer",
329
+ method: "apiKey",
330
+ createBy: [{ group: "visitors" }],
331
+ scopes: ["messages:read"],
332
+ });
333
+
334
+ export default defineConfig({
335
+ connectors: [mail],
336
+ connections: [support, sales],
337
+ connectionRules: [customer],
338
+ });
339
+ ```
340
+
341
+ `@acme/mail` is an illustrative connector, not a shipped package. `support` is also a typed `ConnectionRef<"mail">`, with ID `mail/support`. A workflow selects it in operation options, for example `ctx.mail.message.read({ id: "one" }, { connection: { vendor: "mail", $connection: "mail/support" } })`; a server-side direct call passes `connection: support` alongside its `actor`. A connector with authentication methods requires a selector unless exactly one declaration is marked `default: true`; one connected account alone is not an implicit default. Existing business-resource targets remain unchanged. Cedar sees optional `context.connection` metadata: ID, vendor, method, account, scopes and revision.
342
+
343
+ Connection creation rules accept principal IDs or `{ group: "visitors" }` for authenticated customer sign-ups. The runtime uses admitted groups and owns each dynamic connection by the initiating principal. Disconnect invalidates the account revision and blocks new selection; vendor revocation and credential retirement are separate lifecycle work. Visitors also need a Cedar permit for the relevant operation; a creation rule does not bypass policy.
344
+
345
+ `sanoma.connection.bind` is a policy-controlled access operation. Its input contains a declared reference, the granted account and scopes, and `expectedRevision` (`null` for an initial binding). It persists metadata and a new revision, never credential values. `sanoma.connection.create` takes a declared rule ID, account and scopes; the actor must be in `createBy`. It creates an owner-scoped dynamic ID without a repository commit. Trusted code can make a typed reference with `connectionRef(rule, id)`, but the runtime still verifies the stored owner and declaration on use. These metadata operations are not proof of vendor consent; connector intake must verify the account before binding it.
346
+
347
+ A durable call pins its account revision before policy evaluation. A held direct call carries the same pin into its durable continuation. The runtime checks that the binding and definition still match before execution; rebinding or changing requirements requires a fresh call and authorization. Policy approvals cannot silently authorize a different account. The ledger records connection metadata, not its credential bundle. This changes the durable step layout; old runs are not replayed against the new layout.
348
+
349
+ Resource declarations use the same identity as an option, loaded with the rest of the data file:
350
+
351
+ ```ts
352
+ import { mail } from "@acme/mail/resources";
353
+
354
+ export const inbox = mail.inbox({ name: "support" }, { connection: { vendor: "mail", $connection: "mail/support" } });
355
+ ```
356
+
357
+ The resource type must belong to that connector. Apply and drift pin the selected revision when they read declarations, then carry it through imports, plans and effects. The resource lockfile records the connection alongside the vendor object ID. Changing the account of a bound resource requires an explicit unbind/rebind; a default change or a same-name account replacement cannot redirect a later apply. Named sandbox account state and the connection-management UI remain follow-up work.
358
+
359
+ #### Bootstrap keys
360
+
361
+ `SANOMA_SIGNING_KEY` signs application sessions and authorization state. `SANOMA_SECRETS_KEY` encrypts rows only for the built-in Postgres store. They are independent 32-byte random values encoded as base64. Each accepts exactly one source: the variable itself or its `_FILE` counterpart, whose file holds that base64 value. Conflicting sources, unreadable files and malformed values fail without printing the key. The launcher creates missing local keys in ignored `.env`; newly created files have mode `0600`. With an external store, it creates only the signing key.
362
+
363
+ For a host deployment, deliver signing material, database authentication and the adapter's bootstrap through that host's secret delivery mechanism; include the encryption key only for the built-in store. A mount path is local to the process reading it: do not send a laptop's `_FILE` path to a remote container. Explicit bootstrap bindings let a host resource receive the key value through its secret channel while code keeps only the reference.
364
+
365
+ Back up ciphertext, its namespace/path and version metadata, and the matching encryption key separately. A database-only backup cannot recover encrypted values. Preserve the signing key across ordinary restarts; replacing it invalidates sessions and pending signed authorization state. Changing a vendor token updates a stored value and its version; it does not rotate the row-encryption key. Replacing the encryption key without re-encrypting every retained value makes those values unreadable. Automated encryption-key rotation, external-store migration and OAuth reauthorization tooling are not included here.
251
366
 
252
367
  ### Resources
253
368
 
@@ -277,9 +392,9 @@ A resource type is a connector's group under its own `type`; `defineConnector` r
277
392
 
278
393
  A `writable` type has four change operations besides, which the built-in apply workflow (`APPLY_WORKFLOW`, `"apply"`, a name a config's own workflow may not take) calls and no other: `resolveConfig` refuses a workflow whose `uses` names `create`, `update` or `delete` of a declared type, and a run of any other workflow is refused the call. `plan` (effect `read`, idempotent) takes `{ id?, state?, handle?, desired? }` (no `id` for a create, no `desired` for a delete) and returns `{ action, planned?, unknown, replace }`: `none`, `create`, `update`, `replace` or `delete`, the new state without write-only fields, the dotted paths known only after apply, and those whose change replaces the object. `create` (effect `write`, `target` `new:<vendor>.<type>`) takes `{ desired }` and returns `{ id, state, handle? }`, `id` its binding id; `update` takes `{ id, state?, handle?, desired, expect? }` and returns the same, refusing with a `DriverError` whose `vendorCode` is `plan_changed` when the fresh plan's `action` or `replace` differs from `expect`; `delete` (idempotent) takes `{ id, state?, handle? }` and returns `{ id, gone: true }`, an object already gone included. `desired` may hold secret markers at write-only fields, which the driver resolves (`resolveSecrets`) just before the vendor call (`plan` stores no generated secret: one not stored yet gets a throwaway value); no output holds a write-only value.
279
394
 
280
- Calling the type declares one resource, which is what a data file does: `repository({ name: "sanoma" })` returns a frozen `Declared`, `{ vendor: "github", type: "repository", name: "sanoma", desired: { name: "sanoma" }, refs: {} }`, branded with its `<vendor>.<type>` under the `DECLARED` symbol. It refuses a field the schema does not have, a field the vendor owns (unless `identity` names it), a value the schema rejects, or an empty identity. A type without `find` (Stripe's, whose objects Stripe names) declares resources without a `name`: they are found only by their bindings ([below](#bindings)).
395
+ Calling the type declares one resource, which is what a data file does: `repository({ owner: "acme", name: "sanoma" })` returns a frozen `Declared`, `{ vendor: "github", type: "repository", name: "acme/sanoma", desired: { owner: "acme", name: "sanoma" }, refs: {} }`, branded with its `<vendor>.<type>` under the `DECLARED` symbol. It refuses a field the schema does not have, a field the vendor owns (unless `identity` names it), a value the schema rejects, or an empty identity. A type without `find` (Stripe's, whose objects Stripe names) declares resources without a `name`: they are found only by their bindings ([below](#bindings)).
281
396
 
282
- A field may name another declared resource where the type's `fields.references` says so, by dotted path, with the type it names and, in `by`, the fields of that resource the vendor may hold there instead: GitHub's `branch_protection` has `{ repository_id: { type: "github.repository", by: ["name", "node_id"] } }`, since GitHub holds a rule's repository by its node id. So `branch_protection({ repository_id: site, pattern: "main" })` takes the repository `site`. It stands for that resource's `name`: `desired` holds `repository_id: "website"`, which the schema and `find` (the rule is `website:main`) read, and a drift check takes any of the repository's `by` values, read in the same run, as that name; and `refs` says where a resource was named, `{ repository_id: "github.repository:website" }` (a list item's path has its index, `teams.0`). Anywhere else, or of another type, a resource is refused, and the constructor's type (`Declaring`) says the same, so the editor marks it first.
397
+ A field may name another declared resource where the type's `fields.references` says so, by dotted path, with the type it names and, in `by`, the fields of that resource the vendor may hold there instead: GitHub's `branch_protection` has `{ repository_id: { type: "github.repository", by: ["name", "node_id"] } }`, since GitHub holds a rule's repository by its node id. So `branch_protection({ repository_id: site, pattern: "main" })` takes the repository `site`. It stands for that resource's `name`: `desired` holds `repository_id: "acme/website"` (a GitHub repository's identity is `<owner>/<name>`), which the schema and `find` (the rule is `acme/website:main`) read, and a drift check takes any of the repository's `by` values, read in the same run, as that name; and `refs` says where a resource was named, `{ repository_id: "github.repository:acme/website" }` (a list item's path has its index, `teams.0`). Anywhere else, or of another type, a resource is refused, and the constructor's type (`Declaring`) says the same, so the editor marks it first.
283
398
 
284
399
  Only declared fields are compared for drift, as CloudFormation does: `compareDeclared(fields, state, desired)` keeps the fields `desired` declares, minus `vendorOwned` and `writeOnly` ones, picking objects field by field, list items against the declared item at the same index, and the items of an `unordered` list (a set) against what any declared item declares, sorted, so a set is compared as a multiset whatever order the vendor returns it in. A drift check compares `normalize(actual, desired)` with `normalize(desired, desired)`, leaf by leaf: `diffDeclared(resource, state, desired, accept?)` returns each declared field that differs, `{ path, desired, actual }`, where `accept` gives, by a reference's path, the values that stand for the resource it names. `withoutWriteOnly(fields, desired)` drops write-only values from a declaration, keeping a secret's marker, which names a stored value and holds none. So a field left out is never drift, which covers attributes an OpenTofu provider marks `computed` and `optional` (GitHub's `etag`, `topics`): they are the user's to set, so not `vendorOwned`, but the vendor fills them in when nobody does.
285
400
 
@@ -307,7 +422,7 @@ export const outbound = collection({
307
422
 
308
423
  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.
309
424
 
310
- `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:
425
+ `resources/data.ts` is the data file the runtime imports as well as loads: 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 holds collections only, `collection` imported from `sanoma/data` (or `@sanoma-ai/workflows/data`): a resource declared there, or a collection declared in another data file, is a [problem](#data-files). 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:
311
426
 
312
427
  ```ts
313
428
  uses: [outbound.get, outbound.save],
@@ -317,7 +432,7 @@ async run(ctx, { company }) {
317
432
  },
318
433
  ```
319
434
 
320
- `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.
435
+ `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, at }`, as `loadDataFiles` reads it), whose declaration the app's Hosting page opens. `@sanoma-ai/workflows/shared` exports `DATA_VENDOR` and the `Saved` and `ListQuery` types.
321
436
 
322
437
  ### Direct calls
323
438
 
@@ -334,16 +449,16 @@ const customer = await client.call("stripe.customer.get", { id }, { actor: { id:
334
449
 
335
450
  ### Data files
336
451
 
337
- 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:
452
+ 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/`. Sanoma loads each one, as Node imports a module, and reads what it exports:
338
453
 
339
454
  ```ts
340
455
  // resources/identity/github.ts
341
456
  import { github } from "@sanoma-ai/connector-github/resources";
342
457
 
343
- export const website = github.repository({ name: "website", visibility: "public", has_wiki: false });
458
+ export const website = github.repository({ owner: "acme", name: "website", visibility: "public", has_wiki: false });
344
459
 
345
460
  export const websiteMain = github.branch_protection({
346
- repository_id: website, // a reference: stands for the repository's name
461
+ repository_id: website, // a reference: stands for the repository's identity, "acme/website"
347
462
  pattern: "main",
348
463
  enforce_admins: true,
349
464
  });
@@ -353,35 +468,33 @@ export default [website, websiteMain];
353
468
 
354
469
  Another data file names `website` by importing it: `import { website } from "./github.ts"`.
355
470
 
356
- 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.
471
+ A data file is TypeScript like any other: it may compute a value, spread a shared object or import a helper of its own. Each exported value that a resource type's constructor made (`<vendor>.<type>({ … })`, the vendor's constructors imported from its connector's `<info.package>/resources` entry, the people's from `sanoma/resources`) is one declaration, `<file>#<export>`, in the file that made it (another file re-exporting it declares nothing), under its non-default name when the file also exports it as `default`; anything else the file exports, such as the list `export default [a, b]` that Sanoma writes, declares nothing. A field the type's `fields.references` allows a resource in is given the declared value itself, and names the declaration that exports that very object. A field the type's `fields.writeOnly` lists takes `secret("NAME")` or `secret("NAME", { generate: true })`, imported from `sanoma/resources` (or `@sanoma-ai/workflows/resources`; see [Secrets](#secrets)). `resources/data.ts` is the exception: it holds the [collections](#collections) and nothing else.
472
+
473
+ The loader (`loadModules`) loads synchronously, since `describeConfig` and `readPeople` are: `require` of an ES module, Node's type stripping and `module.registerHooks`, so a data file may not use top-level `await`. It serves the modules a company file imports from the runtime itself, from its own objects: each connector's `<package>/resources` with the config's constructors, `sanoma/resources`, `sanoma/data` (`collection`), `sanoma/site` (`defineSite`) and, to an app's `app.ts`, `sanoma` (`defineApp`); so a data file never needs the package installed, and each value they make records where it was made. Each load is a generation: a file is loaded again, with what it imports, once it, a company file it imports or a directory they are in has changed (by size and modification time), so an edit is read without a restart; every other call answers from the generation, at the cost of a `stat` per file. A superseded generation's modules stay in Node's cache, a copy of the files per edit.
357
474
 
358
475
  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.
359
476
 
360
- `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:
477
+ `loadDataFiles(root, connectors)`, from `@sanoma-ai/workflows/describe`, loads every regular `.ts` file under `<root>/resources` and each app's `apps/<app>/resources` (but `*.test.ts`, `*.d.ts` and `node_modules`). It returns `{ resources, bindings, orphans, collections, problems }` (`collections`: where each `resources/data.ts` declares each collection, `{ id, file, name, at }`, never matched to a connector, a type or the lockfile): one `DeclaredResource` per declaration, by file and then in the order made, less any with a problem and any that names one, and the problems. Their shapes are the zod schemas `DeclaredResourceSchema` and `ResourceProblemSchema` in `@sanoma-ai/workflows/description`:
361
478
 
362
- ```ts
363
- interface DeclaredResource {
364
- id: string; // "<file>#<export>", the file relative to the root: "resources/identity/github.ts#websiteMain"
365
- file: string; // the file, as `id` names it: "resources/identity/github.ts"
366
- vendor: string; // "github"
367
- type: string; // "branch_protection"
368
- name: string; // what people call it: its identity, else its export's name: "website:main"
369
- identity?: string; // the import id that finds it unbound, from `find`: "website:main"
370
- binding?: string; // the id of the object the lockfile binds it to: "BPR_kwDOabc123"
371
- span: readonly [start: number, end: number]; // its `export const`, as UTF-16 offsets into the file
372
- desired: Record<string, unknown>; // its fields as its type checked them: { repository_id: "website", … }
373
- refs: Record<string, string>; // where it names a resource, to that resource's id: { repository_id: "resources/identity/github.ts#website" }
374
- }
375
-
376
- interface ResourceProblem {
377
- file?: string; // "resources/identity/github.ts"; absent, with line and column, when the config has no root
378
- line?: number;
379
- column?: number;
380
- message: string;
479
+ ```jsonc
480
+ // A DeclaredResource
481
+ {
482
+ "id": "resources/identity/github.ts#websiteMain", // <file>#<export>, the file relative to the root
483
+ "file": "resources/identity/github.ts",
484
+ "vendor": "github",
485
+ "type": "branch_protection",
486
+ "name": "acme/website:main", // what people call it: its identity, else its export's name
487
+ "identity": "acme/website:main", // the import id that finds it unbound, from `find`
488
+ "binding": "BPR_kwDOabc123", // the id of the object the lockfile binds it to
489
+ "at": { "file": "resources/identity/github.ts", "export": "websiteMain" },
490
+ "desired": { "repository_id": "acme/website", "pattern": "main", "enforce_admins": true }, // as its type checked them
491
+ "refs": { "repository_id": "resources/identity/github.ts#website" }, // where it names a resource, that resource's id
381
492
  }
493
+ // A ResourceProblem, had `website` been written with `wiki: false`: at the line and column an error's stack gives, else at its export, else at its file
494
+ { "file": "resources/identity/github.ts", "line": 4, "column": 31, "message": "github.repository: no field wiki" }
382
495
  ```
383
496
 
384
- A problem is anything outside the subset, a constructors import of no connector in the config or a data file that is not one, a type the connector does not declare, a value the type refuses (a resource where it takes none among them), a reference cycle, or two resources of one type with one identity, and, from the lockfile ([below](#bindings)), a lockfile that is not JSON or not bindings, a binding of another type than its declaration's, or two declarations bound to one object. `describeConfig` lists both, as `resources` and `problems` (and the lockfile's `orphans`), so the app starts with the resources it could read and shows what is wrong in the rest. `readResources(config)` is the strict reading, for a test or CI: it returns the resources, or throws one error listing every problem at `file:line:column`.
497
+ A problem is, at the line and column the error's stack gives: a file that throws as it loads, a value its type refuses (a resource where it takes none among them) or a type its vendor does not declare, and a syntax error. V8 places a call at its method's name, so the column above is at `repository`. A file stops at its first, so a broken file is one problem, and a file whose import threw is reported once, at the file that threw. At the file: an import Node cannot find (a constructors import of no connector in the config, a file that is not there), top-level `await`, and a resource made but never exported (written inline in a list, say). At `file#export`: one object exported under two names, a reference to a resource no data file exports, a type of no connector in the config, a resource declared in `resources/data.ts` or a collection in another data file, two resources of one type with one identity (and connection), and, from the lockfile ([below](#bindings)), a binding of another type than its declaration's or two declarations bound to one object; a lockfile that is not JSON or not bindings is the lockfile's. `describeConfig` lists them, as `resources` and `problems` (and the lockfile's `orphans`), so the app starts with the resources it could read and shows what is wrong in the rest; `sanoma check` prints them. No lint holds a data file to a form: what loads is read. `readResources(config)` is the strict reading, for a test or CI: it returns the resources, or throws one error listing every problem as `problemLine` prints it (`file:line:column`, `file#export` or `file`).
385
498
 
386
499
  #### Bindings
387
500
 
@@ -394,11 +507,15 @@ Which vendor object a declaration means lives in a lockfile beside the data file
394
507
  }
395
508
  ```
396
509
 
397
- `readDataFiles` joins each declaration to its binding by its key, `<file>#<export>`, as `binding`, returns the lockfile's `bindings`, and lists as `orphans` (`{ key, type, id }`) the bindings no data file declares. A missing lockfile binds nothing. `@sanoma-ai/workflows/describe` exports `readLockfile(root)` (`{ bindings, problems }`), `serializeLockfile(bindings)` (keys sorted, two-space JSON, a trailing newline), `writeLockfile(root, bindings)` (whole or not at all: written beside it, then renamed), and `diffLockfiles(before, after)`, each change a `Rebind`: `{ key, type, kind: "bind" | "rebind" | "unbind", from?, to? }`.
510
+ `loadDataFiles` joins each declaration to its binding by its key, `<file>#<export>`, as `binding`, returns the lockfile's `bindings`, and lists as `orphans` (`{ key, type, id }`) the bindings no data file declares, but those of a file that failed to load, whose declarations are not known. A missing lockfile binds nothing. `@sanoma-ai/workflows/describe` exports `readLockfile(root)` (`{ bindings, problems }`), `serializeLockfile(bindings)` (keys sorted, two-space JSON, a trailing newline), `writeLockfile(root, bindings)` (whole or not at all: written beside it, then renamed), and `diffLockfiles(before, after)`, each change a `Rebind`: `{ key, type, kind: "bind" | "rebind" | "unbind", from?, to? }`.
511
+
512
+ #### Writing a data file
513
+
514
+ Sanoma writes company files too, from `@sanoma-ai/workflows/describe`, by parsing them (with oxc-parser), not loading them: a written file must keep every byte it does not change. `setFields(text, exportName, fields, options)` returns the file's text with fields of one declaration set, by dotted path as a drift report names them (`{ has_wiki: true, "metadata.owner": "ops" }`; a field the declaration lacks is added). The declaration is the object literal of `export const <exportName> = …`, or of `export default …` for `"default"`: the object itself, or the first argument of the call it is (`github.repository({ … })`, `collection({ … })`, `defineSite({ … })`, `defineApp({ … })`). Only that statement changes, formatted with [oxfmt](https://oxc.rs/docs/guide/usage/formatter), and every other byte of the file stays as it was. It writes literals only: a value the file computes (a name, a reference to another resource among them; a call but `secret(…)`; a spread; a template with `${}`), a path into an object or list that holds a spread, and a declaration that is no object literal throw `NotLiteral`, whose `reason` says what to edit in code instead (`the value of has_wiki is computed in the file`) and whose `field` names the field the file computes (none for a declaration that is no object literal). It refuses with a plain `Error` a file that does not parse, an export the file does not have, and a path through a value that is not an object or a list; a path whose segments name a field with a dot in its key (`metadata: { "app.owner": … }`) finds that field. `renderDataFile({ imports, exports }, options)` renders a whole new file, which, loaded, declares what it was given; a reference is a `Reference`, and a secret's marker is written as `secret("NAME")` (or with `{ generate: true }`), the file then importing `secret` from `"sanoma/resources"` (`setFields` adds that import after the file's own when it sets one). `formatOptions(root)` reads the options from `<root>/.oxfmtrc.json`, oxfmt's defaults without one. oxfmt is a dependency of this package, loaded only when a file is written. The app's Adopt and its forms' Save write through it ([the app's Hosting page](../app/README.md#screens)).
398
515
 
399
- oxlint holds `resources/**` to the subset with the rule `sanoma/data-file`, an oxlint JS plugin the package's `oxlint.json` loads from `dist/plugin.js`, so extending it (see [Keeping workflows replay-safe](#keeping-workflows-replay-safe)) covers data files as well; it does not know the config, so whether a connector, type or value is known is the reader's to say, in `describeConfig(config).problems`, which `sanoma check` prints.
516
+ #### Where Sanoma parses instead
400
517
 
401
- Sanoma writes data files too, from `@sanoma-ai/workflows/describe`. `setFields(text, exportName, fields, options)` returns the file's text with fields of one resource set, by dotted path as a drift report names them (`{ has_wiki: true, "metadata.owner": "ops" }`; a field the declaration lacks is added): only that `export const` changes, formatted with [oxfmt](https://oxc.rs/docs/guide/usage/formatter), and every other byte of the file stays as it was. It refuses a resource the file does not declare, a path through a value that is not an object or a list, and a value that is or holds a reference (a person changes those); a path whose segments name a field with a dot in its key (`metadata: { "app.owner": … }`) finds that field. `renderDataFile({ imports, exports }, options)` renders a whole new file, which `readDataFile` reads back as given (what it returns renders the file again, less its comments); a reference is a `Reference`, and a secret's marker is written as `secret("NAME")` (or with `{ generate: true }`), the file then importing `secret` from `"sanoma/resources"` (`setFields` adds that import after the file's own when it sets one). `formatOptions(root)` reads the options from `<root>/.oxfmtrc.json`, oxfmt's defaults without one. oxfmt is a dependency of this package, loaded only when a file is written.
518
+ Everything a company file declares is read by loading it, but for these, which parse source with oxc and never run it: the workflow [outline](#outline) and the workflow lint ([Keeping workflows replay-safe](#keeping-workflows-replay-safe)), since a run is held to its code's shape, not to what it did once; a site's pages and what they call (`readSites`: `routes/**` and `components/**` are browser JSX, read as the site's outline), though its `site.ts` is loaded; `crossAppImports`, a lint over imports; a draft's files, never loaded, since a draft is someone's unreviewed code: a change's facts are told from their text (`mayDeclarePeople`, `isRules`), and updating an app reads `main`'s `app.ts`, never the draft's; `sanoma`'s template, inside the installed package under `node_modules` where Node strips no types, whose people's ids `sanoma` reads from its text when it starts a company; and the writer above.
402
519
 
403
520
  ### Drift
404
521
 
@@ -436,7 +553,7 @@ This package knows nothing of OpenTofu. Resource types from an OpenTofu provider
436
553
 
437
554
  A host is a resource type that runs the company (a container, a Render service, a Fly app): `defineResource({ …, writable: true, host })`, where `host` (`HostSpec`) says where the type takes the runtime's inputs, by dotted path into its fields (`inputs: { source, ref, secrets, port, domains? }`, `secrets` a write-only field; `domains`, when the type takes it, a public site's domain by site name, which the host hands the app as `SANOMA_DOMAINS` and `describeConfig` reports a problem for, at the declaration, when it names a site that is not public or gives one domain two sites: `sanoma.example is the domain of both www and docs`), which field apply fills with the commit (`revision`, which a data file may not declare: it names a branch, never a commit), what it can do (`capabilities: { lifetime, publicUrl, buildsFromGit, staticEgress }`, `lifetime` `long-lived` or `stops-when-quiet`, `HostLifetime`), and how its state reads (`outputs(state) → { url, revision?, healthy }`). `defineResource` refuses a `host` that names no field, or whose `secrets` is not write-only. A write-only field may be a map of secrets (`secrets: { SANOMA_DATABASE_URL: secret("DATABASE_URL") }`): its markers are kept as one at the field is. A host type is `stateless` (a `ResourceSpec` field, default true for a host): its objects keep no data, so replacing or recreating one is routine. `source` is a clone URL, or a reference to a declared repository with a `by` field holding one.
438
555
 
439
- A config whose connectors declare a host type has the built-in operation `sanoma.source.resolve` (`SOURCE_RESOLVE`, effect `read`): `{ url, ref } → { revision }`, by `git ls-remote` on this machine (a branch, then a tag, peeled; a full commit id is itself). Apply calls it for each host it plans, and gives the host the commit as its `revision` and the clone URL as its `source` (a referenced repository's, from its `by` field); a host whose ref does not resolve, or whose repository has no clone URL yet (apply the repository first), is a problem of the plan. The drift run calls it for every host it reads. A host's `secrets` map may name sanoma's own secrets (`SANOMA_…`), and no other field of any type may. `resolveRevision(url, ref)` is the same, outside a run.
556
+ A config whose connectors declare a host type has the built-in operation `sanoma.source.resolve` (`SOURCE_RESOLVE`, effect `read`): `{ url, ref } → { revision }`, by `git ls-remote` on this machine (a branch, then a tag, peeled; a full commit id is itself). Apply calls it for each host it plans, and gives the host the commit as its `revision` and the clone URL as its `source` (a referenced repository's, from its `by` field); a host whose ref does not resolve, or whose repository has no clone URL yet (apply the repository first), is a problem of the plan. The drift run calls it for every host it reads. A sandbox run calls its fake instead (`fakeSourceDriver`), with no git or network: a full commit id is itself, any other ref the SHA-1 of `<url>#<ref>`, the same on every run. A host's `secrets` map may name sanoma's own secrets (`SANOMA_…`), and no other field of any type may. `resolveRevision(url, ref)` is the same, outside a run.
440
557
 
441
558
  A wakeup is a resource type that starts a stopped instance at a time, for what is due while its host is stopped: `defineResource({ …, writable: true, wakeup })`, where `wakeup` (`WakeupSpec`) names the field that references the host (`inputs.host`), the type's own operation the runtime registers each deadline through (`register`, called with `{ at }`, which replaces whatever the wakeup held) and how its state reads (`outputs(state) → { healthy }`); `defineResource` and `resolveConfig` refuse one that does not meet it. Declaring one in a data file is the switch, with no mode flag: `declaredWakeup(config)` reads it, and several are a problem of the data files. A wake is `GET {url}/wake`, with no query: resume if stopped. A config with `wakeup: true` runs the wakeup service itself and must not stop, so `startWorker` refuses it beside a declared wakeup.
442
559
 
@@ -458,7 +575,7 @@ A database is a resource type that holds the company's database (a Neon project,
458
575
 
459
576
  Never put a secret in a workflow's input or an operation's input or output. They are persisted verbatim: DBOS keeps every step's input and output in Postgres, the ledger records each call's input and output, an approval a policy asks for carries the held call's input, and the app shows all of it to anyone who can reach it. A driver reads its credentials when it is called (from the environment or a secret store), and an operation that creates a secret (an API key, a password reset link) returns a reference to where it is stored, not the secret.
460
577
 
461
- A secret a resource needs is named, never written. A data file gives `secret("NAME")` (from `sanoma/resources`; `NAME` upper case, as a variable's; sanoma's own, `SANOMA_…`, only in a host's `secrets`) at a write-only field (or an entry of a write-only map), and `secret("NAME", { generate: true })` for one apply makes when none is stored: `database_password: secret("SUPABASE_DB_PASSWORD", { generate: true })`. Declarations, checkpoints and the ledger hold its marker, `{ "$secret": "NAME", "generate": true }`; the resource type refuses one at any other field, and its schema does not judge it. The driver resolves the markers just before the vendor call, `await resolveSecrets(input, call.secrets)`: each is replaced by the stored value, a `generate` one with none stored first made (32 random bytes, base64url) and stored, and one with nothing stored fails the call for good. `call.secrets` is `{ get(name), ensure(name, make) }`: the environment wins, else the `sanoma_credentials` table (see [Credentials](#credentials)); a fake's and a sandbox run's are in memory (`memorySecrets()`, which a driver's tests use too).
578
+ A secret a resource needs is named, never written. A data file gives `secret("NAME")` (from `sanoma/resources`; `NAME` upper case, as a variable's; sanoma's own, `SANOMA_…`, only in a host's `secrets`) at a write-only field (or an entry of a write-only map), and `secret("NAME", { generate: true })` for one apply makes when none is stored: `database_password: secret("SUPABASE_DB_PASSWORD", { generate: true })`. Declarations, checkpoints and the ledger hold its marker, `{ "$secret": "NAME", "generate": true }`; the resource type refuses one at any other field, and its schema does not judge it. The driver resolves the markers just before the vendor call, `await resolveSecrets(input, call.secrets)`: each is replaced by the stored value, a `generate` one with none stored first made (32 random bytes, base64url) and stored, and one with nothing stored fails the call for good. `call.secrets` is `{ get(name), ensure(name, make) }`, resolved through the configured store and explicit bindings (see [Credentials](#credentials)); a fake's and a sandbox run's are in memory (`memorySecrets()`, which a driver's tests use too).
462
579
 
463
580
  ### Versions
464
581
 
@@ -558,7 +675,7 @@ The steps, as [Cucumber expressions](https://github.com/cucumber/cucumber-expres
558
675
  | 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"` |
559
676
  | 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"`) |
560
677
 
561
- 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.
678
+ A resource step names an object a vendor's fake holds, by resource type and import id (a binding id, or the provider's import id: a GitHub repository's name without its owner, 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.
562
679
 
563
680
  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, or `anonymous`) and `held for group` a declared group, `agents` or `visitors`, 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.
564
681
 
@@ -596,7 +713,7 @@ export const blueskySteps = defineSteps("bluesky", ({ given, then }) => {
596
713
 
597
714
  A `given` returns a `Seed`: `{ op, input, as? }` calls the operation through the run's fake before it starts, `{ op, fault }` injects `failNext`, `rateLimit` or `loseReply` into its next call. A `then` returns an `Expect`: `op` and `called` say what it is about (the graph marks the operation), and `check(run)` reads the run (`RunView`: its `records`; `calls(op)`, the calls made without an error; `seeded(op)`, what the `Given` steps seeded through it; and `made(op)`, both: each `{ input, output }`, typed by the operation) and returns why it does not hold, or throws; returning nothing passes. In `scenario.expect` such a step is `{ step, custom: true, op?, called? }`.
598
715
 
599
- The same steps test the connector's own operations, in its `features/`: there the generic steps below call an operation directly (no worker, no policy) on a fake or on the driver over recorded replies, and `$name.field` in a table cell is a field of what a `Given` named `as` returned (`| id | $draft.id |`). [`describeFeatures`](https://github.com/Sanoma-AI/sanoma/blob/main/packages/testing/README.md#a-connectors-own-features) runs them.
716
+ The same steps test the connector's own operations, in its `features/`: there the generic steps below call an operation directly (no worker, no policy) on a fake or on the driver over recorded replies, and `$name.field` in a table cell is a field of what a `Given` named `as` returned (`| id | $draft.id |`). A `Given` that calls a resource type's `create` seeds the object as apply makes it: the type's `afterCreate` calls follow, each polled as apply polls it (a Neon project's compute has started), the seed's name (else the type's) as the export a `storeUrl` stores under. [`describeFeatures`](https://github.com/Sanoma-AI/sanoma/blob/main/packages/testing/README.md#a-connectors-own-features) runs them.
600
717
 
601
718
  | Keyword | Step | What it does |
602
719
  | ------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
@@ -631,14 +748,14 @@ A sandbox run does not survive a worker restart: its fakes keep their state in t
631
748
 
632
749
  `@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:
633
750
 
634
- - `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`.
635
- - `readSites(root, description, connectors, policies?)`, 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="…" />` and each use of `api`, both 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`, `method`, what it calls of a workflow or collection as written (`start`, `latest`, `list`), `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 `pages` (each `SitePage` a `path` from a route file's id, one per path, a layout and its index being one; and on a path with one parameter, such as `/sites/www/blog/$slug`, `content`, the one kind its file reads an entry of by that parameter, `content.<kind>.get(params.slug)` (or `slug`, destructured) with `content` from `sanoma/site`, for the app's sitemap and its pages' languages, which `siteContentPages(root)` gives the app's build alone, path to kind), its `calls`, its `warnings` (on a public site, each page with one parameter whose file reads content but not by that parameter, so the sitemap lists none of its pages: a warning `sanoma check` prints and does not fail on) 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, and, given the policies (`describeConfig` passes them unless they have problems), on a public site each start (`api.<name>(…)`, `.start(…)`, a `RunForm`) and direct call that no company `permit` could let a visitor make, its `when` and `unless` aside (one naming `anonymous`; on a site with a `login`, `anonymous` or the group `visitors`), whose message ends with the permit to add, and, whatever the policies, its call of a built-in workflow or read of a workflow's runs but `.result` (`.latest`, `.runs`, `.approvals`, `.ledger`), which the app refuses a visitor; 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`.
751
+ - `describeConfig(config)` and its types (`ConfigDescription`, `WorkflowEntry`, `OpEntry`, `VendorEntry`, `ResourceTypeEntry`, `DeclaredResource`, `ResourceProblem`), and `loadDataFiles(root, connectors)` and `readResources(config)`, from `@sanoma-ai/workflows/describe`: what to render, as plain JSON, with each workflow's outline. Its shape is `ConfigDescriptionSchema`, from `@sanoma-ai/workflows/description` (zod and nothing of Node, so a browser may import it): each entry type is inferred from its schema, and the schema refuses a key it does not name. Beside what the sections above say, a resource type carries its `capabilities` (`host`, `database` and `wakeup` from its spec, `source` for a type a host type's `source` field names, such as `github.repository`), a vendor its `credentials` (each variable its driver's `env` declares: its name, description and whether it is optional, never a value or a status), and the policy its `files` (each `.cedar` file's path and text, the root's then each app's). 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`.
752
+ - `readSites(root, description, connectors, policies?)`, from `@sanoma-ai/workflows/describe`, which `describeConfig` returns as `sites`: what each site calls, read from its source with oxc and never run (its pages are browser code: what they call is the site's outline), for a workflow's **Used by**; its `site.ts` is loaded. 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="…" />` and each use of `api`, both 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`, `method`, what it calls of a workflow or collection as written (`start`, `latest`, `list`), `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` (`defineSite({ title })` in `site.ts`, the file's default export, which a zod schema checks: a key it does not name, a `login` naming no login half, or `locales` that are no language tags or not on a public site without a login, is a problem), its `pages` (each `SitePage` a `path` from a route file's id, one per path, a layout and its index being one; and on a path with one parameter, such as `/sites/www/blog/$slug`, `content`, the one kind its file reads an entry of by that parameter, `content.<kind>.get(params.slug)` (or `slug`, destructured) with `content` from `sanoma/site`, for the app's sitemap and its pages' languages, which `siteContentPages(root)` gives the app's build alone, path to kind), its `calls`, its `warnings` (on a public site, each page with one parameter whose file reads content but not by that parameter, so the sitemap lists none of its pages: a warning `sanoma check` prints and does not fail on) 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, and, given the policies (`describeConfig` passes them unless they have problems), on a public site each start (`api.<name>(…)`, `.start(…)`, a `RunForm`) and direct call that no company `permit` could let a visitor make, its `when` and `unless` aside (one naming `anonymous`; on a site with a `login`, `anonymous` or the group `visitors`), whose message ends with the permit to add, and, whatever the policies, its call of a built-in workflow or read of a workflow's runs but `.result` (`.latest`, `.runs`, `.approvals`, `.ledger`), which the app refuses a visitor; 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`.
636
753
  - `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`).
637
754
  - `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)). `client.chats` keeps the operator agent's chats in the config's database: `open({ id, person, engine, title })` (the id the browser gave the new chat), `attach(id, draftId)`, `upsert(id, messages)` (the AI SDK's UI messages, each by its id, a new one after the last), `load(id)`, `get(id)` and `list(person)` (the newest first, `Chat`).
638
755
  - `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`.
639
756
  - `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.
640
757
  - 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`, `WithdrawnError`, `ApprovalPendingError` and `DriverError`. Read codes with `errorCode`, never `instanceof`.
641
- - `@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.
758
+ - `@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 problem's place: `file:line:column`, else `file#export`, else `file`), `problemLine` (a problem as one line: its place, then `: message`, or the message), `exportSpan` (where `export const <name>` or `export default` is in a file's text, for marking a declaration), `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.
642
759
  - `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.
643
760
 
644
761
  ### Outline
@@ -53,11 +53,14 @@ export function applySteps(appName, root, read) {
53
53
  // The policy asked as each call would ask it, now, so one approval can cover them all.
54
54
  holds: (calls) => DBOS.runStep(async () => {
55
55
  const run = runNow();
56
- for (const { op: id, input } of calls) {
56
+ for (const { op: id, input, connection } of calls) {
57
57
  const op = run.state.ops.get(id);
58
58
  // Parsed as the call will be, so the policy sees the same input with its defaults.
59
59
  const parsed = op.input.safeParse(input);
60
- const decision = await decide(run, op, parsed.success ? parsed.data : input);
60
+ const selected = run.sandbox
61
+ ? undefined
62
+ : await run.state.connections?.select(op.vendor, connection, run.actor.id);
63
+ const decision = await decide(run, op, parsed.success ? parsed.data : input, selected);
61
64
  if (decision.kind === "approve")
62
65
  return decision.approver;
63
66
  }