Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
d3ce4eb
wip(automation): flow credentials move into a write-only channel on t…
claude Oct 2, 2026
8584ba8
wip(automation): pins, docs and changeset for the flow credential cha…
claude Oct 2, 2026
c41817b
wip(automation): one cleared-secret guard; the metadata door's error …
claude Oct 2, 2026
a38db79
wip(automation): bound the channel's unique key; the two censuses cou…
claude Oct 2, 2026
699f3cf
wip(automation): a failed credential move logs at error; the test dou…
claude Oct 2, 2026
457f434
wip(automation): dogfood aliases trigger-api to source; the protocol …
claude Oct 2, 2026
6c67c3a
Merge remote-tracking branch 'origin/main' into claude/issue-20790-fl…
claude Oct 2, 2026
ff02ec5
docs(automation): the projection module states where a stored flow's …
claude Oct 2, 2026
9cc9fdf
fix(automation): the channel's table rides the run objects' manifest …
claude Oct 2, 2026
6dd8c07
Merge remote-tracking branch 'origin/main' into claude/issue-20790-fl…
claude Oct 2, 2026
80b4647
test(runtime): the projection pin's clone case reads the C1 refusal o…
claude Oct 2, 2026
bd9a133
docs(automation): scope the flow credential sentence to a flow saved …
claude Oct 2, 2026
be755de
Merge remote-tracking branch 'origin/main' into claude/issue-20790-fl…
claude Oct 2, 2026
84d3d29
fix(automation): a packaged literal hook asks the credential channel …
claude Oct 2, 2026
417ba1f
Merge remote-tracking branch 'origin/main' into claude/issue-20790-fl…
claude Oct 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changeset/20790-flow-credential-channel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
'@objectstack/spec': minor
'@objectstack/service-automation': minor
'@objectstack/metadata-protocol': minor
'@objectstack/trigger-api': minor
'@objectstack/runtime': minor
---

feat(automation): a flow's credentials live in a write-only channel, not in its stored definition (#20790)

Clause-②: yes (widening)

A flow's two credentials, an inbound hook's `secret` on its start node and an `http` node's `signingSecret`, are no longer stored in the flow definition. The metadata save door moves each explicit value into a new platform object, `sys_flow_credential`, owned by `@objectstack/service-automation`. Its one field is `type: 'secret'`, so the engine encrypts it through the host crypto provider, masks it on every read, and dereferences it only through `resolveSecretField`. This is the same seam the webhook signing secret uses. The stored row, every new version-history row and the row's content hash carry no credential. The engine reads the value only when it verifies an inbound post or signs an outbound request. Authoring does not change: you still write the literal, a save that leaves the key out (the form every read serves) keeps the stored secret, `''` clears it, and only an explicit new value rotates it.

**⚠️ Rotate every inbound and outbound flow secret that existed before this release.** On the first boot with a crypto provider, or when a provider registers after a boot without one, each stored flow that still carries a credential is moved into the channel once, and the log prints one notice per flow: `[Automation] flow '<name>' (<state>): … was stored in cleartext … ROTATE: …`. The move guarantees no new copy, but the version-history rows and audit snapshots written before it stay as they were (both are append-only), so an administrator could have read those values. To rotate, save the flow with a new `config.secret` / `config.signingSecret`, then give the new value to whoever signs posts to the hook or verifies its deliveries. The run is recorded in `sys_migration` as `flow-credential-channel` (flow names only, never values). Packaged flows are not moved: a packaged flow's literal stays its source of truth, and where the channel holds a row for it, the row wins at verification.

What else changes:

- **`@objectstack/spec`**: `PLATFORM_OBJECTS_BY_PACKAGE['service-automation']` lists `sys_flow_credential`.
- **`@objectstack/metadata-protocol`**: `registerCredentialChannel(type, channel)` registers a type's write-only credential channel (exported type `MetadataCredentialChannel`). `saveMetaItem` stores the body the channel returns, after the carry-forward and before the put. The runtime authoring gate reads the channel's held positions as present, on an active save and when a draft is published. `SysMetadataRepository.restoreVersion` takes `deriveRestoredBody`, shaped like `promoteDraft`'s `deriveActiveBody`. Rollback and revert pass the channel's strip, so restoring a version written before the move never puts its credential back at rest, and the channel keeps its current credential.
- **`@objectstack/service-automation`**: exports `SysFlowCredential`, `FlowCredentialChannel` and `migrateFlowCredentialsIntoChannel`. `AutomationEngine` gains `setFlowCredentialSource`, `holdsFlowCredential`, `resolveFlowCredential` and `flowCredentialHoldings`. An `api` binding carries `resolveSecret()`, which reads the secret at verification time, so a rotation applies to the next post. A draft save never rotates the live secret; publishing the draft promotes it. Deleting a flow's stored row drops its credentials.
- **`@objectstack/trigger-api`**: `FlowTriggerBinding.resolveSecret` arms a hook without a literal. A post whose secret cannot be read is answered `503 SERVICE_UNAVAILABLE` and is never verified against nothing.
- **Refused now, loudly**:
- With no crypto provider, a save that carries a flow credential is refused with `503 SERVICE_UNAVAILABLE` before anything is written. Register a provider (`setCryptoProvider`) and save again.
- The clone door (`POST /api/v1/automation/:name/clone`) refuses a source that holds a credential, as a literal or in the channel, with `409 RESOURCE_CONFLICT`, because a copy would share it. ⚠️ Accepted cost: a packaged inbound flow can no longer be cloned in one step. Author the copy as a new flow under a new name, with its own secret.

