@sanoma-ai/workflows 0.10.2 → 0.10.6

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 (152) hide show
  1. package/CLOUD-IDENTITY.md +128 -17
  2. package/README.md +56 -13
  3. package/dist/apply.d.ts +11 -4
  4. package/dist/apply.js +127 -36
  5. package/dist/apply.js.map +1 -1
  6. package/dist/call.d.ts +5 -2
  7. package/dist/call.js +65 -20
  8. package/dist/call.js.map +1 -1
  9. package/dist/change.d.ts +5 -6
  10. package/dist/change.js +6 -9
  11. package/dist/change.js.map +1 -1
  12. package/dist/client.d.ts +18 -0
  13. package/dist/client.js +35 -3
  14. package/dist/client.js.map +1 -1
  15. package/dist/cloud-identity.d.ts +5 -1
  16. package/dist/cloud-identity.js +13 -77
  17. package/dist/cloud-identity.js.map +1 -1
  18. package/dist/config.d.ts +45 -4
  19. package/dist/config.js +73 -11
  20. package/dist/config.js.map +1 -1
  21. package/dist/db.d.ts +2 -0
  22. package/dist/db.js +7 -0
  23. package/dist/db.js.map +1 -1
  24. package/dist/deadlines.d.ts +26 -13
  25. package/dist/deadlines.js +50 -11
  26. package/dist/deadlines.js.map +1 -1
  27. package/dist/define.d.ts +29 -0
  28. package/dist/define.js +3 -0
  29. package/dist/define.js.map +1 -1
  30. package/dist/deployment-key.d.ts +20 -1
  31. package/dist/deployment-key.js +28 -3
  32. package/dist/deployment-key.js.map +1 -1
  33. package/dist/describe.js +28 -1
  34. package/dist/describe.js.map +1 -1
  35. package/dist/description.d.ts +3 -0
  36. package/dist/description.js +2 -0
  37. package/dist/description.js.map +1 -1
  38. package/dist/dns.d.ts +20 -0
  39. package/dist/dns.js +26 -0
  40. package/dist/dns.js.map +1 -0
  41. package/dist/drift.d.ts +4 -2
  42. package/dist/drift.js +13 -4
  43. package/dist/drift.js.map +1 -1
  44. package/dist/host.d.ts +13 -4
  45. package/dist/host.js +14 -5
  46. package/dist/host.js.map +1 -1
  47. package/dist/identity-broker.d.ts +14 -0
  48. package/dist/identity-broker.js +84 -0
  49. package/dist/identity-broker.js.map +1 -0
  50. package/dist/identity-provisioning.d.ts +29 -0
  51. package/dist/identity-provisioning.js +178 -0
  52. package/dist/identity-provisioning.js.map +1 -0
  53. package/dist/identity-service.d.ts +25 -0
  54. package/dist/identity-service.js +148 -0
  55. package/dist/identity-service.js.map +1 -0
  56. package/dist/index.d.ts +11 -6
  57. package/dist/index.js +9 -5
  58. package/dist/index.js.map +1 -1
  59. package/dist/ledger.d.ts +30 -1
  60. package/dist/ledger.js +31 -0
  61. package/dist/ledger.js.map +1 -1
  62. package/dist/login-code.d.ts +17 -0
  63. package/dist/login-code.js +32 -0
  64. package/dist/login-code.js.map +1 -0
  65. package/dist/op.d.ts +85 -10
  66. package/dist/op.js +20 -0
  67. package/dist/op.js.map +1 -1
  68. package/dist/outline.js +3 -3
  69. package/dist/outline.js.map +1 -1
  70. package/dist/provisioning-identity.d.ts +27 -0
  71. package/dist/provisioning-identity.js +81 -0
  72. package/dist/provisioning-identity.js.map +1 -0
  73. package/dist/resource.d.ts +73 -10
  74. package/dist/resource.js +103 -14
  75. package/dist/resource.js.map +1 -1
  76. package/dist/resources.d.ts +4 -2
  77. package/dist/resources.js +16 -6
  78. package/dist/resources.js.map +1 -1
  79. package/dist/run.d.ts +3 -0
  80. package/dist/run.js.map +1 -1
  81. package/dist/runtime-ops.d.ts +182 -0
  82. package/dist/runtime-ops.js +294 -0
  83. package/dist/runtime-ops.js.map +1 -0
  84. package/dist/scenario.d.ts +6 -3
  85. package/dist/scenario.js +13 -1
  86. package/dist/scenario.js.map +1 -1
  87. package/dist/seal.d.ts +27 -0
  88. package/dist/seal.js +76 -0
  89. package/dist/seal.js.map +1 -0
  90. package/dist/secret-store.d.ts +6 -4
  91. package/dist/secret-store.js +11 -6
  92. package/dist/secret-store.js.map +1 -1
  93. package/dist/secret.d.ts +7 -0
  94. package/dist/secret.js +13 -1
  95. package/dist/secret.js.map +1 -1
  96. package/dist/shared.d.ts +15 -4
  97. package/dist/shared.js +27 -7
  98. package/dist/shared.js.map +1 -1
  99. package/dist/ssh-key.d.ts +11 -0
  100. package/dist/ssh-key.js +44 -0
  101. package/dist/ssh-key.js.map +1 -0
  102. package/dist/tokens.d.ts +35 -0
  103. package/dist/tokens.js +71 -0
  104. package/dist/tokens.js.map +1 -0
  105. package/dist/wakeup-service.js +2 -7
  106. package/dist/wakeup-service.js.map +1 -1
  107. package/dist/wakeup.d.ts +18 -0
  108. package/dist/wakeup.js +15 -0
  109. package/dist/wakeup.js.map +1 -1
  110. package/dist/worker.d.ts +2 -2
  111. package/dist/worker.js +17 -9
  112. package/dist/worker.js.map +1 -1
  113. package/oxlint.json +2 -1
  114. package/package.json +5 -1
  115. package/src/apply.ts +166 -48
  116. package/src/call.ts +88 -18
  117. package/src/change.ts +9 -20
  118. package/src/client.ts +48 -7
  119. package/src/cloud-identity.ts +16 -76
  120. package/src/config.ts +116 -18
  121. package/src/db.ts +8 -1
  122. package/src/deadlines.ts +66 -20
  123. package/src/define.ts +33 -0
  124. package/src/deployment-key.ts +34 -8
  125. package/src/describe.ts +39 -2
  126. package/src/description.ts +2 -0
  127. package/src/dns.ts +29 -0
  128. package/src/drift.ts +13 -4
  129. package/src/host.ts +22 -6
  130. package/src/identity-broker.ts +94 -0
  131. package/src/identity-provisioning.ts +208 -0
  132. package/src/identity-service.ts +179 -0
  133. package/src/index.ts +20 -1
  134. package/src/ledger.ts +52 -1
  135. package/src/login-code.ts +52 -0
  136. package/src/op.ts +94 -11
  137. package/src/outline.ts +3 -3
  138. package/src/provisioning-identity.ts +106 -0
  139. package/src/resource.ts +155 -20
  140. package/src/resources.ts +14 -4
  141. package/src/run.ts +3 -0
  142. package/src/runtime-ops.ts +313 -0
  143. package/src/scenario.ts +24 -6
  144. package/src/seal.ts +95 -0
  145. package/src/secret-store.ts +12 -9
  146. package/src/secret.ts +16 -1
  147. package/src/shared.ts +31 -7
  148. package/src/ssh-key.ts +48 -0
  149. package/src/tokens.ts +100 -0
  150. package/src/wakeup-service.ts +2 -8
  151. package/src/wakeup.ts +21 -0
  152. package/src/worker.ts +29 -11
package/CLOUD-IDENTITY.md CHANGED
@@ -1,8 +1,8 @@
1
- # Managed Cloud runtime identity
1
+ # Managed Cloud identity
2
2
 
