@sanoma-ai/workflows 0.10.1 → 0.10.3

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 (136) hide show
  1. package/CLOUD-IDENTITY.md +140 -0
  2. package/KMS-SECRETS.md +99 -0
  3. package/README.md +49 -11
  4. package/SECRET-SUPPORT.md +1 -1
  5. package/dist/apply.js +1 -1
  6. package/dist/apply.js.map +1 -1
  7. package/dist/authentication.d.ts +7 -2
  8. package/dist/authentication.js +2 -2
  9. package/dist/authentication.js.map +1 -1
  10. package/dist/call.d.ts +5 -2
  11. package/dist/call.js +65 -20
  12. package/dist/call.js.map +1 -1
  13. package/dist/change.d.ts +5 -6
  14. package/dist/change.js +6 -9
  15. package/dist/change.js.map +1 -1
  16. package/dist/client.d.ts +18 -0
  17. package/dist/client.js +37 -5
  18. package/dist/client.js.map +1 -1
  19. package/dist/cloud-identity.d.ts +52 -0
  20. package/dist/cloud-identity.js +222 -0
  21. package/dist/cloud-identity.js.map +1 -0
  22. package/dist/config.d.ts +42 -4
  23. package/dist/config.js +71 -10
  24. package/dist/config.js.map +1 -1
  25. package/dist/connection.js +8 -2
  26. package/dist/connection.js.map +1 -1
  27. package/dist/db.d.ts +2 -0
  28. package/dist/db.js +7 -0
  29. package/dist/db.js.map +1 -1
  30. package/dist/deadlines.d.ts +26 -13
  31. package/dist/deadlines.js +50 -11
  32. package/dist/deadlines.js.map +1 -1
  33. package/dist/define.d.ts +22 -0
  34. package/dist/define.js +2 -0
  35. package/dist/define.js.map +1 -1
  36. package/dist/deployment-key.d.ts +20 -1
  37. package/dist/deployment-key.js +28 -3
  38. package/dist/deployment-key.js.map +1 -1
  39. package/dist/describe.js +28 -1
  40. package/dist/describe.js.map +1 -1
  41. package/dist/description.d.ts +3 -0
  42. package/dist/description.js +2 -0
  43. package/dist/description.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/index.d.ts +11 -7
  48. package/dist/index.js +8 -5
  49. package/dist/index.js.map +1 -1
  50. package/dist/kms-secrets.d.ts +24 -0
  51. package/dist/kms-secrets.js +209 -0
  52. package/dist/kms-secrets.js.map +1 -0
  53. package/dist/ledger.d.ts +30 -1
  54. package/dist/ledger.js +31 -0
  55. package/dist/ledger.js.map +1 -1
  56. package/dist/login-code.d.ts +17 -0
  57. package/dist/login-code.js +32 -0
  58. package/dist/login-code.js.map +1 -0
  59. package/dist/op.d.ts +78 -10
  60. package/dist/op.js +20 -0
  61. package/dist/op.js.map +1 -1
  62. package/dist/outline.js +3 -3
  63. package/dist/outline.js.map +1 -1
  64. package/dist/resource.d.ts +6 -4
  65. package/dist/resource.js +8 -5
  66. package/dist/resource.js.map +1 -1
  67. package/dist/resources.d.ts +4 -2
  68. package/dist/resources.js +15 -6
  69. package/dist/resources.js.map +1 -1
  70. package/dist/run.d.ts +3 -0
  71. package/dist/run.js.map +1 -1
  72. package/dist/runtime-ops.d.ts +123 -0
  73. package/dist/runtime-ops.js +222 -0
  74. package/dist/runtime-ops.js.map +1 -0
  75. package/dist/seal.d.ts +27 -0
  76. package/dist/seal.js +76 -0
  77. package/dist/seal.js.map +1 -0
  78. package/dist/secret-store.d.ts +6 -4
  79. package/dist/secret-store.js +11 -6
  80. package/dist/secret-store.js.map +1 -1
  81. package/dist/secret.d.ts +7 -0
  82. package/dist/secret.js +13 -1
  83. package/dist/secret.js.map +1 -1
  84. package/dist/shared.d.ts +15 -4
  85. package/dist/shared.js +27 -7
  86. package/dist/shared.js.map +1 -1
  87. package/dist/ssh-key.d.ts +11 -0
  88. package/dist/ssh-key.js +44 -0
  89. package/dist/ssh-key.js.map +1 -0
  90. package/dist/tokens.d.ts +35 -0
  91. package/dist/tokens.js +71 -0
  92. package/dist/tokens.js.map +1 -0
  93. package/dist/wakeup-service.js +2 -7
  94. package/dist/wakeup-service.js.map +1 -1
  95. package/dist/wakeup.d.ts +18 -0
  96. package/dist/wakeup.js +15 -0
  97. package/dist/wakeup.js.map +1 -1
  98. package/dist/worker.d.ts +2 -2
  99. package/dist/worker.js +17 -9
  100. package/dist/worker.js.map +1 -1
  101. package/oxlint.json +3 -1
  102. package/package.json +15 -2
  103. package/src/apply.ts +1 -1
  104. package/src/authentication.ts +9 -2
  105. package/src/call.ts +88 -18
  106. package/src/change.ts +9 -20
  107. package/src/client.ts +50 -7
  108. package/src/cloud-identity.ts +266 -0
  109. package/src/config.ts +111 -17
  110. package/src/connection.ts +8 -2
  111. package/src/db.ts +8 -1
  112. package/src/deadlines.ts +66 -20
  113. package/src/define.ts +25 -0
  114. package/src/deployment-key.ts +34 -8
  115. package/src/describe.ts +39 -2
  116. package/src/description.ts +2 -0
  117. package/src/host.ts +22 -6
  118. package/src/index.ts +19 -1
  119. package/src/kms-secrets.ts +271 -0
  120. package/src/ledger.ts +52 -1
  121. package/src/login-code.ts +52 -0
  122. package/src/op.ts +84 -11
  123. package/src/outline.ts +3 -3
  124. package/src/resource.ts +8 -5
  125. package/src/resources.ts +13 -4
  126. package/src/run.ts +3 -0
  127. package/src/runtime-ops.ts +234 -0
  128. package/src/seal.ts +95 -0
  129. package/src/secret-store.ts +12 -9
  130. package/src/secret.ts +16 -1
  131. package/src/shared.ts +31 -7
  132. package/src/ssh-key.ts +48 -0
  133. package/src/tokens.ts +100 -0
  134. package/src/wakeup-service.ts +2 -8
  135. package/src/wakeup.ts +21 -0
  136. package/src/worker.ts +29 -11