<!-- adr-0087: not-required (no-migration-prescription) the one-time move rewrites stored flow rows through the metadata save door itself, at boot; no authorable key, spelling, export or stored shape is retired, so an author or an upgrading agent has nothing to rewrite. The operator's action is the rotation stated above, which is not a FROM to TO mapping. The gate reads this changeset as non-breaking; the disposition is stated for the migration the ruling named. -->
2 changes: 1 addition & 1 deletion content/docs/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ nothing is assigned or written in its place.
```

<Callout type="warn" title="Do not put a secret in an http node's url or headers">
A flow definition, including an `http` node's `url` and `headers`, is served to every member who can read flows. A token, API key or signed webhook url written there is readable by all of them. Route an outbound credential by where it sits, and call the connector with a `connector_action` node instead — see [Connectors](/docs/automation/connectors#authentication). A credential in a header goes to a declarative connector with `bearer`, `basic` or `api-key` auth, whose `auth.credentialRef` names the secret. A key in the query string goes to `api-key` auth with `paramName`. No `credentialRef` variant carries a secret in the url path, so an incoming-webhook url (whose path is the secret) cannot be routed that way: call the service through a token-authenticated connector instead, such as the `slack` connector, whose bot token is supplied to the plugin by host code rather than written in the flow (it is registered by that plugin, not declared as a `connectors:` instance). Otherwise such a url is served with the definition. Only `signingSecret` (and a start node's `secret`) is withheld when a definition is served; `url` and `headers` are served as written.
A flow definition, including an `http` node's `url` and `headers`, is served to every member who can read flows. A token, API key or signed webhook url written there is readable by all of them. Route an outbound credential by where it sits, and call the connector with a `connector_action` node instead — see [Connectors](/docs/automation/connectors#authentication). A credential in a header goes to a declarative connector with `bearer`, `basic` or `api-key` auth, whose `auth.credentialRef` names the secret. A key in the query string goes to `api-key` auth with `paramName`. No `credentialRef` variant carries a secret in the url path, so an incoming-webhook url (whose path is the secret) cannot be routed that way: call the service through a token-authenticated connector instead, such as the `slack` connector, whose bot token is supplied to the plugin by host code rather than written in the flow (it is registered by that plugin, not declared as a `connectors:` instance). Otherwise such a url is served with the definition. Only `signingSecret` and a start node's `secret` are kept out of a flow saved through the metadata API: the metadata save door stores each in a write-only, encrypted flow credential store, a read never returns it, and the engine reads it only to verify an inbound post or sign an outbound request. A packaged flow keeps its literal in its package source. The literal is withheld when the definition is served, and a credential store row for that flow, where one exists, wins when the engine verifies or signs. `url` and `headers` are stored and served as written.
</Callout>

**Script:**
Expand Down
2 changes: 1 addition & 1 deletion content/docs/concepts/metadata-lifecycle.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ See [ADR-0005](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/

| Type | Sanctioned path the refusal names |
| :--- | :--- |
| `flow` | Clone it under a new name (`POST /api/v1/automation/:name/clone`, body `{name, label}`), or switch it off (`POST /api/v1/automation/:name/toggle`, body `{enabled: false}`). |
| `flow` | Clone it under a new name (`POST /api/v1/automation/:name/clone`, body `{name, label}`), or switch it off (`POST /api/v1/automation/:name/toggle`, body `{enabled: false}`). A flow that holds a credential (an inbound hook's `secret`, an `http` node's `signingSecret`) is not cloned in one step: the clone door refuses it with `409`, because a copy would share the secret, and the copy is authored as a new flow with its own. |
| `action` | Switch it off (`POST /api/v1/actions/_activation/:object/:action`, body `{enabled: false}`; `:object` is `global` for an object-less action). No clone is offered. |
| `permission` | Clone it under a new name: the "Clone" action on the permission set, or `POST /api/v1/data/sys_permission_set` with a new name. |

Expand Down
48 changes: 24 additions & 24 deletions content/docs/permissions/tenant-audit-census.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ what moved this page's population from 225 to 227; nothing about the two sites
changed, only whether this instrument could see them.

**The expensive failure direction is a keyword.** Sites whose receiver the author
typed `any` have no type to read, and there are 44 of them — just under a fifth
typed `any` have no type to read, and there are 48 of them — just over a fifth
of the population, concentrated in exactly the seed and bootstrap paths this
control exists for. Scoring an unreadable receiver as "not an engine" would have
dropped every one of them silently, with a clean exit and a smaller number that
Expand Down Expand Up @@ -122,7 +122,7 @@ are reported as `undecidable` rather than assumed either way.

The same holds twice over for the context. An options argument spelled as a
literal can be read; one spelled `options`, `{ ...opts }`, or handed through a
forwarding shim cannot, and **67 of the 219 sites are spelled that way**. A
forwarding shim cannot, and **67 of the 229 sites are spelled that way**. A
context resolved from an inline literal or a local `const` can be tested for
`isSystem`; one arriving from a helper call cannot.

Expand Down Expand Up @@ -187,10 +187,10 @@ reproduce them. Where it disagrees, it disagrees on the page:

| carried figure | where it survives | this census |
| :--- | :--- | ---: |
| 175 write call sites | quoted in the merged changeset | **219** |
| 175 write call sites | quoted in the merged changeset | **229** |
| 24 carrying no tenant context | quoted in the merged changeset | **9** provable and tenancy-enabled; **34** more whose options argument is unreadable |
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **144 of 219** decidable, **75** undecidable |
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 100 decidably elevated, 0 decidably not, 102 undecidable |
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **152 of 229** decidable, **77** undecidable |
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 110 decidably elevated, 0 decidably not, 102 undecidable |
| 141 and 132, two independent re-derivations | the card that filed this work | — |

**The differences are not reconciled, and deliberately so.** The old census's
Expand All @@ -200,18 +200,18 @@ be stated is what this instrument counts, which is written above and re-runnable
at any commit.

Two structural facts do plausibly widen this reading against any hand or regex
one, and both are counted in the generated tables below: the 44 sites reached
through an erased (`any`) receiver, and the 43 that name their object through a
one, and both are counted in the generated tables below: the 48 sites reached
through an erased (`any`) receiver, and the 51 that name their object through a
`const` rather than inline. An instrument that read either the way a person does
would report a smaller number and would not say so.

The fourth row is the one worth flagging to anyone citing it. **The 135 / 77%
figure has no surviving corroboration anywhere in the tree.** This census reads
100 of 219 (46%) as decidably elevated, with 102 more whose elevation is a
110 of 229 (48%) as decidably elevated, with 102 more whose elevation is a
run-time fact — so the claim is neither confirmed nor refuted, and the honest
answer is that a static reading cannot settle it.

⇒ **Cite `9 / 219`, and say what it is**: the sites whose options argument was
⇒ **Cite `9 / 229`, and say what it is**: the sites whose options argument was
READ and holds no tenant context, against a decidably tenancy-enabled object.
That is the control's provable yield surface. ⛔ Do not cite it as "the sites
without tenant context" — **34 further sites** have an options argument this
Expand All @@ -223,31 +223,31 @@ cannot read, and they are neither in nor out.

| what | count |
| :--- | ---: |
| write call sites on the application surface | **219** |
| …whose object name is statically decidable | 144 |
| …whose object name is chosen at run time | 75 |
| …against an object with tenancy ENABLED | 143 |
| write call sites on the application surface | **229** |
| …whose object name is statically decidable | 152 |
| …whose object name is chosen at run time | 77 |
| …against an object with tenancy ENABLED | 151 |
| …against an object that declares tenancy off | 1 |
| threading a tenant context | 135 |
| threading a tenant context | 145 |
| PROVABLY carrying none (options read, no context key) | **17** |
| …of those, against a decidably tenancy-enabled object | **9** |
| options argument UNREADABLE — may or may not carry one | 67 |
| …of those, against a decidably tenancy-enabled object | 34 |
| threading a decidably ELEVATED (`isSystem`) context | 100 |
| threading a decidably ELEVATED (`isSystem`) context | 110 |
| threading a context that is decidably NOT elevated | 0 |
| threading a context whose elevation is a run-time fact | 102 |

| how the instrument reached the site | count |
| :--- | ---: |
| receiver carried a readable engine type | 175 |
| receiver erased, placed by the object NAME | 24 |
| receiver carried a readable engine type | 181 |
| receiver erased, placed by the object NAME | 28 |
| receiver erased, placed by an `object: string` PARAMETER | 15 |
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 |

| object name spelled inline | 101 |
| object name spelled through a `const` | 43 |
| object name spelled through a `const` | 51 |
| object name is an `object: string` parameter | 17 |
| object name is some other run-time expression | 58 |
| object name is some other run-time expression | 60 |

### Subtractions the census could NOT defend — enforced

Expand Down Expand Up @@ -297,13 +297,13 @@ holds still. They are required to be HERE and to say WHEN they were true;
their values are not compared. The reasoning, and the measurement behind it,
are in `scripts/check-tenant-audit-census.mjs`.

Measured on 2026-10-01 at `752173845`.
Measured on 2026-10-02 at `c41817b12`.

| corpus scale (not enforced) | count |
| :--- | ---: |
| tracked non-test sources scanned | 586 |
| engine-shaped types recognised | 64 |
| declared objects in the registry | 115 |
| same-named calls subtracted as non-engine | 145 |
| tracked non-test sources scanned | 599 |
| engine-shaped types recognised | 66 |
| declared objects in the registry | 116 |
| same-named calls subtracted as non-engine | 150 |

{/* END GENERATED: tenant-audit-census */}
27 changes: 16 additions & 11 deletions docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution.

| Measure | Value |
|---|---:|
| Write call sites | 219 |
| Object name statically decidable | 144 |
| Object name chosen at run time | 75 |
| Against a tenancy-enabled object | 143 |
| Write call sites | 229 |
| Object name statically decidable | 152 |
| Object name chosen at run time | 77 |
| Against a tenancy-enabled object | 151 |
| Against an object declaring tenancy off | 1 |
| Threading a tenant context | 135 |
| Threading a tenant context | 145 |
| Provably carrying none | 17 |
| …and decidably tenancy-enabled | 9 |
| Options argument unreadable | 67 |
| …and decidably tenancy-enabled | 34 |
| Threading a decidably elevated context | 100 |
| Threading a decidably elevated context | 110 |
| Threading a decidably non-elevated context | 0 |
| Threading a context of undecidable elevation | 102 |

Expand Down Expand Up @@ -90,14 +90,14 @@ holds still. They are required to be HERE and to say WHEN they were true;
their values are not compared. The reasoning, and the measurement behind it,
are in `scripts/check-tenant-audit-census.mjs`.

Measured on 2026-10-01 at `752173845`.
Measured on 2026-10-02 at `c41817b12`.

| corpus scale (not enforced) | count |
| :--- | ---: |
| tracked non-test sources scanned | 586 |
| engine-shaped types recognised | 64 |
| declared objects in the registry | 115 |
| same-named calls subtracted as non-engine | 145 |
| tracked non-test sources scanned | 599 |
| engine-shaped types recognised | 66 |
| declared objects in the registry | 116 |
| same-named calls subtracted as non-engine | 150 |

## Every site

Expand Down Expand Up @@ -196,6 +196,11 @@ Measured on 2026-10-01 at `752173845`.
| `packages/services/service-automation/src/builtin/crud-nodes.ts` | `delete` | `objectName` | undecidable | context, elevation undecidable | 1 |
| `packages/services/service-automation/src/builtin/crud-nodes.ts` | `insert` | `objectName` | undecidable | context, elevation undecidable | 1 |
| `packages/services/service-automation/src/builtin/crud-nodes.ts` | `update` | `objectName` | undecidable | context, elevation undecidable | 1 |
| `packages/services/service-automation/src/flow-credential-channel.ts` | `delete` | `sys_flow_credential` | enabled | elevated | 5 |
| `packages/services/service-automation/src/flow-credential-channel.ts` | `insert` | `sys_flow_credential` | enabled | elevated | 1 |
| `packages/services/service-automation/src/flow-credential-channel.ts` | `update` | `sys_flow_credential` | enabled | elevated | 2 |
| `packages/services/service-automation/src/flow-credential-migration.ts` | `insert` | `DATA_MIGRATION_FLAG_OBJECT` | undecidable | elevated | 1 |
| `packages/services/service-automation/src/flow-credential-migration.ts` | `update` | `DATA_MIGRATION_FLAG_OBJECT` | undecidable | elevated | 1 |
| `packages/services/service-automation/src/flow-dispatch-store.ts` | `insert` | `sys_flow_dispatch` | enabled | elevated | 1 |
| `packages/services/service-automation/src/flow-dispatch-store.ts` | `update` | `sys_flow_dispatch` | enabled | elevated | 1 |
| `packages/services/service-automation/src/suspended-run-store.ts` | `delete` | `sys_automation_run` | enabled | elevated | 3 |
Expand Down
2 changes: 1 addition & 1 deletion packages/metadata-protocol/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ export type { ClusterMetadataMutationPayload } from './protocol.js';
// kernel-wide `metadata:reloaded` announce. Exported for the same reason its
// mutation sibling is: the subscriber lives in another package.
export type { MetaItemPublishedEvent } from './protocol.js';
export type { MetadataAuthoringGate, MetadataAuthoringGateContext } from './protocol.js';
export type { MetadataAuthoringGate, MetadataAuthoringGateContext, MetadataCredentialChannel } from './protocol.js';

export { SysMetadataRepository, resetEnvWritableMetadataTypes } from './sys-metadata-repository.js';
export type {
Expand Down
Loading
Loading