3
3
  `@sanoma-ai/workflows/cloud-identity` supplies the identity-broker handler and refreshable Google SDK authentication for [D1, #492](https://github.com/Sanoma-AI/sanoma/issues/492). It is a reusable implementation boundary. Isolated live checks on a temporary Cloud Run service in `sanoma-513217` have verified attached-identity issuance, scoped KMS use and separate database permissions; production deployment remains open.
4
4
 
5
- This runtime route is separate from the provisioning OIDC identity in [#489](https://github.com/Sanoma-AI/sanoma/issues/489). Provisioning manages enrollment and infrastructure. The company runtime receives only its deployment's bootstrap credential and short-lived access tokens. The broker issues identity; it does not read company credential rows, decrypt credentials or unwrap data keys.
5
+ Runtime and provisioning identity are separate trust boundaries. `identityBroker` issues ten-minute OAuth access tokens for company runtimes. `provisioningIdentityBroker` supplies Google-issued OIDC tokens for the control plane’s Workload Identity Federation (WIF) flow. Deploy them with separate attached accounts, enrollment databases/readers and bootstrap credentials. Sharing handler code does not establish that separation. Neither handler reads company credential rows or unwraps data keys.
6
6
 
7
7
  ## Request and authority
8
8
 
@@ -39,7 +39,7 @@ GRANT USAGE ON SCHEMA sanoma_identity
39
39
  TO identity_broker_reader, identity_enrollment_writer;
40
40
  GRANT SELECT ON sanoma_identity.deployments, sanoma_identity.credentials
41
41
  TO identity_broker_reader;
42
- GRANT SELECT, INSERT, UPDATE, DELETE
42
+ GRANT SELECT, INSERT, UPDATE
43
43
  ON sanoma_identity.deployments, sanoma_identity.credentials
44
44
  TO identity_enrollment_writer;
45
45
  -- Separate pre-created logins, neither superuser nor schema/table owner:
@@ -49,24 +49,39 @@ GRANT identity_enrollment_writer TO example_provisioner_login;
49
49
 
50
50
  The broker login must have no writer-role membership, inherited write grants or authority to grant itself privileges. The provisioning login stays outside company runtimes and outside the broker's serving process. Database connection/authentication is separate bootstrap material; it cannot be fetched from the same service it unlocks.
51
51
 
52
- Trusted provisioning calls `createDeploymentCredential()` to generate 32 random bytes encoded as base64url plus its SHA-256 hash. Deliver the credential once through that deployment's protected secret channel; persist only its hash. Enrollment writes should be parameterized. For example, with synthetic deployment identifiers and the generated hash supplied as `$1`:
52
+ ### Provisioning operations
53
53
 
54
- ```sql
55
- BEGIN;
56
- INSERT INTO sanoma_identity.deployments
57
- (id, company, environment, service_account)
58
- VALUES ('example-deployment', 'example-company', 'production',
59
- 'runtime@example-project.iam.gserviceaccount.com');
60
- INSERT INTO sanoma_identity.credentials
61
- (hash, deployment, generation, valid_from, valid_until)
62
- VALUES ($1, 'example-deployment', 1, clock_timestamp(),
63
- clock_timestamp() + interval '1 day');
64
- COMMIT;
54
+ The trusted control-plane config supplies `identityProvisioning`; company runtimes and the serving broker do not receive this writer. Install `CLOUD_IDENTITY_SQL` separately with administrative authority, then construct the writer over its dedicated database pool:
55
+
56
+ ```ts
57
+ import { postgresIdentityProvisioning } from "@sanoma-ai/workflows/cloud-identity";
58
+
59
+ const identityProvisioning = postgresIdentityProvisioning(async () => enrollmentWriterPool, {
60
+ maxValidityMs: 24 * 60 * 60 * 1000,
61
+ });
62
+ // Supply identityProvisioning to defineConfig in the trusted control plane.
63
+ // Deployment composition owns the writer pool and closes it on shutdown.
65
64
  ```
66
65
 
67
- The interval is illustrative. The SQL schema requires finite timestamps and checks that `valid_until` follows `valid_from`; provisioning must enforce its maximum validity and operational renewal policy. There is no HTTP enrollment endpoint.
66
+ The one-day limit is illustrative; choose a validity and renewal policy for the deployment. `maxValidityMs` is required and enforced against the enrollment database clock. The adapter does not install schemas, create roles, provision IAM, deliver credentials or schedule renewal.
67
+
68
+ Workflows declare the built-in `sanoma.identity.enroll`, `.retire` and `.revoke` operations in `uses`. They pass through the existing policy and ledger path; direct calls are refused. All three identify the same fixed mapping: `deployment`, `company`, `environment` and `serviceAccount`. The workflow and policy must authorize the actor against that company and environment before granting access.
68
69
 
69
- For rotation, provision a new random credential under a higher unused generation, deliver it to the deployment, and set the old generation's `valid_until` to a bounded overlap deadline. Revoke a generation with `revoked = true`, or disable the deployment to prevent renewal for all its generations. Do not repoint an existing deployment to another company's service account as a credential-rotation shortcut. Restore enrollment backups carefully so revoked credentials do not become active again.
70
+ | Operation | Additional input | Result and behavior |
71
+ | --------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
72
+ | `enroll` | `generation`, `validUntil`, `store` | Ensures a random bootstrap credential in the selected secret store, enrolls its SHA-256 hash and returns `{ deployment, generation, store }`. No bearer or hash enters the workflow result. |
73
+ | `retire` | `generation`, `validUntil` | Shortens a generation's validity without extending it; a deadline at or before the database's current time revokes it. Returns `{}`. |
74
+ | `revoke` | None | Disables renewal for every generation of the deployment. This is terminal for that deployment ID. Returns `{}`. |
75
+
76
+ Use a distinct secret name for each deployment and generation, such as `ACME_PRODUCTION_BOOTSTRAP_G1`. A higher generation must use a new credential; reusing an enrolled hash is refused. Neither rotation nor retry may change the deployment's company, environment or service account.
77
+
78
+ Enrollment writes take a deployment-row lock, so concurrent enrollment, retirement and revocation serialize. A retry with the same mapping, generation, hash and still-valid deadline succeeds. A changed or retired deadline, revoked credential, disabled deployment, reused hash or lower unused generation is refused. If the database response is lost after commit, retry with the original inputs and secret name; the stored credential is retained. Do not delete or replace that secret while the outcome is uncertain. A failed enrollment can leave an inactive secret; reconcile the enrollment before removing it.
79
+
80
+ Rotation consists of enrolling a higher generation under a new secret name, securely delivering it, verifying renewal with it, then retiring the old generation with a bounded overlap deadline. These operations do not perform delivery or readiness verification. Revocation stops further issuance, but already issued Google tokens can remain usable until their expiry.
81
+
82
+ Database failures are sanitized and classified as unavailable; invalid inputs and conflicting enrollment state are not retryable. There is no HTTP enrollment endpoint. Sandbox runs use separate in-memory enrollment authority and never touch the live enrollment database; they do not validate the deployment-specific maximum validity.
83
+
84
+ Restore enrollment backups carefully so revoked credentials do not become active again. Bootstrap values remain in the selected secret store and the deployment's protected delivery channel; the enrollment database stores only their hashes.
70
85
 
71
86
  Broker composition uses its read-only database pool and a separate durable audit sink:
72
87
 
@@ -138,3 +153,99 @@ Before rollout:
138
153
  - Deliver each bootstrap credential only to its intended deployment. For Cloudflare, explicitly pass its protected Worker binding to that company's container. Prove cold start, resume, refresh through token expiry, bounded credential rotation, revoked/expired generations and broker/audit outages.
139
154
  - Prove company A's bearer cannot select B via body/query, A's token cannot use B's key, A's database login cannot read B's rows, and preview cannot access production. Synthetic handler tests are insufficient evidence for Google IAM or database isolation.
140
155
  - Verify issuance and KMS audit attribution, credential/header redaction, service quotas, backup recovery and retained key versions. Record the actual deployment evidence before declaring D1 or managed Cloud ready.
156
+
157
+ ## Provisioning identity from a non-Google host
158
+
159
+ The trusted control plane can obtain renewable provisioning credentials without a service-account private key:
160
+
161
+ ```mermaid
162
+ flowchart LR
163
+ control[Trusted control plane] -->|GET /identity + provisioning bearer| issuer[Separate Cloud Run provisioning issuer]
164
+ issuer -->|Read fixed enrollment| db[(Provisioning enrollment database)]
165
+ issuer -->|Attached identity: generateIdToken| google[Google IAM Credentials]
166
+ google -->|OIDC token for fixed source account| issuer
167
+ issuer -->|idToken + expiresAt| control
168
+ control -->|OIDC token| sts[Google STS / WIF]
169
+ sts -->|Federated credential| target[Impersonate provisioning service account]
170
+ target -->|Scoped access token| provider[Google OpenTofu provider]
171
+ ```
172
+
173
+ Three identities have different responsibilities:
174
+
175
+ - The **attached issuer account** has `iam.serviceAccounts.getOpenIdToken` on the fixed source account only.
176
+ - The **source account** identifies the control plane to WIF through its immutable numeric Google `uniqueId`; it needs no infrastructure-administration roles.
177
+ - The **provisioning account** receives scoped infrastructure permissions and grants impersonation only to the intended WIF principal.
178
+
179
+ Trusted `ProvisioningIdentity` configuration fixes the source `serviceAccount`, numeric `subject` and exact WIF provider `audience`. Configure WIF for issuer `https://accounts.google.com`, map `google.subject` from `assertion.sub`, and restrict trust to the intended subject and audience. The source and target provisioning accounts are different identities.
180
+
181
+ `provisioningIdentityBroker` accepts **GET `/identity`** with a provisioning bearer, no query and no body. Its enrollment must map to the configured source account. The caller cannot select an account, audience or lifetime. It checks enrollment before issuance and before delivery; audit failure prevents delivery. Responses contain only `{ idToken, expiresAt }` and prohibit caching.
182
+
183
+ `attachedGoogleIdTokenIssuer(identity)` uses the attached `Compute` identity to call [Google’s generateIdToken API](https://docs.cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/generateIdToken). It validates returned claims and expiry; Google STS verifies the token signature during federation. This trusted callback does not accept caller-supplied JWTs.
184
+
185
+ ```ts
186
+ import {
187
+ provisioningIdentityBroker,
188
+ attachedGoogleIdTokenIssuer,
189
+ postgresIdentityEnrollments,
190
+ } from "@sanoma-ai/workflows/cloud-identity";
191
+
192
+ const identity = {
193
+ serviceAccount: "provision-source@example-project.iam.gserviceaccount.com",
194
+ subject: "123456789012345678901",
195
+ audience:
196
+ "//iam.googleapis.com/projects/123456789012/locations/global/workloadIdentityPools/provision/providers/google",
197
+ };
198
+ const handle = provisioningIdentityBroker({
199
+ identity,
200
+ enrollments: postgresIdentityEnrollments(async () => provisioningReadOnlyPool),
201
+ issue: attachedGoogleIdTokenIssuer(identity),
202
+ audit: writeDurableProvisioningAudit,
203
+ });
204
+ ```
205
+
206
+ The pool and audit sink belong to this separate service. Use the GCP connector’s `gcpProvisioningIdentity` helper to supply this endpoint as an external-account URL credential source to both the Node verifier and the Google provider. Its protected header contains a provisioning bearer; never put the resulting configuration in source control, workflow inputs/results or logs. The target account still comes from trusted deployment configuration.
207
+
208
+ Google-issued OIDC tokens last up to **one hour**. A ten-minute final impersonated access token does not shorten that lifetime. Revoking enrollment stops new issuance, but an issued OIDC or federated credential may still obtain access tokens. Incident revocation must also remove federation and/or target impersonation trust, accounting for IAM propagation and access tokens already issued. These helpers do not implement that IAM revocation workflow. The runtime route remains POST `/token` with ten-minute OAuth access tokens.
209
+
210
+ ### Evidence and remaining integration
211
+
212
+ An isolated live check in `sanoma-513217` verified Google-issued OIDC tokens, STS exchange, scoped impersonation and rejection of another subject and target. The observed OIDC lifetime was 3,600 seconds. Temporary federation and service accounts were removed. Local tests cover both broker routes and Google’s URL-source exchange using synthetic transports.
213
+
214
+ This establishes the Google trust path, not a deployed provisioning broker or provider renewal through that broker. Deploying the separate issuer/enrollment/audit boundary, delivering and rotating protected bootstrap configuration, and proving renewal and revocation through the actual host remain [#532](https://github.com/Sanoma-AI/sanoma/issues/532).
215
+
216
+ ## Standalone identity service
217
+
218
+ `sanoma identity` serves one broker mode from protected deployment environment variables. It loads no company config, `.env`, workflows or sites, starts no worker, and creates no database or schema. Deploy runtime and provisioning modes as separate services with separate attached identities, enrollment readers, bootstraps and audit boundaries; a mode flag alone does not establish that separation.
219
+
220
+ | Setting | Purpose |
221
+ | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------- |
222
+ | `SANOMA_IDENTITY_MODE` | Required: `runtime` or `provisioning`. |
223
+ | `SANOMA_IDENTITY_READ_DATABASE_URL` | Required protected enrollment-reader connection string. |
224
+ | `SANOMA_IDENTITY_AUDIT_DATABASE_URL` | Required protected audit-writer connection string. |
225
+ | `SANOMA_IDENTITY_HOST` | Listening address, default `0.0.0.0` for the command. |
226
+ | `SANOMA_IDENTITY_CONCURRENCY` | Maximum in-flight broker requests, default `32`, allowed `1`–`256`; excess requests receive 429 with a retry delay. |
227
+ | `PORT` | Listening port, default `8080`. |
228
+ | `SANOMA_IDENTITY_SOURCE_ACCOUNT` | Provisioning only: fixed source service-account email. |
229
+ | `SANOMA_IDENTITY_SOURCE_SUBJECT` | Provisioning only: its immutable numeric Google `uniqueId`. |
230
+ | `SANOMA_IDENTITY_AUDIENCE` | Provisioning only: exact WIF provider audience. |
231
+
232
+ Supply connection strings through the host's protected deployment mechanism. Never put them in source, resource plans, workflow inputs/results or logs. Runtime mode serves POST `/token`; provisioning mode serves GET `/identity`. Both require the corresponding enrollment bearer, use an attached Google identity and expose no enrollment endpoint. Put the HTTP listener behind HTTPS ingress, such as Cloud Run's TLS endpoint.
233
+
234
+ An administrator installs `CLOUD_IDENTITY_SQL` from `@sanoma-ai/workflows/cloud-identity` in the enrollment database and `CLOUD_IDENTITY_AUDIT_SQL` from `@sanoma-ai/workflows/identity-service` in the audit database. The service performs no DDL. The enrollment login needs schema USAGE and SELECT on the two enrollment tables, with no table mutation or schema CREATE privileges. The audit login needs schema USAGE and INSERT on `sanoma_identity.audit_events`, with no SELECT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER or schema CREATE privileges. For example, in the audit database:
235
+
236
+ ```sql
237
+ CREATE ROLE identity_audit_writer NOLOGIN;
238
+ GRANT USAGE ON SCHEMA sanoma_identity TO identity_audit_writer;
239
+ GRANT INSERT ON sanoma_identity.audit_events TO identity_audit_writer;
240
+ GRANT identity_audit_writer TO example_audit_login;
241
+ ```
242
+
243
+ Create the login and deliver its authentication separately. Neither serving role may be superuser, create roles/databases, bypass RLS or inherit the disallowed privileges. Startup checks these role and table/schema permissions and refuses excessive authority. Keep schema ownership and enrollment writes with separate administrative/provisioning identities.
244
+
245
+ Audit rows contain fixed issuance metadata: result, deployment, company, environment, service account, generation and expiry. They contain no tokens, bearer hashes, authorization headers or arbitrary request fields. Audit failure prevents token delivery and returns a sanitized error; recovery never switches identities.
246
+
247
+ GET or HEAD `/healthz` and the launcher's `ready` event mean the process is listening. They do **not** prove current Google issuance, continuing database/audit availability, provider renewal or managed-store readiness. Before declaring a company ready, verify its deployed runtime can write and read a credential under its own key/database identity. The two-company, preview/production, renewal, cold-start, outage and recovery checks remain #532 gates.
248
+
249
+ Trusted programmatic composition may call `startIdentityService(options)` from `@sanoma-ai/workflows/identity-service`, returning `{ url, stop }`. Its default host is loopback; the caller owns shutdown. The serving package does not load company code.
250
+
251
+ The product's [identity Dockerfile](../sanoma/identity/Dockerfile) installs a specified published `sanoma` version and runs `sanoma identity` as a non-root user. Build it only after that version is published, tag the result immutably and deploy by digest. It is separate from the company runtime image, which clones company code. Database credentials and mode configuration enter at deployment, not image build. Neither the service tests nor building the image substitutes for live deployment verification.
package/README.md CHANGED
@@ -53,7 +53,7 @@ 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_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)).
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, `sendCode(email, code)` when the config names a `login.sender`, and `social`, the company's GitHub App's OAuth client when the app is given it); 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, with the email and the GitHub login when it verified them, 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 (and an email code, with a sender) on the company's database; it also exports `betterAuthStaffLogin`, the staff sign-in the app uses on a host (`LoginContext.staff`: who is declared, the GitHub client, and where a sign-in is recorded). 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
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
 
@@ -137,10 +137,28 @@ 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)` 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.
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, and `login`, which sends sign-in codes) 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
 
144
+ A script or another service acts through an API token: `client.tokens.mint({ subject, groups })` makes one, `sca_` and 32 random bytes in base64url, and returns `{ id, token }`, the token shown this once (only its SHA-256 is kept, in `sanoma_api_tokens`); `subject` is whom it acts for (`visitor:better-auth:<id>`, say). A public site's host takes it as `Authorization: Bearer <token>`, and the request acts as `token:<id>` in the token's `groups` (admission keeps them, where a visitor's are stamped `visitors`), with no email; `client.tokens.revoke({ id })` ends it, and an unknown or revoked token is a 401.
145
+
146
+ #### Signing in on a host
147
+
148
+ The app on a host signs people in by an email code, an invite link, or GitHub ([the app's README](https://www.npmjs.com/package/@sanoma-ai/app#staff-sign-in-on-a-host)). The code goes out through the operation the config names as `login.sender`, a connector's send that takes `{ to, subject, text }`:
149
+
150
+ ```ts
151
+ import { resend } from "@sanoma-ai/connector-resend";
152
+
153
+ export default defineConfig({
154
+ connectors: [resend],
155
+ login: { sender: resend.emails.send },
156
+ // …
157
+ });
158
+ ```
159
+
160
+ With one, the runtime registers the built-in workflow `login-code` (so a company workflow may not be named that), which the app starts as the agent `login` with the code sealed under `SANOMA_SIGNING_KEY` together with the email it is for (`sealLoginCode(to, code)`, so a copy of a run's input mails it to nobody else); its one call mails "Your <company> sign-in code", under the policy and in the ledger like any send, the mail's `text` recorded as `<text>`. A policy that holds sends exempts it (`unless { … || principal == Sanoma::Principal::"login" }`): otherwise `describeConfig` reports a problem on `policies`, naming the policy and the fix. `resolveConfig` refuses a sender that is no connector's operation, does not send, has no driver, or takes no email. Each sign-in and each invite is a ledger record outside any run (`signin.recorded { person, method }` under `signin:<person>`, `invite.minted { person, by, expiresAt }` under `invite:<person>`), written by `client.recordSignIn(person, method)` and `client.recordInvite(person, { by, expiresAt })`; `client.lastSignIns(people)` reads when each last signed in and how. `sealInvite({ email })` seals an invite request for five minutes under `SANOMA_SIGNING_KEY` (`openInvite` opens one), which an instance holding the same key answers with a one-use invite link at `POST /auth/invite`.
161
+
144
162
  ### Policies
145
163
 
146
164
  The policy is [Cedar](https://docs.cedarpolicy.com): `policies/**/*.cedar` in the root, evaluated in process with `@cedar-policy/cedar-wasm` against a schema built from the resolved config and the people (`schemaOf`). Everything is allowed unless a `forbid` refuses it: the runtime adds the one `permit`, so a company's policies are `forbid`s. That permit leaves [visitors](#people) out at a run's start and at a direct call: a visitor may do neither until a company `permit` names it, the one kind of `permit` a company file may hold, its principal exactly `in Sanoma::Group::"visitors"` or `== Sanoma::Principal::"anonymous"`. Inside a run a visitor was let start, its calls are allowed as anyone's, the forbids still holding, so one permit per workflow is enough and the run's operations stay out of a visitor's direct reach. A visitor's start or direct call no permit allows is denied ("A visitor may do nothing until a policy permits it", `VISITOR_DENIED`), never held; `allowAll`, a repo with no policies, denies them the same, and a visitor starts none of the runtime's own workflows but `call`.
@@ -215,7 +233,7 @@ A run's ctx calls run one at a time, in program order. Even inside `Promise.all`
215
233
 
216
234
  A workflow gets these on `ctx` by listing them in `uses`, next to its operations: `"approval"` (see [Approvals](#approvals)), `"sleep"` and `"all"`. `describeConfig` lists them as each workflow's `builtins`.
217
235
 
218
- Every workflow has `ctx.runId` and `ctx.now()`, without `uses`. `await ctx.now()` is the time in epoch milliseconds, recorded so a replay reads the same. A date is made from it: with `const now = await ctx.now()`, `new Date(now).toISOString()` is ISO 8601 text, which a collection's `date` field takes (`.slice(0, 10)` for the day alone), and `new Date(now + 86_400_000)` is a day later, from the same reading. The lint refuses only the uses of `Date` that read the clock, `Date.now()`, `new Date()` and `Date()` (and `Date` passed on, which it cannot follow); `new Date(value)`, `Date.parse`, `Date.UTC` and `instanceof Date` read none (see [Keeping workflows replay-safe](#keeping-workflows-replay-safe)). Read a date in UTC (`toISOString`, `getUTCHours`) and parse ISO 8601 text with an offset: the local-time methods (`getHours`, `toLocaleString`) and other text depend on the worker's time zone, which a replay on another worker may not share.
236
+ Every workflow has `ctx.runId`, `ctx.startedBy` and `ctx.now()`, without `uses`. `ctx.startedBy` is who started the run, `{ id, email?, github?, subject? }` (`StartedBy`): the principal's id (a person's, `visitor:<login>:<subject>`, `token:<id>`), for a visitor the email and the GitHub login their site's login verified (the login when they signed in with GitHub or linked it, with the company's GitHub App's client), and for an api token the subject its row acts for (`SanomaClient.tokens`, read at the start, never taken from the caller), so a workflow can tell whose a record is. `await ctx.now()` is the time in epoch milliseconds, recorded so a replay reads the same. A date is made from it: with `const now = await ctx.now()`, `new Date(now).toISOString()` is ISO 8601 text, which a collection's `date` field takes (`.slice(0, 10)` for the day alone), and `new Date(now + 86_400_000)` is a day later, from the same reading. The lint refuses only the uses of `Date` that read the clock, `Date.now()`, `new Date()` and `Date()` (and `Date` passed on, which it cannot follow); `new Date(value)`, `Date.parse`, `Date.UTC` and `instanceof Date` read none (see [Keeping workflows replay-safe](#keeping-workflows-replay-safe)). Read a date in UTC (`toISOString`, `getUTCHours`) and parse ISO 8601 text with an offset: the local-time methods (`getHours`, `toLocaleString`) and other text depend on the worker's time zone, which a replay on another worker may not share.
219
237
 
220
238
  `ctx.sleep({ until })` takes an ISO 8601 date-time with an offset (`2026-10-07T09:00:00Z`) or epoch milliseconds, and a time already past does not wait; `ctx.sleep({ minutes: 5 })` takes a duration from `ms`, `seconds`, `minutes`, `hours` and `days`, none negative. A request it can't read fails the run with `invalid_input`, as does an operation input its schema refuses. Each sleep writes a `sleep.started` record before it waits, with `until`: the time asked for, or for a duration, the record's `at` plus the duration, as the runtime saw it.
221
239
 
@@ -231,6 +249,20 @@ const comments = await ctx.all(threads.map((id) => () => ctx.forum.comments.list
231
249
 
232
250
  The lint refuses `Promise.all` and `Promise.allSettled` in workflows, pointing at `ctx.all`, and `Promise.race` and `Promise.any`: calls through `ctx` run one at a time, so nothing races; pick one call, or sleep and then check.
233
251
 
252
+ #### The runtime's own operations
253
+
254
+ What a workflow cannot do itself, the runtime does as operations of the vendor `sanoma`, in every config with their driver: `import { sanoma } from "sanoma"`, list them in `uses` and call `ctx.sanoma.<group>.<op>`. They are decided by the policy (`Sanoma::Action::"sanoma.secret.generate"`), recorded in the ledger, never answer a secret's value, never take a `SANOMA_…` name (the runtime's own keys and credentials), and are no [direct calls](#direct-calls).
255
+
256
+ | Operation | Input | Output | What it does |
257
+ | ------------------------ | --------------------------------------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
258
+ | `sanoma.secret.generate` | `{ name, kind: "random" \| "ssh-rsa" }` | `{ name, publicKey? }` | Stores a value under `name` (upper case) unless one is stored: `random`, 32 random bytes as a `generate` marker makes; `ssh-rsa`, an RSA key pair as going live's deploy key, its private half (PEM) stored and its public half (`ssh-rsa <base64>`) answered, from what is stored, so a name stored already answers its own. Effect `write` |
259
+ | `sanoma.secret.delete` | `{ name }` | `{}` | Deletes the stored value; none stored is nothing to do. Effect `write` |
260
+ | `sanoma.invite.mint` | `{ host, email, key: secret("NAME") }` | `{ url }` | Seals an invite request for `email` (good for five minutes) under the signing key stored as `NAME`, an instance's `SANOMA_SIGNING_KEY` (`sealInvite` with that key), posts it to `<host>/auth/invite` and answers the one-use link it mints; the ledger records `url` as `"<url>"`. Retried on a status worth retrying. Effect `access` |
261
+ | `sanoma.token.mint` | `{ subject, groups, store: "NAME" }` | `{ id }` | Mints an api token (`SanomaClient.tokens`) acting for `subject` in `groups`, stores it under `store` (refused when `store` holds a value), and answers its id alone; a host takes it as `secret("NAME")`. Not retried. Effect `access` |
262
+ | `sanoma.token.revoke` | `{ id }` | `{}` | Revokes the token: it resolves to nobody from now on. Effect `access` |
263
+
264
+ A sandbox run calls them over its own secrets and tokens, in memory, and `invite.mint` posts nothing there.
265
+
234
266
  #### Step names
235
267
 
236
268
  Every call is a step with a name, which a test and the graph name it by: `ctx.github.repository.import(input, { name: "Import the repository" })` and `ctx.sleep({ days: 1 }, { name: "Wait a day" })` give one, and an approval is named by its title. Without one, a call is named by its operation's id (`github.repository.import`), a sleep `Sleep` (`DEFAULT_SLEEP_NAME`, exported from the main entry and `@sanoma-ai/workflows/shared`). A name given, and an approval's title, is a string literal or a top-level `const` of the file holding one, so the outline reads it; it is one step's only (`two steps of this workflow are named "Draft"`), and a name given may not be what unnamed calls are called, an operation's id (three dotted words) or `Sleep`, so a name in a test means one call. Unnamed calls may share their default: one operation in two arms of an `if`, a retry in a `catch`, two sleeps. A call in a loop is one step, however often it runs. The outline resolves each call's name (`name` on `op`, `sleep` and `approval` nodes: the name given, the title, the operation's id or `Sleep`), except a call with a computed segment (`ctx[vendor].post.create`) and no name, which the operation it calls names in a run, and an approval whose title is no literal, which its title names in a run. The ledger has the name of every step (`name` on `op.called`, `sleep.started` and `approval.requested`, where a policy hold's is the held call's); records written before steps had names have none. `StepOptions` is the options' type.
@@ -420,12 +452,21 @@ export const github = defineConnector("github", { repository });
420
452
 
421
453
  A resource type is a connector's group under its own `type`; `defineConnector` refuses one of another vendor, or under another key, or on a connector without `info.package` (data files import the types from `<package>/resources`), and keeps the list on the connector, beside its vendor info. Its operations are `<vendor>.<type>.import` (`{ id }`, an identity or a binding id) and `<vendor>.<type>.read` (`{ id, state?, handle? }`, a state from an earlier call), both answering `{ id, gone: true }` when the vendor has no such object and `{ id, gone: false, state, handle? }` when it has, both effect `read`, idempotent, with the id as the policy's `target`. "Missing" is part of the contract: the driver decides it, so a caller never parses a vendor's error for it. A driver implements them like any other. `handle` is the driver's own data about the object, opaque to everyone else, passed back unchanged (an OpenTofu driver keeps the provider's private data and state version in it); the operations declare it `opaque`, so the ledger records it as `"<handle>"`, never its bytes. Any operation may name such fields (`opaque` in its spec).
422
454
 
423
- 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.
455
+ A `writable` type has four change operations besides. The built-in apply workflow (`APPLY_WORKFLOW`, `"apply"`, a name a config's own workflow may not take) calls them for the declared resources, and only it changes a declared object; any workflow may name them in its `uses` and call them for an object no data file declares, as a control plane makes and deletes a host per customer. Such a call is an ordinary write, held to the policy and recorded in the ledger like any other, and a run of any workflow but apply is refused (`invalid_input`, before the policy is asked) the call aimed at a declared object: an `update` or `delete` whose `id` the lockfile binds, or a `create` whose identity (what the type's `find` makes of `desired`) a data file declares or the lockfile binds. A direct call of one is refused as for any write. `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.
424
456
 