@@ -0,0 +1,140 @@
1
+ # Managed Cloud runtime identity
2
+
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
+
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.
6
+
7
+ ## Request and authority
8
+
9
+ ```mermaid
10
+ flowchart LR
11
+ runtime[Company runtime] -->|POST /token: deployment bearer| broker[Trusted Cloud Run broker]
12
+ broker -->|Hash lookup and fixed mapping| enrollments[(Broker enrollment database)]
13
+ broker -->|Impersonate mapped service account| iam[Google IAM Credentials]
14
+ iam -->|600-second OAuth access token| broker
15
+ broker -->|Token and expiry| runtime
16
+ runtime -->|Company-scoped identity| kms[Company/environment CryptoKey]
17
+ runtime -->|Company-scoped database login| db[(Company Postgres ciphertext)]
18
+ ```
19
+
20
+ `identityBroker` accepts **POST `/token`**, with `Authorization: Bearer <deployment credential>`, no query string and no request body. Do not send `{}`. The request cannot select company, service account, key, scope or token lifetime. Wrong routes/methods return 404, a query/body returns 400, invalid enrollment returns 401, and dependency/audit failures return a sanitized 503. Responses prohibit caching.
21
+
22
+ The broker hashes the bearer, loads its active enrollment, issues for the mapped service account, then checks the enrollment again before delivery. `postgresIdentityEnrollments` reads on each check; it does not cache enrollment authority. Audit must durably record issuance/denial/unavailability metadata, never tokens or authorization headers. Failure to record a successful issuance prevents delivery, even if Google already minted the token.
23
+
24
+ `attachedGoogleTokenIssuer` explicitly uses the Cloud Run attached identity through `google-auth-library`'s `Compute` client. It requests `generateAccessToken` for the enrollment's service account, with the fixed `cloud-platform` scope and `600s` lifetime. It returns an OAuth API access token, not an OIDC ID token. No service-account private key or ambient credential-file fallback is used. The attached account needs `iam.serviceAccounts.getAccessToken` on the intended target accounts; this permission is documented by [Google IAM Credentials](https://docs.cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/generateAccessToken).
25
+
26
+ The broker's attached identity must have no company database access, direct KMS decrypt grant or IAM-policy mutation authority. Each target service account must have encrypt/decrypt access only to its company/environment key, with no inherited fleet grants or impersonation authority. These are deployment requirements; constructing the handler does not establish them.
27
+
28
+ ## Enrollment database and role separation
29
+
30
+ Install `CLOUD_IDENTITY_SQL` with an administrator in a **broker-owned database**, never a company database. It creates `sanoma_identity.deployments` (deployment → company/environment/service account and enabled state) and `sanoma_identity.credentials` (SHA-256 hash, deployment, positive generation, validity interval and revoked state). It revokes public schema/table access. It does not create logins or grant broker/provisioner privileges.
31
+
32
+ Example roles, to adapt and install with administrative authority:
33
+
34
+ ```sql
35
+ -- Synthetic role names; supply login authentication through protected deployment setup.
36
+ CREATE ROLE identity_broker_reader NOLOGIN;
37
+ CREATE ROLE identity_enrollment_writer NOLOGIN;
38
+ GRANT USAGE ON SCHEMA sanoma_identity
39
+ TO identity_broker_reader, identity_enrollment_writer;
40
+ GRANT SELECT ON sanoma_identity.deployments, sanoma_identity.credentials
41
+ TO identity_broker_reader;
42
+ GRANT SELECT, INSERT, UPDATE, DELETE
43
+ ON sanoma_identity.deployments, sanoma_identity.credentials
44
+ TO identity_enrollment_writer;
45
+ -- Separate pre-created logins, neither superuser nor schema/table owner:
46
+ GRANT identity_broker_reader TO example_broker_login;
47
+ GRANT identity_enrollment_writer TO example_provisioner_login;
48
+ ```
49
+
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
+
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`:
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;
65
+ ```
66
+
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.
68
+
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
+
71
+ Broker composition uses its read-only database pool and a separate durable audit sink:
72
+
73
+ ```ts
74
+ import {
75
+ identityBroker,
76
+ postgresIdentityEnrollments,
77
+ attachedGoogleTokenIssuer,
78
+ } from "@sanoma-ai/workflows/cloud-identity";
79
+
80
+ const handle = identityBroker({
81
+ enrollments: postgresIdentityEnrollments(async () => brokerReadOnlyPool),
82
+ issue: attachedGoogleTokenIssuer(),
83
+ audit: writeDurableIdentityAudit,
84
+ });
85
+ // The deployment's HTTP server passes the incoming Request to handle.
86
+ // Pool, audit sink and server lifecycle belong to trusted deployment composition.
87
+ ```
88
+
89
+ ## Runtime refresh and KMS integration
90
+
91
+ Create one `cloudIdentityAuth` client per runtime deployment. It returns an `OAuth2Client` whose refresh handler coalesces concurrent renewal requests and renews 60 seconds before expiry. Each renewal calls the bootstrap reader again, so mounted-credential rotation can take effect without caching the old bearer. Tokens remain only in that client's memory. The endpoint must be HTTPS `/token`, with no embedded credentials, query or fragment; redirects are refused. Broker calls time out after ten seconds, malformed/near-expiry replies fail, and errors expose no response body or credential. There is no fallback identity.
92
+
93
+ The following composition joins D1 to the separate [#470 KMS adapter](https://github.com/Sanoma-AI/sanoma/issues/470); it requires both entries to be available in the installed package. Company database/key provisioning remains separate:
94
+
95
+ ```ts
96
+ import { readFile } from "node:fs/promises";
97
+ import { KeyManagementServiceClient } from "@google-cloud/kms";
98
+ import { cloudIdentityAuth } from "@sanoma-ai/workflows/cloud-identity";
99
+ import { kmsPostgresStore } from "@sanoma-ai/workflows/kms-secrets";
100
+
101
+ const authClient = cloudIdentityAuth({
102
+ endpoint: "https://identity.example.test/token",
103
+ credential: async () => (await readFile("/run/sanoma/deployment-credential", "utf8")).trim(),
104
+ });
105
+ const kms = new KeyManagementServiceClient({ authClient });
106
+ const store = kmsPostgresStore({
107
+ company: "example-company", // Fixed by trusted bootstrap.
108
+ keyName: "projects/example-project/locations/us-central1/keyRings/production/cryptoKeys/example-company",
109
+ kms,
110
+ database: async () => companyScopedPool,
111
+ });
112
+ // Supply store to defineConfig's secrets configuration.
113
+ // The deployment owns kms.close() and the database pool lifecycle.
114
+ ```
115
+
116
+ Credential ciphertext stays in the company's Postgres database. Caching the short-lived Google token does not authorize caching decrypted credential bundles or unwrapped data keys. App signing material remains independent. Neither this route nor the KMS adapter needs `SANOMA_SECRETS_KEY`.
117
+
118
+ ## Isolated live verification
119
+
120
+ The verification used D1 source `244fb3e78c66c5d3d6bb22cacad73acbb1726cb3` and KMS source `c3c293d0bc48cbcf66adf8e0cb0c57f500e686bc`, with a temporary Cloud Run broker and separate disposable remote Postgres databases. The broker image contained the compiled handler and a small HTTP adapter; its attached service account had only `iam.serviceAccounts.getAccessToken` on three test runtime accounts. It had no company database credentials or direct KMS grant. Database enrollment used a read-only login and audit writes used a separate insert-only login.
121
+
122
+ The actual Google SDK and KMS store passed own-key encrypted writes/reads, company A/B and production/preview encrypt/decrypt denial, separate company-database access checks, enrollment-write refusal, invalid bearer and caller-target override refusal. Revoking the broker's enrollment-read or audit-insert permission caused a sanitized 503 without a delivered token; restoring the permission restored service. These are permission-failure checks, not a measurement of provider-wide outages.
123
+
124
+ A manual primary-version rotation kept old ciphertext readable and new writes adopted the new primary after propagation. Revoking a deployment credential rejected new issuance while the SDK's already issued token continued working until renewal. The ten-minute runtime renewal result is tracked in [#492](https://github.com/Sanoma-AI/sanoma/issues/492), separately from [provisioning OIDC verification](../../connectors/gcp/README.md#testing-and-current-evidence).
125
+
126
+ This proof uses test infrastructure and compiled source, not published-package consumption in sanoma-ops. Cloudflare cold start/resume, bootstrap delivery, production monitoring/quotas, IAM/KMS audit reconciliation and backup recovery remain rollout gates.
127
+
128
+ ## Residual authority and rollout gates
129
+
130
+ Possession of the deployment bearer proves possession, not host attestation. A stolen bearer can renew that deployment's tokens while its enrollment remains active. Revoking enrollment stops subsequent renewal checks; **already issued tokens can remain usable until expiry**. IAM changes have their own propagation behavior, and neither revocation nor expiry recalls plaintext already obtained.
131
+
132
+ A compromised broker can impersonate every service account its attached identity may mint for. Per-company keys do not remove that fleet-level issuer risk. A compromised enrollment writer can change mappings within the broker's permitted targets. Keep both outside company-authored code and public company Workers; give each company runtime only its own bearer, database authority and key access.
133
+
134
+ Before rollout:
135
+
136
+ - Provision the Cloud Run service, attached issuer account, broker database roles, durable audit sink and per-company/environment keys and service accounts. Enable billing and required APIs. The temporary verification deployment below does not provision a production service.
137
+ - Make the TLS endpoint reachable without first demanding a Google invocation token; the application bearer authenticates it. This requires an explicit [Cloud Run invocation configuration](https://docs.cloud.google.com/run/docs/authenticating/public), plus appropriate request limits and quota monitoring. Do not add this endpoint to a company Worker that holds fleet authority.
138
+ - 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
+ - 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
+ - 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.
package/KMS-SECRETS.md ADDED
@@ -0,0 +1,99 @@
1
+ # GCP KMS-protected credential storage
2
+
3
+ `@sanoma-ai/workflows/kms-secrets` implements the proposed managed Cloud store for #470. Encrypted credentials live in **Postgres**, in `sanoma_kms.secrets` in the company database supplied by trusted bootstrap. Supabase can host that Postgres database; no separate key-value database is needed. GCP KMS protects encryption keys, not the credential records. BYOC Supabase Vault is a separate adapter. AWS Secrets Manager remains deferred.
4
+
5
+ ## Runtime boundary
6
+
7
+ One company runs in one process with its own database authority and **GCP identity permitted to use only that company's CryptoKeys**. Trusted deployment bootstrap supplies the fixed company ID, CryptoKey resource name, SDK client and database connection. Never give company code fleet-wide decrypt authority or an administrator database connection. A company's code and installed connectors can use its authorized credentials; encryption does not protect those credentials from its own compromised runtime.
8
+
9
+ The proposed default is one symmetric SOFTWARE CryptoKey per company and environment, with keys grouped in a shared environment key ring/project. HSM protection is an independent provisioning choice, with different pricing; per-company software keys do not imply HSM protection. Separate production and test identities, keys and databases.
10
+
11
+ This adapter is not a production Cloud deployment. D1 implementation is tracked in [#492](https://github.com/Sanoma-AI/sanoma/issues/492) and [PR #496](https://github.com/Sanoma-AI/sanoma/pull/496). Isolated live IAM tests have passed; intended-host deployment and rollout checks remain separate. A Cloudflare deployment does not automatically have a Google identity. Workload Identity Federation or another approved refreshable credential route must bind the authenticated workload to its company; tenant code cannot select or impersonate other companies. The adapter provisions no identities, keys, database grants or shared service.
12
+
13
+ ```ts
14
+ import { KeyManagementServiceClient } from "@google-cloud/kms";
15
+ import { KMS_SECRETS_SQL, kmsPostgresStore } from "@sanoma-ai/workflows/kms-secrets";
16
+
17
+ // Privileged deployment setup, once per company database:
18
+ await adminDb.query(KMS_SECRETS_SQL);
19
+ // Grant runtime USAGE on sanoma_kms and SELECT/INSERT/UPDATE/DELETE on sanoma_kms.secrets.
20
+
21
+ // Supply the company-scoped refreshable auth client from trusted bootstrap.
22
+ // D1 provides cloudIdentityAuth in the separate /cloud-identity entry once released.
23
+ const kms = new KeyManagementServiceClient({ authClient: companyAuthClient });
24
+ const store = kmsPostgresStore({
25
+ company: companyIdFromTrustedBootstrap,
26
+ keyName: "projects/cloud-project/locations/us-central1/keyRings/production/cryptoKeys/company-id",
27
+ kms,
28
+ database: async () => companyDatabase,
29
+ });
30
+ await store.validate("production");
31
+ // Deployment config: secrets: { store, namespace: "production" }
32
+ ```
33
+
34
+ The caller owns client/database lifecycle, including `kms.close()`. `SANOMA_SIGNING_KEY` stays independent. This store does not use `SANOMA_SECRETS_KEY`. Workflow code cannot import this backend entry. Secret intake must remain outside durable workflow inputs/outputs.
35
+
36
+ ## One shared key versus a key per company
37
+
38
+ | Event | One shared CryptoKey | Per-company CryptoKeys with scoped identities |
39
+ | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
40
+ | Database backup stolen without KMS authority | Encrypted credentials remain protected | Same protection |
41
+ | Company A runtime identity compromised | If it can call the shared key directly, that grant also authorizes decryption of B's ciphertext when available with its AAD | A's key grant cannot decrypt B's ciphertext under B's key |
42
+ | Fleet decrypt identity or IAM administrator compromised | Fleet exposure | Fleet exposure still possible; separate keys cannot fix broad credentials or an attacker able to grant access |
43
+ | Disable a company or respond to an incident | Key disable affects everyone; tenant access needs a broker/policy boundary | Revoke the company's identity or disable its key versions independently |
44
+ | Destroy keys after retention ends | Shared key versions cannot be destroyed while any company still needs them | Independent company retention; all versions/copies/backups must be accounted for |
45
+ | Rotation and audits | Shared lifecycle; attribution relies on identities and application records | Separate key lifecycle and key resource in audit events; configure appropriate audit logging |
46
+
47
+ ```mermaid
48
+ flowchart LR
49
+ A[Company A runtime identity] --> KA[GCP CryptoKey A]
50
+ A --> PA[Company A Postgres encrypted rows]
51
+ B[Company B runtime identity] --> KB[GCP CryptoKey B]
52
+ B --> PB[Company B Postgres encrypted rows]
53
+ ```
54
+
55
+ A shared key can be safe behind a trusted broker that independently authorizes every company request and keeps shared-key permission out of company runtimes. That broker is a different architecture and is not implemented here. In the direct-runtime design, use per-company CryptoKeys and grant `roles/cloudkms.cryptoKeyEncrypterDecrypter` on those keys only. Avoid inherited project/key-ring grants and permissions to change IAM or impersonate other identities. [GCP IAM](https://cloud.google.com/kms/docs/iam) defines these resource boundaries.
56
+
57
+ GCP additional authenticated data (AAD) protects ciphertext binding; it is **not tenant authorization**. Unlike an AWS policy conditioned on encryption context, this GCP design does not claim an IAM restriction on the company string in AAD. A caller with permission to decrypt a shared key can supply another company's known AAD. See [GCP AAD](https://cloud.google.com/kms/docs/additional-authenticated-data).
58
+
59
+ `validate` checks an own-key encryption/decryption round trip and database schema access. It cannot certify that the identity lacks permission on every other company's key. Deployment evidence must inspect grants and prove cross-company denial using existing keys, including inherited permissions. The live test checks encrypt/decrypt denial in both directions between two test identities.
60
+
61
+ The database login must separately prevent cross-company access. The company column and key allowlist are defense in depth, not a replacement for database permissions or IAM. The SQL installer uses a private schema and removes public/API-role grants; never expose it through a browser Data API. Keep database/SDK payloads and credentials out of tracing and logs.
62
+
63
+ ## Envelope format and rotation
64
+
65
+ Each independent secret or atomic token bundle occupies one row. `sanoma-gcp-kms-v1` generates a fresh random 256-bit data key locally for each write attempt. AES-256-GCM encrypts the value; GCP `Encrypt` wraps only the data key using the CryptoKey's primary version. Postgres stores the ciphertext, nonce, tag, wrapped data key, CryptoKey and version names, and an opaque UUID revision for compare-and-swap updates.
66
+
67
+ Both GCP AAD and local AES-GCM bind company, namespace, path, row revision and format. AES-GCM also authenticates the returned CryptoKeyVersion name. Reads accept only configured CryptoKeys. RPCs send CRC32C checksums and verify response integrity, including the version resource returned by encryption. Provider errors become sanitized store error codes. See Google's [encryption API](https://cloud.google.com/kms/docs/reference/rest/v1/projects.locations.keyRings.cryptoKeys/encrypt) and [integrity guidance](https://cloud.google.com/kms/docs/data-integrity-guidelines).
68
+
69
+ Values larger than 65,536 UTF-8 bytes are rejected. No plaintext or data-key cache is kept. Temporary mutable key buffers are cleared in `finally`; JavaScript strings and SDK-internal copies cannot be guaranteed erased. CAS conflicts leave committed values intact. Deleting a row does not revoke its vendor token or erase copies in backups.
70
+
71
+ There are three different rotations:
72
+
73
+ - **KMS version rotation:** a new primary version under the same CryptoKey. After the primary change propagates, new writes use it; existing rows still decrypt through their original enabled versions, without configuration changes. Automatic rotation does not rewrite old rows.
74
+ - **CryptoKey replacement:** change `keyName` and retain old company keys in `decryptKeyNames`. Read and rewrite old rows with their expected versions; conflict means reread rather than overwrite. This draft supports those primitives, not an automatic migration worker. Backups may still need the old keys.
75
+ - **Vendor credential rotation:** obtain a new API key or refresh an OAuth token, then save the new value. KMS rotation does not rotate vendor credentials or remove a compromised runtime's access.
76
+
77
+ Recovery needs database backups plus every enabled key version needed by retained envelopes. Test restores before retiring keys. Rewriting current rows is insufficient if retained backups contain old envelopes. Disabling is reversible but makes dependent ciphertext unreadable; destruction is irreversible after the provider's destruction window. AAD/version binding detects substitution, but does not prevent an authorized database writer from rolling back a complete old valid row; CAS assumes trusted database state. [GCP rotation](https://cloud.google.com/kms/docs/key-rotation).
78
+
79
+ ## Rotation costs
80
+
81
+ GCP bills retained key **versions**, not just logical keys. Current list pricing is about $0.06 per SOFTWARE symmetric version per month, or $1 per HSM symmetric version; encrypt/decrypt operations cost $0.03 per 10,000. Creating a rotation is free. Enabled, disabled and scheduled-for-destruction versions all count until destroyed; partial months are prorated. [GCP pricing](https://cloud.google.com/kms/pricing).
82
+
83
+ | Retained versions per key | One shared SOFTWARE key | 1,000 company SOFTWARE keys | 1,000 company HSM keys |
84
+ | ------------------------- | ----------------------: | --------------------------: | ---------------------: |
85
+ | 1 | $0.06/month | $60/month | $1,000/month |
86
+ | 2 | $0.12/month | $120/month | $2,000/month |
87
+ | 5 | $0.30/month | $300/month | $5,000/month |
88
+
89
+ These examples exclude operations, database costs, free-tier eligibility and replicas. One million symmetric KMS operations adds $3. Each store write attempt uses one encryption operation; each successful credential read requires one decryption. Validation uses both. Rewriting an existing row uses a read plus a write, so two operations before retries: $6 per million rows, plus database/compute work. A future data-key rewrap also requires decrypt/encrypt calls but is not implemented. Five retained versions could mean an initial version plus four rotations; this is an example, not a retention recommendation. Retention follows credential and backup recovery requirements.
90
+
91
+ ## Verification
92
+
93
+ Focused tests use real local Postgres and AES-GCM with a GCP transport/IAM model. They cover atomic store conformance, tampering, checksums, key revocation, key replacement, primary-version rotation and restoration of old rows. They explicitly show that AAD cannot substitute for IAM.
94
+
95
+ The opt-in live suite requires two pre-provisioned test CryptoKeys and separate identities, each authorized only on its own key. Configure `SANOMA_GCP_KMS_LIVE=1`, `SANOMA_GCP_KMS_LIVE_KEY`, `SANOMA_GCP_KMS_LIVE_OTHER_KEY`, `SANOMA_GCP_KMS_LIVE_CREDENTIALS` and `SANOMA_GCP_KMS_LIVE_OTHER_CREDENTIALS`; the latter two name ignored SDK credential/federation configuration files. Use the isolated `SANOMA_TEST_DATABASE_URL` and run `pnpm vitest run packages/workflows/test/kms-secrets-live.test.ts`.
96
+
97
+ The suite creates no cloud resources and cleans only its own synthetic Postgres rows. Its positive checks prevent nonexistent/disabled keys from masquerading as an isolation proof. Local modeled tests do not establish real IAM isolation.
98
+
99
+ Live verification on `sanoma-513217` passed at `c3c293d0bc48cbcf66adf8e0cb0c57f500e686bc`: store conformance and two-way encrypt/decrypt denial between two existing company keys under distinct scoped identities. A separate run also proved production/preview denial in both directions. Positive own-key operations passed before each negative check. The local SDK used explicit service-account impersonation through the administrator’s OAuth session, with no service-account private keys. A separate temporary Cloud Run broker proof exercised the SDK/store against remote company databases, cross-key denials and KMS primary-version rotation: old ciphertext remained readable and new writes adopted the new primary after propagation. These are isolated synthetic resources, not a managed-hosting rollout; runtime renewal and production deployment evidence belong to D1.
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, and `sendCode(email, code)` when the config names a `login.sender`); 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 when it verified one, 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?, subject? }` (`StartedBy`): the principal's id (a person's, `visitor:<login>:<subject>`, `token:<id>`), for a visitor the email their site's login verified, 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.
@@ -348,6 +380,8 @@ A durable call pins its account revision before policy evaluation. A held direct
348
380
 
349
381
  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
382
 
383
+ Workload-identity methods receive `verify(context?: AuthenticationContext)` instead of a credential bundle. The runtime supplies `{ connectionId, vendor }` from its selected connection during secure intake, Test and execution. Use that reference to select a trusted deployment identity mapping, then verify the identity with the provider; it is not itself proof of cloud authority. The method stores no credential bundle and must not accept a caller-selected credential file or fall back to ambient identity. Bundle-based methods still receive `verify(credentials)`. See [GCP provisioning identity](../../connectors/gcp/README.md#deployment-identity) for an implementation.
384
+
351
385
  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
386
 
353
387
  Resource declarations use the same identity as an option, loaded with the rest of the data file:
@@ -388,6 +422,8 @@ Call `sanoma.connection.test` with `{ connection }` to verify the current bundle
388
422
 
389
423
  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
424
 
425
+ The managed Cloud [runtime identity broker](https://github.com/Sanoma-AI/sanoma/blob/main/packages/workflows/CLOUD-IDENTITY.md) supplies refreshable, deployment-bound Google access tokens; its enrollment, bootstrap and rollout requirements are separate from credential storage and provisioning identity.
426
+
391
427
  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.
392
428
 
393
429
  ### Resources
@@ -416,7 +452,7 @@ export const github = defineConnector("github", { repository });
416
452
 
417
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).
418
454
 
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.
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.
420
456
 
421
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)).
422
458
 
@@ -559,7 +595,7 @@ A host's revision is drift too (see [Hosts](#hosts)): the run resolves its ref w
559
595
 
560
596
  ### Apply
561
597
 
562
- `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.
598
+ `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.
563
599
 
564
600
  ```ts
565
601
  const runId = await client.apply(
@@ -577,11 +613,11 @@ This package knows nothing of OpenTofu. Resource types from an OpenTofu provider
577
613
 
578
614
  ### Hosts
579
615
 
580
- 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.
616
+ 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.
581
617
 
582
618
  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.
583
619
 
584
- 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.
620
+ 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.
585
621
 
586
622
  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`.
587
623
 
@@ -601,7 +637,7 @@ A database is a resource type that holds the company's database (a Neon project,
601
637
 
602
638
  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.
603
639
 
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).
640
+ 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).
605
641
 
606
642
  ### Versions
607
643
 
@@ -617,7 +653,7 @@ Queues used to be one `sanoma` queue for every app and are now `sanoma:<appName>
617
653
 
618
654
  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.
619
655
 
620
- 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:
656
+ 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:
621
657
 
622
658
  ```json
623
659
  {
@@ -627,7 +663,7 @@ oxlint, with the rules this package ships in `oxlint.json`, refuses the clock (`
627
663
 
628
664
  `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.
629
665
 
630
- 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:
666
+ 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:
631
667
 
632
668
  ```ts
633
669
  import { describeConfig } from "@sanoma-ai/workflows/describe";
@@ -801,10 +837,12 @@ Each node's `path` is its place in the outline: its index among its siblings, un
801
837
 
802
838
  `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.
803
839
 
804
- 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`.
840
+ 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`.
805
841
 
806
842
  Status: early (0.x). The API may change between minor versions.
807
843
 
808
844
  TODO: redact fields a schema marks `.meta({ sensitive: true })` from the ledger, the approvals event and DBOS's step records, so a secret passed by mistake is not kept.
809
845
 
810
846
  License: FSL-1.1-ALv2.
847
+
848
+ The server-only [GCP KMS credential store](KMS-SECRETS.md) is a managed-backend option with explicit company-scoped bootstrap. It requires reviewed IAM/database isolation and does not provision Cloud infrastructure.
package/SECRET-SUPPORT.md CHANGED
@@ -17,7 +17,7 @@ Configuration and lifecycle contracts remain in [Credentials](README.md#credenti
17
17
 
18
18
  ## Connector coverage
19
19
 
20
- **Neon is the only connector in this checkout declaring a named authentication method.** Its [authentication documentation](../../connectors/neon/README.md#authentication) describes the supported bundle and provider verification.
20
+ **Neon is the only connector in the 0.10.1 checkpoint declaring a named authentication method.** Its [authentication documentation](../../connectors/neon/README.md#authentication) describes the supported bundle and provider verification.
21
21
 
22
22
  GitHub, Stripe, Supabase and Docker resource drivers use the session-capable bridge, but do not yet opt into named authentication. Supabase Vault is a storage adapter; it does not migrate the Supabase Management API/resource driver. Other connectors retain their existing authentication paths. Login, model and remote halves are also distinct from named operation accounts. This release does not promise multiple named accounts for every connector, nor Gmail, LinkedIn, Reddit or Instagram OAuth connections.
23
23
 
package/dist/apply.js CHANGED
@@ -10,7 +10,7 @@ export { APPLY_WORKFLOW } from "./shared.js";
10
10
  // would make each match its declaration, asks one approval wherever the policy holds a change,
11
11
  // then makes the changes in dependency order, keeping the lockfile's bindings as it goes. It is a
12
12
  // workflow like any other, held to the same lint, so every vendor call goes through `ctx`. Only
13
- // it may call a writable type's change operations (`isChangeOp`).
13
+ // it may call a writable type's change operations (`isChangeOp`) on a declared object.
14
14
  /** What a person answers for a bound object the vendor no longer has (`gone`), or a binding no declaration has (`orphan`). */
15
15
  export const PLAN_ANSWERS = ["recreate", "forget", "delete"];
16
16
  /**