@sanoma-ai/workflows 0.9.1 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/README.md +193 -50
  2. package/SECRET-SUPPORT.md +33 -0
  3. package/dist/apply-steps.js +5 -2
  4. package/dist/apply-steps.js.map +1 -1
  5. package/dist/apply.d.ts +3 -0
  6. package/dist/apply.js +27 -13
  7. package/dist/apply.js.map +1 -1
  8. package/dist/approvals.d.ts +1 -0
  9. package/dist/approvals.js +6 -1
  10. package/dist/approvals.js.map +1 -1
  11. package/dist/apps.d.ts +13 -24
  12. package/dist/apps.js +69 -68
  13. package/dist/apps.js.map +1 -1
  14. package/dist/ast.d.ts +9 -7
  15. package/dist/ast.js +2 -0
  16. package/dist/ast.js.map +1 -1
  17. package/dist/authentication.d.ts +54 -0
  18. package/dist/authentication.js +138 -0
  19. package/dist/authentication.js.map +1 -0
  20. package/dist/call-workflow.d.ts +5 -0
  21. package/dist/call-workflow.js +4 -3
  22. package/dist/call-workflow.js.map +1 -1
  23. package/dist/call.d.ts +3 -2
  24. package/dist/call.js +36 -7
  25. package/dist/call.js.map +1 -1
  26. package/dist/cedar.js +14 -1
  27. package/dist/cedar.js.map +1 -1
  28. package/dist/change-steps.js +7 -5
  29. package/dist/change-steps.js.map +1 -1
  30. package/dist/change.d.ts +7 -0
  31. package/dist/change.js +7 -0
  32. package/dist/change.js.map +1 -1
  33. package/dist/client.d.ts +62 -10
  34. package/dist/client.js +272 -47
  35. package/dist/client.js.map +1 -1
  36. package/dist/collection.d.ts +7 -0
  37. package/dist/collection.js +5 -2
  38. package/dist/collection.js.map +1 -1
  39. package/dist/config.d.ts +16 -4
  40. package/dist/config.js +89 -21
  41. package/dist/config.js.map +1 -1
  42. package/dist/connection.d.ts +389 -0
  43. package/dist/connection.js +657 -0
  44. package/dist/connection.js.map +1 -0
  45. package/dist/credentials.d.ts +7 -53
  46. package/dist/credentials.js +84 -247
  47. package/dist/credentials.js.map +1 -1
  48. package/dist/db.js +34 -2
  49. package/dist/db.js.map +1 -1
  50. package/dist/define.d.ts +9 -2
  51. package/dist/define.js.map +1 -1
  52. package/dist/definitions.d.ts +20 -0
  53. package/dist/definitions.js +16 -0
  54. package/dist/definitions.js.map +1 -0
  55. package/dist/deployment-key.d.ts +2 -0
  56. package/dist/deployment-key.js +22 -0
  57. package/dist/deployment-key.js.map +1 -0
  58. package/dist/describe.d.ts +15 -175
  59. package/dist/describe.js +122 -35
  60. package/dist/describe.js.map +1 -1
  61. package/dist/description.d.ts +532 -0
  62. package/dist/description.js +381 -0
  63. package/dist/description.js.map +1 -0
  64. package/dist/direct.js +7 -0
  65. package/dist/direct.js.map +1 -1
  66. package/dist/drift.d.ts +1 -1
  67. package/dist/drift.js +1 -1
  68. package/dist/drift.js.map +1 -1
  69. package/dist/env.d.ts +2 -0
  70. package/dist/env.js +2 -2
  71. package/dist/env.js.map +1 -1
  72. package/dist/fake.d.ts +13 -1
  73. package/dist/fake.js +70 -0
  74. package/dist/fake.js.map +1 -1
  75. package/dist/index.d.ts +11 -3
  76. package/dist/index.js +10 -2
  77. package/dist/index.js.map +1 -1
  78. package/dist/ledger.d.ts +2 -0
  79. package/dist/ledger.js +1 -1
  80. package/dist/ledger.js.map +1 -1
  81. package/dist/lint.d.ts +0 -9
  82. package/dist/lint.js +2 -17
  83. package/dist/lint.js.map +1 -1
  84. package/dist/load.d.ts +85 -0
  85. package/dist/load.js +391 -0
  86. package/dist/load.js.map +1 -0
  87. package/dist/lockfile.d.ts +2 -0
  88. package/dist/lockfile.js +8 -3
  89. package/dist/lockfile.js.map +1 -1
  90. package/dist/op.d.ts +22 -5
  91. package/dist/op.js +20 -2
  92. package/dist/op.js.map +1 -1
  93. package/dist/outline.d.ts +5 -0
  94. package/dist/outline.js +46 -5
  95. package/dist/outline.js.map +1 -1
  96. package/dist/paths.d.ts +18 -2
  97. package/dist/paths.js +19 -7
  98. package/dist/paths.js.map +1 -1
  99. package/dist/plugin.d.ts +0 -11
  100. package/dist/plugin.js +3 -18
  101. package/dist/plugin.js.map +1 -1
  102. package/dist/policy.d.ts +2 -0
  103. package/dist/policy.js.map +1 -1
  104. package/dist/resource.d.ts +10 -4
  105. package/dist/resource.js +11 -4
  106. package/dist/resource.js.map +1 -1
  107. package/dist/resources.d.ts +33 -76
  108. package/dist/resources.js +219 -227
  109. package/dist/resources.js.map +1 -1
  110. package/dist/run.d.ts +4 -0
  111. package/dist/run.js.map +1 -1
  112. package/dist/sandbox.d.ts +5 -2
  113. package/dist/sandbox.js +56 -7
  114. package/dist/sandbox.js.map +1 -1
  115. package/dist/scenario.d.ts +5 -0
  116. package/dist/scenario.js +3 -2
  117. package/dist/scenario.js.map +1 -1
  118. package/dist/scenarios.js +7 -1
  119. package/dist/scenarios.js.map +1 -1
  120. package/dist/schema.js +8 -0
  121. package/dist/schema.js.map +1 -1
  122. package/dist/secret-store-check.d.ts +11 -0
  123. package/dist/secret-store-check.js +76 -0
  124. package/dist/secret-store-check.js.map +1 -0
  125. package/dist/secret-store.d.ts +106 -0
  126. package/dist/secret-store.js +228 -0
  127. package/dist/secret-store.js.map +1 -0
  128. package/dist/secret.d.ts +2 -0
  129. package/dist/secret.js +32 -0
  130. package/dist/secret.js.map +1 -1
  131. package/dist/shared.d.ts +21 -11
  132. package/dist/shared.js +41 -7
  133. package/dist/shared.js.map +1 -1
  134. package/dist/sites.d.ts +4 -93
  135. package/dist/sites.js +76 -84
  136. package/dist/sites.js.map +1 -1
  137. package/dist/steps.d.ts +3 -0
  138. package/dist/steps.js.map +1 -1
  139. package/dist/version.d.ts +2 -1
  140. package/dist/version.js +2 -1
  141. package/dist/version.js.map +1 -1
  142. package/dist/wakeup-driver.js +15 -6
  143. package/dist/wakeup-driver.js.map +1 -1
  144. package/dist/worker.js +12 -8
  145. package/dist/worker.js.map +1 -1
  146. package/dist/writer.d.ts +53 -14
  147. package/dist/writer.js +113 -43
  148. package/dist/writer.js.map +1 -1
  149. package/oxlint.json +1 -5
  150. package/package.json +6 -1
  151. package/src/apply-steps.ts +5 -2
  152. package/src/apply.ts +54 -21
  153. package/src/approvals.ts +7 -1
  154. package/src/apps.ts +71 -86
  155. package/src/ast.ts +6 -6
  156. package/src/authentication.ts +191 -0
  157. package/src/call-workflow.ts +5 -4
  158. package/src/call.ts +52 -9
  159. package/src/cedar.ts +14 -1
  160. package/src/change-steps.ts +7 -4
  161. package/src/change.ts +10 -0
  162. package/src/client.ts +346 -45
  163. package/src/collection.ts +10 -2
  164. package/src/config.ts +107 -19
  165. package/src/connection.ts +912 -0
  166. package/src/credentials.ts +94 -281
  167. package/src/db.ts +34 -2
  168. package/src/define.ts +7 -2
  169. package/src/definitions.ts +31 -0
  170. package/src/deployment-key.ts +23 -0
  171. package/src/describe.ts +150 -227
  172. package/src/description.ts +426 -0
  173. package/src/direct.ts +7 -0
  174. package/src/drift.ts +10 -2
  175. package/src/env.ts +4 -2
  176. package/src/fake.ts +71 -0
  177. package/src/index.ts +53 -1
  178. package/src/ledger.ts +3 -1
  179. package/src/lint.ts +2 -17
  180. package/src/load.ts +466 -0
  181. package/src/lockfile.ts +10 -3
  182. package/src/op.ts +41 -7
  183. package/src/outline.ts +53 -7
  184. package/src/paths.ts +21 -6
  185. package/src/plugin.ts +3 -24
  186. package/src/policy.ts +2 -0
  187. package/src/resource.ts +24 -6
  188. package/src/resources.ts +246 -301
  189. package/src/run.ts +4 -0
  190. package/src/sandbox.ts +61 -7
  191. package/src/scenario.ts +24 -6
  192. package/src/scenarios.ts +7 -1
  193. package/src/schema.ts +10 -0
  194. package/src/secret-store-check.ts +93 -0
  195. package/src/secret-store.ts +314 -0
  196. package/src/secret.ts +29 -0
  197. package/src/shared.ts +44 -10
  198. package/src/sites.ts +85 -179
  199. package/src/steps.ts +3 -2
  200. package/src/version.ts +2 -1
  201. package/src/wakeup-driver.ts +22 -6
  202. package/src/worker.ts +12 -8
  203. package/src/writer.ts +145 -56
  204. package/dist/datafile.d.ts +0 -97
  205. package/dist/datafile.js +0 -479
  206. package/dist/datafile.js.map +0 -1
  207. 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 (`appSourceOf`: an `app.ts` that throws, or is not `defineApp({ source })`, is refused with its problem, and one that names no source as the company's own), 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,158 @@ 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. The [Supabase Vault adapter](../../connectors/supabase/README.md#supabase-vault-as-the-secret-store) provides this boundary for BYOC. See the [support and release-readiness matrix](https://github.com/Sanoma-AI/sanoma/blob/main/packages/workflows/SECRET-SUPPORT.md) for merged capabilities, connector coverage, publication status and remaining providers/lifecycle work.
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. OAuth consent and refresh remain separate lifecycle work.
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 low-level metadata operations are not a credential-entry interface or proof of vendor consent. Use the secure intake APIs below for user-entered values; each named invocation also verifies the current bundle.
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
+ Named drivers explicitly opt into methods with `defineDriver(connector, implementation, { authentication: ["apiKey"] })`. Each method supplies `verify(credentials)`, which checks the provider and returns `{ account, scopes, expiresAt? }`; expiry is Unix milliseconds. Workload identity verifies the deployment identity without a stored bundle. A missing verifier or unsupported driver is refused. After policy allows the operation, the runtime reads only the selected bundle, validates its schema, verifies that its account and grants still match the authorized metadata, and rechecks the binding after verification. Changed grants or expired credentials require reconnection. Verifier exceptions expose no SDK messages or causes.
350
+
351
+ The driver reads `authenticationCredentials("apiKey", definition, call)` inside its operation and creates a client for that invocation. `defineDriver` also accepts a factory `(call) => implementation`, invoked inside that boundary, for operations sharing one SDK client during a call. `call.authentication.credentialVersion` identifies the version read. The runtime keeps no credential or SDK cache: replacement affects the next invocation, while a call already executing retains its original bundle. Never put this material in an operation result. Named calls' `call.secrets` can resolve only auxiliary secret markers in their authorized input, and `ensure` additionally requires a marker with `generate: true`. An operation may declare generated destinations with `createsSecrets(input)`, such as a database connector storing a URL under the authorized resource export's name; these are exact logical names, not wildcard store access. Driver failures retain retryability and HTTP status, with a fixed message and no provider cause. Direct calls and durable calls use the same driver boundary.
352
+
353
+ Resource declarations use the same identity as an option, loaded with the rest of the data file:
354
+
355
+ ```ts
356
+ import { mail } from "@acme/mail/resources";
357
+
358
+ export const inbox = mail.inbox({ name: "support" }, { connection: { vendor: "mail", $connection: "mail/support" } });
359
+ ```
360
+
361
+ 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. The app exposes these declarations through its [connection management](../app/README.md#connection-management) forms.
362
+
363
+ Sandbox runs use synthetic identities for declared static connections, with separate fake state, faults and remembered replies for each account and run. They never read production connection records, invoke authentication verifiers or read production credentials. A production revision or an undeclared dynamic account is refused. Scenario steps can select an account in their returned seed, such as `{ op: mail.message.create, input: { title }, connection: support }`; absent a selector, only an explicitly declared default is used. Fault seeds accept the same `connection` field. Standalone driver tests can use `connectionFakes(makeFake)` from `@sanoma-ai/workflows/fake`: its `driver` requires `call.connection`, `account(ref)` exposes just that account's fake for seeding and fault injection, and `calls` is the combined log with connection IDs. Rebinding a reference to a different vendor account starts fresh fake state.
364
+
365
+ #### Connecting and managing accounts
366
+
367
+ Use `SanomaClient.previewConnection(target, credentials, { actor, id })` and `connectConnection(review, credentials, { actor })` for secure account intake. These are trusted server APIs, not workflow operations: credential values must never be passed to `client.call`, workflow arguments or an approval. `target` is either `{ connection: ref }` for a declared account or `{ rule: "vendor/rule" }` for an authorized dynamic account. Keep the same request ID while completing one review; a new account or a changed review starts a new request.
368
+
369
+ Preview runs the metadata-only `sanoma.connection.prepare` access gate before reading a store or verifying submitted credentials. The connector verifies the account and granted scopes against the code's requirements. The returned `ConnectionReview` contains metadata only: the verified account and scopes, destination, existing account/destination when present, expected revisions and the authorization fingerprint. It never returns credentials. An externally managed or read-only binding accepts no pasted replacement; preview reads its existing bundle. Workload identity verifies the deployment without a stored bundle.
370
+
371
+ Save rechecks the review, policy and destination, verifies the credentials again, then calls `sanoma.connection.configure` to reserve a `pending` account revision. It writes a managed bundle directly through the selected store with the reviewed expected version, outside durable execution. An opaque stored-version receipt records a successful write so resuming the same approved setup does not blindly repeat it. `sanoma.connection.activate` is a separate access gate: it verifies the saved bundle before making that revision usable. These gates, Test and Disconnect use ordinary policy decisions and ledger records containing metadata only. When a gate asks for approval, return to the secure form and retry after the decision; an approval never stores the form's credential values for later replay.
372
+
373
+ `pending` and `uncertain` accounts cannot run operations. An ambiguous store write or a failure after writing can leave `uncertain`; it must be inspected and reverified rather than treated as a successful connection or blindly written again. A policy, account, scope, destination or binding revision change invalidates the relevant review and requires fresh verification. Changing a destination does not copy or delete the previous value: the review identifies both locations, and a store migration remains an explicit separate action.
374
+
375
+ The read APIs are scoped to the admitted actor:
376
+
377
+ - `client.connections({ actor })` returns `ConnectionCard` metadata for company declarations and that actor's dynamic accounts, including requirements, account, scopes, ownership, current/bound storage location, change attribution and last-tested health. Listing cards does not read credentials or contact a vendor.
378
+ - `client.connectionRules({ actor })` returns creation choices permitted by the current code's `createBy` person IDs or `{ group }` entries. Creating under a rule still passes policy and ownership checks.
379
+ - `client.secretStoreStatus({ actor })` validates the selected store's bootstrap access and reports its ID, namespace, capabilities and a sanitized readiness problem. It neither enumerates nor reads credential values.
380
+
381
+ Call `sanoma.connection.test` with `{ connection }` to verify the current bundle under policy. Its health result is a timestamped observation, not a live guarantee or background monitor: it can report expired, revoked, wrong-account, changed-scope, invalid, unavailable or store-denied states. An external bundle whose opaque version differs from the prior test is marked externally renewed. A later vendor or store change is detected on the next Test or authorized invocation.
382
+
383
+ `sanoma.connection.disconnect` takes `{ connection, expectedRevision }` and makes the binding unusable under a new revision. It does not delete any stored value or revoke the token at the vendor; externally managed values remain with their owner. Deletion, vendor revocation, OAuth consent/refresh and provider-store migration require their own lifecycle support. The app's [connection forms](../app/README.md#connection-management) implement this secure intake and show these states; they do not add a second configuration source.
384
+
385
+ #### Bootstrap keys
386
+
387
+ `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.
388
+
389
+ 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.
390
+
391
+ 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
392
 
252
393
  ### Resources
253
394
 
@@ -277,9 +418,9 @@ A resource type is a connector's group under its own `type`; `defineConnector` r
277
418
 
278
419
  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
420
 
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)).
421
+ 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
422
 
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.
423
+ 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
424
 
284
425
  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
426
 
@@ -307,7 +448,7 @@ export const outbound = collection({
307
448
 
308
449
  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
450
 
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:
451
+ `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
452
 
312
453
  ```ts
313
454
  uses: [outbound.get, outbound.save],
@@ -317,7 +458,7 @@ async run(ctx, { company }) {
317
458
  },
318
459
  ```
319
460
 
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.
461
+ `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
462
 
322
463
  ### Direct calls
323
464
 
@@ -334,16 +475,16 @@ const customer = await client.call("stripe.customer.get", { id }, { actor: { id:
334
475
 
335
476
  ### Data files
336
477
 
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:
478
+ 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
479
 
339
480
  ```ts
340
481
  // resources/identity/github.ts
341
482
  import { github } from "@sanoma-ai/connector-github/resources";
342
483
 
343
- export const website = github.repository({ name: "website", visibility: "public", has_wiki: false });
484
+ export const website = github.repository({ owner: "acme", name: "website", visibility: "public", has_wiki: false });
344
485
 
345
486
  export const websiteMain = github.branch_protection({
346
- repository_id: website, // a reference: stands for the repository's name
487
+ repository_id: website, // a reference: stands for the repository's identity, "acme/website"
347
488
  pattern: "main",
348
489
  enforce_admins: true,
349
490
  });
@@ -353,35 +494,33 @@ export default [website, websiteMain];
353
494
 
354
495
  Another data file names `website` by importing it: `import { website } from "./github.ts"`.
355
496
 
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.
497
+ 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.
357
498
 
358
- 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.
499
+ 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. `liveDescription(config)` (`@sanoma-ai/workflows/describe`) keeps a `describeConfig` and makes it again only once a file a load read (`stampsOf`), the lockfile, or a folder a new data file, app or site would appear in has changed: the app reads its description through it, so a saved field shows on the next page without a restart. A superseded generation's modules stay in Node's cache, a copy of the files per edit.
359
500
 
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:
501
+ 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.
361
502
 
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
- }
503
+ `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`:
375
504
 
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;
505
+ ```jsonc
506
+ // A DeclaredResource
507
+ {
508
+ "id": "resources/identity/github.ts#websiteMain", // <file>#<export>, the file relative to the root
509
+ "file": "resources/identity/github.ts",
510
+ "vendor": "github",
511
+ "type": "branch_protection",
512
+ "name": "acme/website:main", // what people call it: its identity, else its export's name
513
+ "identity": "acme/website:main", // the import id that finds it unbound, from `find`
514
+ "binding": "BPR_kwDOabc123", // the id of the object the lockfile binds it to
515
+ "at": { "file": "resources/identity/github.ts", "export": "websiteMain" },
516
+ "desired": { "repository_id": "acme/website", "pattern": "main", "enforce_admins": true }, // as its type checked them
517
+ "refs": { "repository_id": "resources/identity/github.ts#website" }, // where it names a resource, that resource's id
381
518
  }
519
+ // 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
520
+ { "file": "resources/identity/github.ts", "line": 4, "column": 31, "message": "github.repository: no field wiki" }
382
521
  ```
383
522
 
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`.
523
+ 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
524
 
386
525
  #### Bindings
387
526
 
@@ -394,11 +533,15 @@ Which vendor object a declaration means lives in a lockfile beside the data file
394
533
  }
395
534
  ```
396
535
 
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? }`.
536
+ `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? }`.
537
+
538
+ #### Writing a data file
539
+
540
+ 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
541
 
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.
542
+ #### Where Sanoma parses instead
400
543
 
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.
544
+ 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
545
 
403
546
  ### Drift
404
547
 
@@ -458,7 +601,7 @@ A database is a resource type that holds the company's database (a Neon project,
458
601
 
459
602
  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
603
 
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).
604
+ 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
605
 
463
606
  ### Versions
464
607
 
@@ -558,7 +701,7 @@ The steps, as [Cucumber expressions](https://github.com/cucumber/cucumber-expres
558
701
  | 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
702
  | 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
703
 
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.
704
+ 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
705
 
563
706
  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
707
 
@@ -631,14 +774,14 @@ A sandbox run does not survive a worker restart: its fakes keep their state in t
631
774
 
632
775
  `@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
776
 
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`.
777
+ - `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`.
778
+ - `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 or a field of the wrong type (a number for `icon`) is a problem and that field is left out, the rest still read, so a bad `icon` keeps a site public, though a wrong `public`, `login` or `locales` leaves out all three; 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 too), 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
779
  - `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
780
  - `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
781
  - `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
782
  - `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
783
  - 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.
784
+ - `@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
785
  - `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
786
 
644
787
  ### Outline