425
457
  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)).
426
458
 
427
459
  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.
428
460
 
461
+ A reference with `from`, a dotted path in the named resource's state, takes that state instead: apply and drift give the field the resource's state at that path, as a whole (a list or an object), read in the same run or made by an earlier step of it. So a sending domain's DNS records go to the zone that holds them, the record set's type declaring `{ records: { type: "resend.domain", from: "dns_records" } }`:
462
+
463
+ ```ts
464
+ export const mail = resend.domain({ name: "sanoma.work" });
465
+ export const mailDns = cloudflare.records({ zone: "sanoma.work", name: "resend", records: mail });
466
+ ```
467
+
468
+ The resource stands for the whole field, never an item of a list (`records: [mail]` is refused), and a literal value is still allowed there; a reference has `from` or `by`, not both, and is refused at a write-only field or one that holds a write-only field. Resolving a config refuses a `from` path that is no field of a type the reference may name (`checkFromRefs`), naming that type's fields. `desired` holds the resource's `name` in its place, which the schema does not judge, and the declaration is applied after it. Where the same apply changes the resource at that path (creates or replaces it, or updates it with the path in its plan's `unknown` or another value planned there; an update that touches other fields, such as a domain's re-verify, changes nothing here), or, for a create, the resource has no state yet or nothing at that path, the value is known only after apply: the plan is given `null` there, so a type that takes a `from` field must accept `null` in its `plan` and report the path in `unknown`, and its `update` must keep what the vendor holds for a field left out of `desired`, and a change the new value makes (a replaced domain's new DKIM key) is planned, and approved, with the rest; an update or replace step carries those paths in its own `unknown`. A plan of a bound object whose resource is absent, gone or unreadable and not about to change is given its fields without it, as drift compares them, and its step runs without it. A step planned with the field `null` is planned again at its step, with the value now known: `none` (the value turned out unchanged) leaves it done without a write, and otherwise its update expects the fresh plan's action and replace, since the approval covered the change in kind. An empty list or object at the path counts as known: a vendor that answers `[]` while still filling the list makes the dependent write an empty one. At its step the field takes the resource's state as the earlier step left it; one planned `null` whose value is still not known, as when the resource's own step failed or waited, fails before its driver is called: `its records come from resources/mail.ts#mail, which has no state yet` (or `whose state has no dns_records yet`), and so does a step down the chain from it. A policy and an approval therefore see `null` for such a field: its value comes from another declared resource's state at the vendor, known after apply. Drift compares the field against the bound resource's state, read in the same run, and leaves it out while that resource is absent, gone, unreadable or has nothing at the path, which its own row reports. A bridge-backed type (`tfConnector`) cannot take a `from` field yet: an OpenTofu provider's plan reads `null` as unset, not as known after apply. `dnsRecord` (`DnsRecord`), from the main entry, is the shape a DNS record passes in between two connectors: `{ type: "TXT" | "MX" | "CNAME" | "CAA", name, content, priority?, ttl? }`, `name` the full name in lower case with no trailing dot, a TXT record's `content` unquoted (each refused otherwise, never rewritten), and `ttl` 1 for automatic. Later, not yet: the cases apply hard-codes, a wakeup's `host` (its host's `outputs(state).url`) and a host's `source` (a repository's clone URL, by its `by` fields), could be `from` references, once `from` can name a host's outputs as well as its state.
469
+
429
470
  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.
