Repository navigation
Commit c96beb2
Fixes #20552
Clause-②: yes (widening)
## What this changes
An `api` flow's start node carries its inbound hook's HMAC secret
(`config.secret`, ADR-0041), the only credential that hook has. Every
read that served the flow's definition served the secret with it, to any
authenticated caller. This PR withholds it from every **served** flow
definition, with one helper applied where each surface's definition
leaves the process, and keeps it everywhere the engine **executes**.
- **One helper.** `redactFlowCredentials`
(`packages/services/service-automation/src/flow-credential-projection.ts`)
removes `config.secret` from every `start` node and nothing else. The
automation plugin registers it at `init` as the `flow` entry of the
existing per-type read-path redactor registry
(`@objectstack/spec/kernel`). That is the seam the datasource credential
fix (#13405's class) already uses, so no second redaction dialect
exists. The key is **dropped, not masked**. A mask is a non-blank string
that `validateApiTriggerSecret` would accept, so a write path that
missed the carry-forward would silently store the mask as the HMAC
secret. An absent key is refused loudly by every registration door.
- **Metadata plane.** Every protocol read exit already applies the
registry: item, list, layered, draft preview, diff, audit and package
export. The one exit that did not was `MetadataManager.getPublished`,
the body both `/published` doors serve when no runtime overlay exists.
It now applies the type's registered redactor too, which also covers the
built-in datasource redactor.
- **Automation domain.** The four exits that answer with a flow
definition go through one function, `servedFlowDefinition`: the
definition read, the `POST /` and `PUT /:name` write answers (#12206: a
write answers what the read serves), and the clone answer. That function
applies the same registry entry through `redactMetadataItem('flow', …)`,
so the two planes cannot disagree about what is withheld.
`automationService.getFlow` itself stays the raw in-process read. The
clone door copies a **whole** definition through it (ADR-0126 §7.1),
secret included, and redaction is a serving act.
- **The round trip.** `carryForwardRedactedValues` is the one inverse on
both planes: the metadata save door, and now the automation `PUT /:name`
and `POST /` onto an existing name. A body that carries the projected
form keeps the stored secret, and an explicit value replaces it. The
inverse now follows a path through an **array** (a flow's `nodes`) by
the stored element's `id`, not by its index. An edit that reorders
`nodes` therefore still carries the secret back onto the start node, not
onto whichever node now sits at the old position. An element with no
`id`, or one whose `id` is shared with a sibling, is never carried into.
- **The engine binds from the execution face.** This is the half the
dispatch's route did not foresee (A3 below). The automation plugin
(re)binds flows from the protocol at `kernel:ready` and on every
`metadata:reloaded`, which covers every Studio publish. It read the
**served** `getMetaItems`, which no longer holds the secret.
`ObjectStackProtocolImplementation` therefore gains
`getMetaItemsForExecution`: the same body, sources and merge as
`getMetaItems`, returned without the serving decorations (no
`_diagnostics`, no redaction). The plugin reads flows (and connectors)
through it. Its docblock forbids any door that answers a caller from
using it.
## The dispatch's mechanism assumptions, measured
- **A1 — held.** The secret is a literal in the start node's `config`,
and nothing projected it anywhere.
- **A2 — census, from a composed showcase boot at `1c761c0d`, read as a
member-level user.** Surfaces that served the secret: the automation
domain's definition read; the metadata-plane item, list, layered,
draft-preview and published reads; and, for an administrator, the
package export. After the change, at `25362c12`'s code, no response from
any of them contains it, for the member or the administrator. The
automation write and clone answers, and the metadata diff and audit
reads, carry none of it either.
- **A2, cross-organization — measured under an `isolated` posture.** A
member of another organization reads the same definitions. Flows are
environment-wide metadata (`allowOrgOverride: false`, ADR-0005), and an
organization administrator without `manage_metadata` is refused
authoring (measured 403 on both write doors). So the definition read
across organizations is the environment-wide design, not a tenant leak
by itself. The credential it carried was the leak, and this projection
closes it for that reader too.
- **A3 — FALSIFIED.** Projecting at the metadata-plane read source DOES
break verification, because the engine registers flows from that same
served list. Ablation A3 below measures it: with the plugin reading the
served face, an inbound flow is never armed, and a republish or rotation
never reaches the hook. `getMetaItemsForExecution` is the fix.
- **A4 — reused.** The precedent's registry
(`registerMetadataTypeRedactor`), its generic inverse
(`carryForwardRedactedValues`) and its drop-not-mask posture are all
reused. The one extension is the array hop above.
- **A5 — NOT MEASURED.** The Studio designer's round trip was not
measured, because objectui is not reachable from this session. Believed
path: Studio's `nav_flows` entry is `componentRef: 'metadata:resource'`
with `type: 'flow'` (`platform-objects/src/apps/studio.app.ts`), that
is, the metadata plane's item read and draft save + publish. That path
is covered by the metadata-plane carry-forward and the execution-face
bind, and both were measured live through the same API the component
calls.
## Live measurement (local, composed showcase, after the change)
As an administrator, a definition read through each plane was edited and
saved back in its projected form, then republished. The hook kept
verifying with the original secret, and a wrong secret was refused. On
the metadata plane the edit reordered `nodes`, and the stored row kept
the secret on the start node. An explicit rotation made the old secret
fail and the new one verify. No served read carried either value at any
point. The boot binds the same 20 of 30 flows as the baseline boot.
## Tests
Pins, one file per package (all new behaviour, all green at `25362c12`):
- `service-automation/src/flow-credential-projection.test.ts` covers
four things: what the helper withholds; that the plugin registers it;
that an inbound hook is armed with the stored secret while the served
face withholds it; and that a republish keeps it while a rotation
replaces it. The binding `config` asserted is the object `trigger-api`'s
`start()` reads the HMAC secret from.
- `metadata-protocol/src/protocol.metadata-redaction.test.ts` covers the
array-hop carry-forward (reorder, rotation, removed container, missing
or duplicate id), every read exit against the execution face, and the
save round trip.
- `runtime/src/domains/automation-flow-credential-projection.test.ts`
covers the four automation exits for a member-level caller, and the
`PUT`/`POST` round trip with a rotation.
- `metadata/src/metadata-service.test.ts` covers `getPublished`.
Package suites: `metadata` 828 passed; `metadata-protocol` 2765 passed,
19 skipped; `service-automation` 1851 passed; `runtime` (unit project)
4190 passed, 1 skipped. `typecheck` is green on all four. The 65 gate
commands `dispatch-gates.mjs --commands` derives from this diff all exit
0 at `25362c12`. `eslint --no-inline-config` over the 16 changed source
files reports 0 findings. The config lints every `*.ts` outside the
never-linted build dirs and enables no type-aware rule
(`eslint.config.mjs`, "never enables type-aware linting"), so the diff
cannot move an untouched file's verdict.
### Ablations: each forbidden behaviour put back, committed tree,
restore proven
Every mutation went through `scripts/ablation-replace.mjs`. The anchor
hit once, the blob changed, and the restore was proven as "blob == HEAD
and `git diff HEAD` empty". Each subject is loaded from the package's
own `src`, so no `dist` was involved.
| # | Put back | Pins that went red |
|---|---|---|
| A1 | `servedFlowDefinition` returns the flow unredacted | 4 of 7: the
definition read, create answer, clone answer and `PUT` answer. `expected
'{"success":true,"data":{"name":"inbou…' not to contain
'stored-hook-secret-20552'` |
| A2 | `redactFlowCredentials` withholds nothing | 3 of 6: `expected []
to deeply equal [ 'nodes.1.config.secret' ]`; the served-face control
`expected '{"items":[{"name":"inbound_hook","lab…' not to contain
'stored-hook-secret-20552'` |
| A3 | the plugin binds from the served `getMetaItems` | 2 of 6: the arm
and republish pins, `expected undefined to be
'stored-hook-secret-20552'` |
| A4 | the protocol's served list skips the redaction | 2 of 23: the
flow read exits `expected [ 'flow', 'inbound_hook', …(14) ] to not
include 'stored-hook-secret-20552'`, and the datasource list pin
`expected 'hunter2' to be undefined` |
| A5 | `getPublished` returns the body unredacted | 1 of 72: `expected
'{"name":"inbound_hook","label":"Inbou…' not to contain
'stored-hook-secret-20552'` |
| A6 | an array hop resolves nothing (the pre-change walk) | 2 of 23:
the reorder carry-forward and the save round trip, `expected undefined
to be 'stored-hook-secret-20552'` |
## Deviations
- **A new public method on a published package's exported class.**
`ObjectStackProtocolImplementation.getMetaItemsForExecution`
(`@objectstack/metadata-protocol`) is reachable from the package entry.
The `IAutomationService` and `ObjectStackProtocol` contracts in
`packages/spec` are untouched, and no key is added to any wire payload.
`Clause-②: no` is copied from the claim as dispatched. The seat may
correct it to `yes (widening)`, in which case
`@objectstack/metadata-protocol` moves to `minor` in the changeset.
- **`packages/metadata` is touched** (`getPublished`). The claim's file
surface names "`packages/metadata*` or `packages/rest`" for the
metadata-plane read, so this is inside it.
- **The automation domain projects at its exits, not inside the engine's
`getFlow`.** The reasons are the clone and in-process readers given
above. The dispatch's route suggested projecting where the definition
leaves the engine. This PR projects where it leaves the process, through
one function and the one registry entry.
## Acceptance notes
- **Consequence stated in the changeset.** A package export no longer
carries an inbound flow's secret. Re-importing it elsewhere registers
its `api` flows only once a secret is set again, and until then they are
refused at registration, loudly.
- **A clone still shares its source's secret** (the whole-definition
copy is unchanged). Whether a per-flow secret should survive a clone
belongs with the durable write-only secret seam that triage routed to
the maintainer after this lands.
- **A rotation is invisible in the metadata diff**, because both sides
are redacted. This is the datasource precedent's posture.
- **An author who deletes `secret` from a projected body gets the stored
one back.** The wire cannot tell that from a round trip. This is the
ambiguity the datasource inverse documents, and rotating the secret or
deleting the flow is the unambiguous door.
- Not changed, noted only: the runtime twin of the metadata list read
falls back to `metadataService.list()` (raw) when the protocol read
throws. This is source-read and unreached on a composed boot, and it
applies to datasources as much as flows.
## Seat append (domain:services seat,
`session_01XY5uCwTjZj7884yYtyur4H`)
- The `Clause-②` line above was corrected from `no` to `yes (widening)`
by the seat, not by the dev. `getMetaItemsForExecution` is a new public
method on `ObjectStackProtocolImplementation`, which
`@objectstack/metadata-protocol` exports from its entry. The claim was
corrected in place in the same act. The changeset follows in patch round
1: `@objectstack/metadata-protocol` moves to `minor`, and the changeset
carries the same line.
## Seat append — patch round 2 (the dev's text, appended by the
`domain:services` seat)
### The first metadata-plane save of a code-authored item (contract
review F1)
**Measured first.** Three new pins in
`packages/metadata-protocol/src/protocol.metadata-redaction.test.ts`
seed a registry-only (code-authored) `api` flow with no `sys_metadata`
row. Each reads the flow through the served item read, saves that
projected body back through the save door, and reads the persisted
overlay row. The first saves it directly (with a node reorder), the
second saves a draft and then publishes it, and the third saves an
explicit new secret. They were committed at `004f70bd` and run against
the unfixed save door. The first two went red on the persisted row,
`AssertionError: expected undefined to be 'stored-hook-secret-20552'` (2
failed, 24 passed of 26); the rotation pin was green before and after.
**Fix** (`02b73b05`). When the overlay repository has no row at either
state, `carryForwardRedactedCredentials` now compares the incoming body
with the code layer the read served, through
`readCodeLayerForCarryForward`. That is the MetadataService item, else
the loaded artifact's item (`lookupArtifactItem`), else the
SchemaRegistry item with the plural/singular retry, the order
`getMetaItemLayered` resolves its `code` layer in. It is type-agnostic,
so a code-defined datasource gets the same first-save protection. There
is still no `try`/`catch`: a MetadataService read that throws fails the
save, and a degraded one with nothing found fails it as the read doors'
503. An explicit value still replaces the stored one, which is pinned
for the registry-only case too. The changeset gains one sentence stating
this.
**Ablation.** The fallback was removed through
`scripts/ablation-replace.mjs` on the committed tree: the line became
`stored?.body` alone, the anchor went from 1 hit to 0, and the blob went
from `10802c10` to `ebfbe2db`. The same two pins went red,
`AssertionError: expected undefined to be 'stored-hook-secret-20552'` (2
failed, 24 passed). The restore was proven: blob == HEAD and `git diff
HEAD` empty.
**Live** (local, composed showcase, one persistent store across two
boots, after the fix). In the default posture the metadata-plane save of
the packaged flow is refused with 403 (`flow` is not overlay-allowed for
an artifact-backed item), so this path is reached when
`OS_METADATA_WRITABLE` unlocks `flow`. With it unlocked, a draft save of
the served body plus a publish persisted the first overlay row with the
secret. The hook kept verifying in that process. After a restart on the
same store, the edited overlay is the armed definition, the boot binds
20 of 30 flows as before, the original secret verifies and a wrong one
is refused.
**Runs at `ce8475ea`** (the source is identical to `02b73b05`; the one
later commit is the changeset sentence). `metadata-protocol`: 2768
passed, 19 skipped. `service-automation`: 1851 passed. `typecheck` is
green on both. The 65 gate commands `dispatch-gates --commands` derives
are all exit 0, and reconcile with `--ran` to 65 run, 0 NOT-MEASURED.
`origin/main` was merged first (`dc1e281f`).
---
_Generated by [Claude
Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7001918 commit c96beb2
17 files changed
Lines changed: 1276 additions & 91 deletions
File tree
- .changeset
- packages
- metadata-protocol/src
- metadata/src
- runtime/src/domains
- services/service-automation/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
131 | 131 | | |
132 | 132 | | |
133 | 133 | | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
134 | 183 | | |
135 | | - | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
136 | 211 | | |
137 | | - | |
138 | | - | |
139 | | - | |
140 | | - | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
141 | 217 | | |
142 | 218 | | |
143 | 219 | | |
144 | | - | |
| 220 | + | |
145 | 221 | | |
146 | | - | |
147 | | - | |
148 | | - | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
149 | 232 | | |
150 | | - | |
151 | | - | |
| 233 | + | |
152 | 234 | | |
153 | 235 | | |
154 | 236 | | |
155 | | - | |
156 | | - | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
157 | 240 | | |
158 | | - | |
159 | | - | |
160 | | - | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
161 | 245 | | |
162 | | - | |
163 | | - | |
164 | | - | |
165 | | - | |
166 | | - | |
167 | | - | |
168 | | - | |
169 | | - | |
170 | | - | |
171 | | - | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
172 | 252 | | |
173 | | - | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
174 | 257 | | |
175 | 258 | | |
176 | 259 | | |
| |||
209 | 292 | | |
210 | 293 | | |
211 | 294 | | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
212 | 301 | | |
213 | 302 | | |
214 | 303 | | |
| |||
238 | 327 | | |
239 | 328 | | |
240 | 329 | | |
241 | | - | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
242 | 333 | | |
243 | 334 | | |
| 335 | + | |
| 336 | + | |
244 | 337 | | |
245 | | - | |
| 338 | + | |
246 | 339 | | |
247 | 340 | | |
248 | 341 | | |
249 | | - | |
| 342 | + | |
250 | 343 | | |
251 | 344 | | |
252 | | - | |
| 345 | + | |
253 | 346 | | |
254 | 347 | | |
255 | | - | |
| 348 | + | |
256 | 349 | | |
257 | 350 | | |
258 | 351 | | |
0 commit comments