430
471
 
431
472
  `describeConfig` lists the resource types as `resourceTypes`, each `{ id: "<vendor>.<type>", vendor, type, title, identity, fields, schema, ops }`: `schema` is its state as JSON Schema, with `$id` `sanoma:resource-type/<id>`, which its operations' contracts `$ref` rather than repeat. `@sanoma-ai/workflows/shared` exports the `Resource`, `ResourceSpec`, `ResourceFields`, `References`, `FieldReference`, `Declared`, `Declaring`, `DeclaredResource` and `ResourceProblem` types.
@@ -563,7 +604,7 @@ A host's revision is drift too (see [Hosts](#hosts)): the run resolves its ref w
563
604
 
564
605
  ### Apply
565
606
 
566
- `apply` is the built-in workflow that makes the vendors match the data files, beside `drift` and listed the same way (`builtin: true`). It `uses` the `import` of each resource type with a driver, the change operations of each writable one, and `approval`; it is the only workflow that may call a change operation. Its input is `{ only?, bind?, answers? }` (any other field refused): `only` the declared ids (`<file>#<export>`) or orphaned lockfile keys to plan, else all; `bind`, a declared id to the vendor's id of the object to bind it to; `answers`, by id or key, `recreate` or `forget` for a gone object, `forget` or `delete` for an orphaned binding.
607
+ `apply` is the built-in workflow that makes the vendors match the data files, beside `drift` and listed the same way (`builtin: true`). It `uses` the `import` of each resource type with a driver, the change operations of each writable one, and `approval`; it is the only workflow that may call a change operation on a declared resource. Its input is `{ only?, bind?, answers? }` (any other field refused): `only` the declared ids (`<file>#<export>`) or orphaned lockfile keys to plan, else all; `bind`, a declared id to the vendor's id of the object to bind it to; `answers`, by id or key, `recreate` or `forget` for a gone object, `forget` or `delete` for an orphaned binding.
567
608
 
568
609
  ```ts
569
610
  const runId = await client.apply(
@@ -573,19 +614,19 @@ const runId = await client.apply(
573
614
  const report = await client.applyReport(runId); // { plan: { problems, rebinds, steps }, results, lockfile: { written, revision?, bindings } }
574
615
  ```
575
616
 
576
- The run reads the data files and the lockfile in its first step (`apply:data-files`; a secret's marker stays in `desired`, a plain write-only value does not), with the lockfile's `rebinds`: what changed in it since apply last wrote it (`diffLockfiles` against the bindings the latest live apply run's last `apply:lockfile` step wrote), changes someone else made. It imports each declared resource as drift does (by the binding `bind` gives, else the lockfile's, else its identity), then plans each one `only` wants, each after the resources it names, as a `PlanStep` (`{ id, vendor, type, action, … }`): `bind` (to the object `bind` gives, or the one its identity `found`, by its binding id), `create` (unbound and nothing found: `planned` and `unknown` from its type's `plan`), `update` or `replace` (its bound object as `plan` says, with drift's `fields` and the paths that force a replace), `none`, `gone` (its bound object is no longer there: skipped unless answered, since a recreated database looks fine and has lost its data; a type with `stateless: true`, holding no data, is planned as a `create` instead, unless answered), and `orphan` for each binding no declaration has (skipped unless answered: deleting a declaration never deletes its object). What cannot be planned (a read-only type that is absent or drifted, an answer that cannot be done, an id the input names that nothing declares) is one of the plan's `problems`, with no step.
617
+ The run reads the data files and the lockfile in its first step (`apply:data-files`; a secret's marker stays in `desired`, a plain write-only value does not), with the lockfile's `rebinds`: what changed in it since apply last wrote it (`diffLockfiles` against the bindings the latest live apply run's last `apply:lockfile` step wrote), changes someone else made. It imports each declared resource as drift does (by the binding `bind` gives, else the lockfile's, else its identity), then plans each one `only` wants, each after the resources it names, as a `PlanStep` (`{ id, vendor, type, action, … }`): `bind` (to the object `bind` gives, or the one its identity `found`, by its binding id), `create` (unbound and nothing found: `planned` and `unknown` from its type's `plan`), `update` or `replace` (its bound object as `plan` says, with drift's `fields`, the paths that force a replace, and any `unknown` until it is done), `none`, `gone` (its bound object is no longer there: skipped unless answered, since a recreated database looks fine and has lost its data; a type with `stateless: true`, holding no data, is planned as a `create` instead, unless answered), and `orphan` for each binding no declaration has (skipped unless answered: deleting a declaration never deletes its object). What cannot be planned (a read-only type that is absent or drifted, an answer that cannot be done, an id the input names that nothing declares) is one of the plan's `problems`, with no step.
577
618
 
578
- When the policy would hold any change call the plan makes (asked ahead, in the step `apply:policy`, as each call would ask it), the run asks one approval of the first approver it names, titled "Apply resource changes", covering every change operation in the plan, with the plan as JSON in its `details`; a policy that lets a covered call through once approved (`approvedFor`) then holds none of them again. Then each step runs, in order: `create`, `update` (with `expect`, the plan's action and replace, so a vendor that now plans otherwise refuses with `plan_changed`), `delete`, or only the lockfile changed. A reference to a resource the vendor holds by another field (`by`) is given that resource's binding id, one made earlier in the run included. Right after any step that changes a binding, that binding is changed on the lockfile as it is then (so another run's change made meanwhile stays), written whole and committed through the worker's `source` (`apply:lockfile`: `Apply: create resources/infra.ts#store (supabase.project abcd…)`, by the person who started the run), so an object made is never left unbound. Right after a create's binding is written, the run calls its type's `afterCreate` operations in order, by id, each given the new object's binding id and its export (a Supabase project waits for its health, then stores its database URL); one with `until` is called again every 5 seconds until its output passes, for up to 10 minutes, and one that never does fails the step, its object still bound. The approval covers the `write` ones among them too. Each step's result is `done`, `skipped` (unanswered) or `failed` (with `error`; the run goes on); a host's that it made or changed carries `host`, its `outputs`. A sandbox run writes nothing: the report's `lockfile.bindings` is its copy. `ApplyPlan`, `PlanStep`, `ApplyReport` and `ApplyResult` are the types, also in `@sanoma-ai/workflows/describe` and `/shared`.
619
+ When the policy would hold any change call the plan makes (asked ahead, in the step `apply:policy`, as each call would ask it), the run asks one approval of the first approver it names, titled "Apply resource changes", covering every change operation in the plan, with the plan as JSON in its `details`; a policy that lets a covered call through once approved (`approvedFor`) then holds none of them again. Then each step runs, in order: `create`, `update` (with `expect`, the plan's action and replace, so a vendor that now plans otherwise refuses with `plan_changed`), `delete`, or only the lockfile changed. A reference to a resource the vendor holds by another field (`by`) is given that resource's binding id, one made earlier in the run included, and a `from` reference that resource's state at its path, as found or as an earlier step left it (see [Resources](#resources)). Right after any step that changes a binding, that binding is changed on the lockfile as it is then (so another run's change made meanwhile stays), written whole and committed through the worker's `source` (`apply:lockfile`: `Apply: create resources/infra.ts#store (supabase.project abcd…)`, by the person who started the run), so an object made is never left unbound. Right after a create's binding is written, the run calls its type's `afterCreate` operations in order, by id, each given the new object's binding id and its export (a Supabase project waits for its health, then stores its database URL); one with `until` is called again every 5 seconds until its output passes, for up to 10 minutes, and one that never does fails the step, its object still bound. The approval covers the `write` ones among them too. Each step's result is `done`, `skipped` (unanswered) or `failed` (with `error`; the run goes on); a host's that it made or changed carries `host`, its `outputs`. A sandbox run writes nothing: the report's `lockfile.bindings` is its copy. `ApplyPlan`, `PlanStep`, `ApplyReport` and `ApplyResult` are the types, also in `@sanoma-ai/workflows/describe` and `/shared`.
579
620
 
580
621
  This package knows nothing of OpenTofu. Resource types from an OpenTofu provider are generated by [`@sanoma-ai/bridge/tfschema`](../bridge/src/tfschema/AGENTS.md), which turns the provider's schema document into zod schemas and flagged fields (`resources.gen.ts`); `@sanoma-ai/bridge` depends on this package, never the other way round.
581
622
 
582
623
  ### Hosts
583
624
 
584
- A host is a resource type that runs the company (a container, a Render service, a Fly app): `defineResource({ …, writable: true, host })`, where `host` (`HostSpec`) says where the type takes the runtime's inputs, by dotted path into its fields (`inputs: { source, ref, secrets, port, domains? }`, `secrets` a write-only field; `domains`, when the type takes it, a public site's domain by site name, which the host hands the app as `SANOMA_DOMAINS` and `describeConfig` reports a problem for, at the declaration, when it names a site that is not public or gives one domain two sites: `sanoma.example is the domain of both www and docs`), which field apply fills with the commit (`revision`, which a data file may not declare: it names a branch, never a commit), what it can do (`capabilities: { lifetime, publicUrl, buildsFromGit, staticEgress }`, `lifetime` `long-lived` or `stops-when-quiet`, `HostLifetime`), and how its state reads (`outputs(state) → { url, revision?, healthy }`). `defineResource` refuses a `host` that names no field, or whose `secrets` is not write-only. A write-only field may be a map of secrets (`secrets: { SANOMA_DATABASE_URL: secret("DATABASE_URL") }`): its markers are kept as one at the field is. A host type is `stateless` (a `ResourceSpec` field, default true for a host): its objects keep no data, so replacing or recreating one is routine. `source` is a clone URL, or a reference to a declared repository with a `by` field holding one.
625
+ A host is a resource type that runs the company (a container, a Render service, a Fly app): `defineResource({ …, writable: true, host })`, where `host` (`HostSpec`) says where the type takes the runtime's inputs, by dotted path into its fields (`inputs: { source, ref, secrets, port, domains? }`, `secrets` a write-only field; `domains`, when the type takes it, a public site's domain by site name, which the host hands the app as `SANOMA_DOMAINS` and `describeConfig` reports a problem for, at the declaration, when it names a site that is not public or gives one domain two sites: `sanoma.example is the domain of both www and docs`), which field apply fills with the commit (`revision`, which a data file may not declare: it names a branch, never a commit), what it can do (`capabilities: { lifetime, publicUrl, buildsFromGit, staticEgress }`, `lifetime` `long-lived` or `stops-when-quiet`, `HostLifetime`), and how its state reads (`outputs(state) → { url, revision?, healthy }`). `defineResource` refuses a `host` that names no field, or whose `secrets` is not write-only. A host whose vendor mints the instance's bootstrap (its database URL, keys and tokens) and binds it itself declares `bindsBootstrap: true` and takes no `secrets` (Sanoma Cloud's `cloud.host`); `defineResource` refuses one that gives both, or neither. A write-only field may be a map of secrets (`secrets: { SANOMA_DATABASE_URL: secret("DATABASE_URL") }`): its markers are kept as one at the field is. A host type is `stateless` (a `ResourceSpec` field, default true for a host): its objects keep no data, so replacing or recreating one is routine. `source` is a clone URL, or a reference to a declared repository with a `by` field holding one.
585
626
 
586
627
  A config whose connectors declare a host type has the built-in operation `sanoma.source.resolve` (`SOURCE_RESOLVE`, effect `read`): `{ url, ref } → { revision }`, by `git ls-remote` on this machine (a branch, then a tag, peeled; a full commit id is itself). Apply calls it for each host it plans, and gives the host the commit as its `revision` and the clone URL as its `source` (a referenced repository's, from its `by` field); a host whose ref does not resolve, or whose repository has no clone URL yet (apply the repository first), is a problem of the plan. The drift run calls it for every host it reads. A sandbox run calls its fake instead (`fakeSourceDriver`), with no git or network: a full commit id is itself, any other ref the SHA-1 of `<url>#<ref>`, the same on every run. A host's `secrets` map may name sanoma's own secrets (`SANOMA_…`), and no other field of any type may. `resolveRevision(url, ref)` is the same, outside a run.
587
628
 
588
- A wakeup is a resource type that starts a stopped instance at a time, for what is due while its host is stopped: `defineResource({ …, writable: true, wakeup })`, where `wakeup` (`WakeupSpec`) names the field that references the host (`inputs.host`), the type's own operation the runtime registers each deadline through (`register`, called with `{ at }`, which replaces whatever the wakeup held) and how its state reads (`outputs(state) → { healthy }`); `defineResource` and `resolveConfig` refuse one that does not meet it. Declaring one in a data file is the switch, with no mode flag: `declaredWakeup(config)` reads it, and several are a problem of the data files. A wake is `GET {url}/wake`, with no query: resume if stopped. A config with `wakeup: true` runs the wakeup service itself and must not stop, so `startWorker` refuses it beside a declared wakeup.
629
+ A wakeup is a resource type that starts a stopped instance at a time, for what is due while its host is stopped: `defineResource({ …, writable: true, wakeup })`, where `wakeup` (`WakeupSpec`) names the field that references the host (`inputs.host`), the type's own operation the runtime registers each deadline through (`register`, called with `{ at }`, which replaces whatever the wakeup held) and how its state reads (`outputs(state) → { healthy }`); `defineResource` and `resolveConfig` refuse one that does not meet it. Declaring one in a data file is the switch, with no mode flag: `declaredWakeup(config)` reads it, and several are a problem of the data files. A wake is `GET {url}/wake`, with no query: resume if stopped. A config with `wakeup: true` runs the wakeup service itself and must not stop, so `startWorker` refuses it beside a declared wakeup. With none declared, a worker given `onStopped` takes the wakeup its environment names (`envWakeup(process.env)`, an `EnvWakeup` of `{ url, token }` when both `SANOMA_HOST_URL` (`HOST_URL`, an http(s) URL as the wakeup service keeps one) and `SANOMA_WAKEUP_TOKEN` are set, as a host Sanoma Cloud made sets them) and registers each deadline itself: `PUT {url}/__sanoma/wake` (`WAKE_PATH`) with the token as a bearer and `{ at }`, answered 204, no driver. An instance with `wakeup: true` ignores it.
589
630
 
590
631
  A config whose connectors declare a host type also has the runtime's own wakeup, `sanoma.wakeup({ host })` (`import { sanoma } from "sanoma/resources"` in a data file), no connector named: an instance of a [wakeup service](#the-wakeup-service) for a `cloudflare.container`, `docker.container` or `testhost.app`, the fallback for a host with no clock of its own (a host's own vendor's wakeup comes first). Its driver creates, reads and deletes the instance at `SANOMA_WAKEUP_URL` with the account's `SANOMA_WAKEUP_ACCOUNT_TOKEN`, registering the instance's own `SANOMA_WAKEUP_TOKEN`, and `sanoma.wakeup.register` sends `PUT {url}/wakeup/wake { at }` with that token; `fakeSanomaWakeup()` from `@sanoma-ai/workflows/fake` is the service in memory, which sends `GET {host}/wake` at `at`.
591
632
 
@@ -605,7 +646,7 @@ A database is a resource type that holds the company's database (a Neon project,
605
646
 
606
647
  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.
607
648
 
608
- 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).
649
+ 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), delete?(name) }`, 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).
609
650
 
610
651
  ### Versions
611
652
 
@@ -621,7 +662,7 @@ Queues used to be one `sanoma` queue for every app and are now `sanoma:<appName>
621
662
 
622
663
  A run is replayed after a restart by calling the function again and reading each step's result back, so workflows must do the same thing every time, and must reach vendors only through `ctx` so the policy sees every call. Two checks guard against getting that wrong by accident, on files under `workflows/`. They are not a sandbox: they read the source, and code written to get around them can.
623
664
 
624
- oxlint, with the rules this package ships in `oxlint.json`, refuses the clock (`Date.now()`, `new Date()` and `Date()`, through the plugin's rule `sanoma/date`, which allows `new Date(await ctx.now())`; `performance`), randomness (`Math.random`, `crypto`), the network (`fetch`, `WebSocket`), timers, `process`, `globalThis`, `Promise.all` and its kin (use `ctx.all`; nothing races), and imports of `@sanoma-ai/testing`, `@sanoma-ai/app`, `@sanoma-ai/workflows/describe`, `@sanoma-ai/workflows/fake`, `@sanoma-ai/workflows/plugin`, `@sanoma-ai/workflows/scenario`, `@sanoma-ai/workflows/scenarios`, `@sanoma-ai/workflows/wakeup-service`, `@sanoma-ai/connector-*/fake` and `@sanoma-ai/connector-*/driver`. From `@sanoma-ai/workflows` it allows only `defineWorkflow`, `mayDecide`, `errorCode`, `z` (which `sanoma` re-exports) and types (the list is `allowImportNames` in `oxlint.json`; the `sanoma` package's own `oxlint.json` allows the same names from `sanoma` and refuses its subpaths): the rest could start runs or approve the run's own approvals (`SanomaClient`, `startWorker`), forge the audit record (`jsonlLedger`, `memoryLedger`: a store keeps the first record per id) or read credentials. Each message names the `ctx` replacement. `sanoma check` runs it, through `sanoma/oxlint.json`, which carries these rules; without `sanoma`, extend this package's from your `.oxlintrc.json` by its path: oxlint resolves `extends` as a file, not a package name, so `@sanoma-ai/workflows/oxlint` would not load. The `workflows/**` glob resolves against your config:
665
+ oxlint, with the rules this package ships in `oxlint.json`, refuses the clock (`Date.now()`, `new Date()` and `Date()`, through the plugin's rule `sanoma/date`, which allows `new Date(await ctx.now())`; `performance`), randomness (`Math.random`, `crypto`), the network (`fetch`, `WebSocket`), timers, `process`, `globalThis`, `Promise.all` and its kin (use `ctx.all`; nothing races), and imports of `@sanoma-ai/testing`, `@sanoma-ai/app`, `@sanoma-ai/workflows/describe`, `@sanoma-ai/workflows/fake`, `@sanoma-ai/workflows/plugin`, `@sanoma-ai/workflows/scenario`, `@sanoma-ai/workflows/scenarios`, `@sanoma-ai/workflows/wakeup-service`, `@sanoma-ai/connector-*/fake` and `@sanoma-ai/connector-*/driver`. From `@sanoma-ai/workflows` it allows only `defineWorkflow`, `mayDecide`, `errorCode`, `z`, `sanoma` ([the runtime's own operations](#the-runtimes-own-operations)) (which `sanoma` re-exports) and types (the list is `allowImportNames` in `oxlint.json`; the `sanoma` package's own `oxlint.json` allows the same names from `sanoma` and refuses its subpaths): the rest could start runs or approve the run's own approvals (`SanomaClient`, `startWorker`), forge the audit record (`jsonlLedger`, `memoryLedger`: a store keeps the first record per id) or read credentials. Each message names the `ctx` replacement. `sanoma check` runs it, through `sanoma/oxlint.json`, which carries these rules; without `sanoma`, extend this package's from your `.oxlintrc.json` by its path: oxlint resolves `extends` as a file, not a package name, so `@sanoma-ai/workflows/oxlint` would not load. The `workflows/**` glob resolves against your config:
625
666
 
626
667
  ```json
627
668
  {
@@ -631,7 +672,7 @@ oxlint, with the rules this package ships in `oxlint.json`, refuses the clock (`
631
672
 
632
673
  `describeConfig` (`@sanoma-ai/workflows/describe`) reads each workflow's file once, the one way the worker does, and lists on the workflow's entry, in `problems`, what oxlint can't express: imports come only from `@sanoma-ai/workflows` or `sanoma`, a `@sanoma-ai/connector-<vendor>` package, zod, or a relative file that stays inside the file's `workflows/` directory (so not `../sanoma.config.ts`, which holds the config); no namespace import of either package; no dynamic `import()`; and no `instanceof` against `DriverError`, `SanomaError`, `PolicyDeniedError` or `RejectedError`. On a replay DBOS rethrows a serialized copy of an error, which is no instance of its class, so a branch on `instanceof` would go another way than the first time and the run's steps would fall out of step. Read `errorCode(err)` instead.
633
674
 
634
- In each workflow's `run` it also lists what the run's graph could not show, the same problems that make the outline `{ error }` and `startWorker` refuse (see [Outline](#outline)): `ctx` used other than called directly or read for `ctx.runId` (passed to a helper, aliased, destructured, or `run` taking it destructured); a call on `ctx` that is no operation or built-in (`ctx.ghost.post()`); `ctx.all` over members made elsewhere (write the list, or `items.map(…)`, in the call); a `ctx` call inside a function the outline does not read (one defined inside `run`, or a callback not passed to `ctx.all`, `.map`, `.forEach` or `.reduce`) or in a `finally`; and, in `run`, in a `finally` and in the callbacks that make `ctx` calls, a `return` before the end, a `throw` outside a `catch`, a `break` out of a loop or to a label, and `continue`; and a [step name](#step-names) that is not a literal or reads as an operation's id or `Sleep`, and two steps given one name. A function without `ctx` calls computes, and may do as it likes. Each message says what to write instead: inline the helper, use `if`/`else` around the rest, name the call. Each problem has its `line` and `column` in the entry's `file` (an unreadable file, or one that holds no workflow of the entry's name, is the outline's error at 1:1); `sanoma check` prints them, as `problemLine` does, and the app's startup warns of them. A test holds a repo to the same:
675
+ In each workflow's `run` it also lists what the run's graph could not show, the same problems that make the outline `{ error }` and `startWorker` refuse (see [Outline](#outline)): `ctx` used other than called directly or read for `ctx.runId` or `ctx.startedBy` (passed to a helper, aliased, destructured, or `run` taking it destructured); a call on `ctx` that is no operation or built-in (`ctx.ghost.post()`); `ctx.all` over members made elsewhere (write the list, or `items.map(…)`, in the call); a `ctx` call inside a function the outline does not read (one defined inside `run`, or a callback not passed to `ctx.all`, `.map`, `.forEach` or `.reduce`) or in a `finally`; and, in `run`, in a `finally` and in the callbacks that make `ctx` calls, a `return` before the end, a `throw` outside a `catch`, a `break` out of a loop or to a label, and `continue`; and a [step name](#step-names) that is not a literal or reads as an operation's id or `Sleep`, and two steps given one name. A function without `ctx` calls computes, and may do as it likes. Each message says what to write instead: inline the helper, use `if`/`else` around the rest, name the call. Each problem has its `line` and `column` in the entry's `file` (an unreadable file, or one that holds no workflow of the entry's name, is the outline's error at 1:1); `sanoma check` prints them, as `problemLine` does, and the app's startup warns of them. A test holds a repo to the same:
635
676
 
636
677
  ```ts
637
678
  import { describeConfig } from "@sanoma-ai/workflows/describe";
@@ -687,6 +728,8 @@ The steps, as [Cucumber expressions](https://github.com/cucumber/cucumber-expres
687
728
  | Given | `{resource} {string} is gone` | The vendor's fake no longer holds the object: `Given github.repository "docs" is gone` |
688
729
  | Given | `{who} is acting` | Starts the run as that declared person or agent, or `anonymous`: `Given refund-bot is acting` (else `runScenario`'s `startedBy`, `scenarios` by default) |
689
730
  | Given | `a visitor is acting` | Starts the run as a visitor of a public site signed in (`visitor:scenario:visitor`, in `visitors`), which only a `permit` lets start one |
731
+ | Given | `a visitor {string} is acting` | The same visitor with the email its login verified: `Given a visitor "hopper@example.com" is acting` (`ctx.startedBy.email`) |
732
+ | Given | `a visitor {string} with GitHub {string} is acting` | The same, with the GitHub login it linked too: `Given a visitor "hopper@example.com" with GitHub "hopper" is acting` (`ctx.startedBy.github`) |
690
733
  | When | `{workflow} runs with` and a JSON doc string or a two-column table (`name \| value`) | The workflow the scenario runs, and its input |
691
734
  | When | `{workflow} runs` | The same, with the input made up |
692
735
  | Any | `{string} is approved by {who}`, `… with note {string}`, and the `rejected` forms | How the approval with that title (or id, such as `approval-2`) is decided |
@@ -805,7 +848,7 @@ Each node's `path` is its place in the outline: its index among its siblings, un
805
848
 
806
849
  `defineWorkflow` records the file it is called from as the workflow's `file`, by itself; nothing passes it, and a definition made from code with no file (an eval, a bundle without one) is refused. The outline is read from that file, which must hold the workflow as an object literal with its `name` (a string literal, or a top-level `const` of the file holding one) and a `run` function taking `ctx` by name; otherwise it is `{ error }`, saying why (the file could not be read or parsed, holds no workflow of that name, or `run` destructures `ctx`). It is `{ error }` too, one line per problem at `file:line:column`, when `run` holds what the graph cannot draw: the control flow and uses of `ctx` that `lintWorkflow` refuses (see [Keeping workflows replay-safe](#keeping-workflows-replay-safe)). Either way `startWorker` refuses the config, since nothing could say where the workflow's calls are.
807
850
 
808
- The worker reads each workflow's outline when it starts and holds every run to it. Each `ctx` call captures where the workflow made it, from the stack, and the worker finds the call node whose code holds that position; a call from anywhere else fails the run with `call_not_in_outline`, naming the file, line and column and what to do (inline the helper): from a function the outline does not read, from another file, from a `finally`, or through an alias (`const create = ctx.ghost.post.create`). `ctx.now()` and `ctx.runId` are exempt: a read of the clock, and a value. The records a call writes name its node as `node` (its `path`): `op.called`, `sleep.started`, `approval.requested` (a policy hold's approval names the held call's node) and each `group` of a `ctx.all`, so a UI places a run's steps in the code exactly, a loop's every pass on its one node. Records written by a worker from before calls were placed have no `node`.
851
+ The worker reads each workflow's outline when it starts and holds every run to it. Each `ctx` call captures where the workflow made it, from the stack, and the worker finds the call node whose code holds that position; a call from anywhere else fails the run with `call_not_in_outline`, naming the file, line and column and what to do (inline the helper): from a function the outline does not read, from another file, from a `finally`, or through an alias (`const create = ctx.ghost.post.create`). `ctx.now()`, `ctx.runId` and `ctx.startedBy` are exempt: a read of the clock, and values. The records a call writes name its node as `node` (its `path`): `op.called`, `sleep.started`, `approval.requested` (a policy hold's approval names the held call's node) and each `group` of a `ctx.all`, so a UI places a run's steps in the code exactly, a loop's every pass on its one node. Records written by a worker from before calls were placed have no `node`.
809
852
 
810
853
  Status: early (0.x). The API may change between minor versions.
811
854
 
package/dist/apply.d.ts CHANGED
@@ -30,7 +30,8 @@ export type ApplyInput = z.infer<typeof ApplyInput>;
30
30
  /**
31
31
  * One declared resource, or orphaned binding, by what apply does with it: `create` it (as the
32
32
  * vendor plans: `planned`, the paths `unknown` until it exists), `update` or `replace` its bound
33
- * object (the declared `fields` that differ, the paths that force the replace), `bind` it to an
33
+ * object (the declared `fields` that differ, the paths that force the replace, any `unknown` until
34
+ * it is done: a `from` field whose resource changes first), `bind` it to an
34
35
  * object its identity `found` or the input `given`, ask about a `gone` object or an `orphan`ed
35
36
  * binding (unless `answer`ed), or `none`.
36
37
  */
@@ -48,6 +49,7 @@ export type PlanStep = {
48
49
  binding: string;
49
50
  fields: DriftField[];
50
51
  replace: string[];
52
+ unknown?: string[];
51
53
  } | {
52
54
  action: "bind";
53
55
  binding: string;
@@ -194,8 +196,13 @@ type ApplyUse = ImportOp | PlanOp | CreateOp | UpdateOp | DeleteOp | ResolveOp |
194
196
  * and right after one that changes a binding, the lockfile written and committed
195
197
  * (`steps.write`), so an object made is never left unbound. An update is given the plan's action
196
198
  * and replace (`expect`), and refuses (`plan_changed`) when the vendor now plans otherwise; a
197
- * reference to a resource made in this run is given its new binding, and a wakeup's `host` the
198
- * URL its host's state gives (`outputs(state).url`), found or made in this run. A failed step is reported
199
- * and the run goes on; a failure that ends the run (`isRunControlError`) ends it.
199
+ * reference to a resource made in this run is given its new binding, a `from` reference that
200
+ * resource's state at its path as an earlier step left it (planned as `null` while this apply
201
+ * changes it there, or a create's while it has none there, so the change is planned and approved
202
+ * up front, and the step planned again once the value is known: done without a write when it
203
+ * plans `none`, else expecting the fresh plan; a step whose resource still has none there, its
204
+ * step failed or not done, fails before its driver is called), and a wakeup's `host` the URL its host's state gives
205
+ * (`outputs(state).url`), found or made in this run. A failed step is reported and the run
206
+ * goes on; a failure that ends the run (`isRunControlError`) ends it.
200
207
  */
201
208
  export declare function applyWorkflow(types: ReadonlyMap<string, Resource>, ops: ReadonlyMap<string, Op>, drivers: ReadonlyMap<string, unknown>, steps: ApplySteps): WorkflowDefinition<readonly ApplyUse[], typeof ApplyInput>;