fix(service-automation,metadata-protocol,metadata,runtime): withhold a flow's inbound-hook secret from every served definition, and keep it on a round trip (#20552) - #20585
Conversation
…inbound-hook secret out of every served definition read (#20552) Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
…dential projection on every surface and the round trip (#20552) Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 4 package(s): 13 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 5 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 36 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d5f818ad30118f420785de6128be50fe9355ce10 && git checkout d5f818ad30118f420785de6128be50fe9355ce10
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e666636fd99a363d76753f5455a5708b9c9876cb ce8475eab9b68d1e80e8ff77b9d7249cd27d6748 && git checkout -B drift-repro e666636fd99a363d76753f5455a5708b9c9876cb && git merge --no-ff ce8475eab9b68d1e80e8ff77b9d7249cd27d6748
node scripts/docs-audit/affected-docs.mjs --json e666636fd99a363d76753f5455a5708b9c9876cb
|
…ing (#20552) Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Inputs: card #20552 (body, triage Check-runs on the head, as read: ① Derived judgmentsEach accept-set or public-surface change the diff implies, named right or wrong.
② Semver levelChangeset ③ Boundary flagsDev report
Dev report
Patch round 1 (
Reviewer's own flags:
Implemented-by: VERDICT: FAIL What flips it to PASS on a later head: F1 closed (the artifact-layer fallback in the save door's carry-forward, plus the registry-only round-trip pin). F2, F3, OOS1 and OOS3 are filed, not blocking. Generated by Claude Code |
…ow-hook-secret-read-projection
…which persists its overlay without the secret (#20552) Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
…less item with the code layer the read served (#20552) Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
…ial (#20552) Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Re-review of PR #20585 after patch round 2. The previous record on this PR is What moved since Check-runs on the head, as read: every run ① Derived judgmentsCarried forward, code byte-unchanged since Re-judged at this head:
② Semver levelUnchanged levels: ③ Boundary flagsF1 — closed? YES. The defect was that with no overlay row the carry-forward compared against nothing and persisted the served projection, so the first metadata-plane save of a code-authored The dev's reach deviation (patch round 2, deviation 2). ANSWERED, in two halves. The measurement is right as far as it goes: Patch round 2, deviation 1 — the changeset gained one sentence, levels unchanged. ANSWERED: right, and required (② above). Accepted. Patch round 2, Standing from Implemented-by: VERDICT: PASS Generated by Claude Code |
…ning stored flow-credential positions at every depth, and answer a /meta list fault as itself (objectstack-ai#20590) (objectstack-ai#20615) Part of objectstack-ai#20590 Clause-②: no ## What this changes This PR closes the stored-credential positions that the first instance's projection (PR objectstack-ai#20585) did not reach. Each position was measured first. Its pin was committed red on the unfixed code (`99a75023`) and then closed (`1f17293b`). The defect is stated abstractly here, per the security-family disclosure rule. - **Position 1: a second credential kind in a flow node's `config`.** The registered `flow` projection (`service-automation/src/flow-credential-projection.ts`) withheld only the start node's hook secret. - It now withholds every position in one table keyed by node kind, `FLOW_NODE_CREDENTIAL_KEYS`: the start node's `secret` and the `http` node's `signingSecret`. - It walks every ADR-0031 region (a `loop` body, `parallel` branches, `try_catch` try and catch) through `FLOW_REGION_SLOTS_BY_TYPE`. A credential-holding node nested at any depth is therefore covered. - **Round trip:** the rule is unchanged. The withheld form (the key absent) keeps the stored value, and an explicit value replaces it. - **Removal door, with no spec change:** the empty string. - `HttpConfigSchema` already accepts it, and it holds no secret. - The messaging outbox signs only with a non-empty secret. - The projection serves it as written, so it round-trips as "cleared". Absent is never read as "remove". - **Enumeration pin:** `flow-credential-positions.test.ts`. - It reads every declared node config contract: each builtin executor's descriptor `configSchema`, the schemaless builtins' spec Zod contracts, and the approval node's contract. - It requires the table to equal the credential-named keys it finds, and each to be withheld at the top level and in every region kind. - A newly declared credential key turns it red until the key is covered, or reviewed out with a reason. It is a test in the same package, not a gate. - **Position 2: a list fallback that served stored bodies on a protocol fault.** The runtime dispatcher's `/meta` list branch (`runtime/src/domains/meta.ts`) no longer swallows a throw from the protocol's list read. The throw is answered as itself (`errorFromThrown`). That is one option, per triage's call. - **Position 3: identity versus kind.** `carryForwardRedactedValues` (`metadata-protocol/src/metadata-redaction.ts`) now does two more things. - It re-runs the type's redactor over what it grafted, and drops any carried value whose new position the read would serve. This is the remedy the at-tier review named. - It walks an array element that has no `id` by the identified node beneath it on the same path. A `parallel` branch has no `id`. Position 1 needs this: once a secret inside a branch is withheld, the old "skip the path" would have deleted it silently on every round trip, including one that reorders the branches. ## The dispatch's mechanism assumptions, measured - **A1 held. Position 1 is REACHED at a member-level read.** - At `c96beb27` (the unfixed code), on a composed in-process boot, the registered projection passed `signingSecret` through at every depth. - The boot was `@objectstack/verify`'s `bootStack` on `examples/app-crm` with the automation capability loaded, one member signed up, and the flow authored by the seeded admin. - The member's item, list and published reads each served both values: the top-level `http` node's and the one inside a `loop` body. - The start node's secret was withheld there, which confirms the objectstack-ai#20552 projection was live in that boot. - After this change, none of the three reads carries either value. - **A2 partly falsified: there is no "unknown type" error to discriminate on.** - `ObjectStackProtocolImplementation.getMetaItems` is the one implementation in this repository. It answers a type it holds nothing for with an empty list, because it merges the metadata service's runtime-registered items itself. - What it throws is a store fault (503), a metadata app's marked refusal, a redactor failing closed, or a refused spelling (400). So every throw now propagates, which is what "a fault propagates, an unknown type still falls through" reduces to. - The metadata-service fallback stays for a protocol slot with no list verb. - **Position 2 is REACHED only under a forced fault, and only on a dispatcher-routed host.** A member read on the real `HttpDispatcher` with a forced protocol fault served a flow's hook secret and a datasource's password, `200`. - On the composed boot above, the `/meta` list is `RestServer`'s route. It has no fallback, and a forced fault there answered `503` with nothing served. - **A3 held. Position 3 is REACHED at a member-level read after an authoring round trip.** - On the same composed boot at `c96beb27`, an author's ordinary save kept a node's `id` and changed its kind. The member's next item and list reads then served the old hook secret on that node, and the row at rest held it there. - After this change the carried value is dropped. The node's config at rest is empty, and nothing is served. - **A4: none, as specified.** Details are under Acceptance notes. No in-repo composition serves `/meta` without the automation capability *while sharing a store with one that loads it*. The plugin-absent half does exist in the repository, measured below. ## Tests The pins below were all committed red first, at `99a75023`. - `service-automation`, `flow-credential-positions.test.ts` (new): the enumeration, every position at each depth, exact paths, the cleared form, and a lookalike key on another kind. - `metadata-protocol`, `protocol.metadata-redaction.test.ts`: - relocation through the pure inverse and through the save door (followed by the served reads); - nested carry-forward, including reordered branches and twin branches; - the removal door: the empty string clears, absent keeps, and a value replaces; - the save-door round trip for nested secrets. - `runtime`: - `meta-list-protocol-fault.test.ts` (new): 503, 400 and an undeclared throw are each answered as themselves, with the fallback never called. It also preserves the empty-list answer and the no-list-verb host. - `automation-flow-credential-projection.test.ts`: the relocating `PUT`, then the member's read. - `meta-list-read-gate-parity.test.ts`: its double now reaches the fallback exits by answering no list instead of throwing. Run at `fee1d6b9b`: - Package suites: - `service-automation`: 152 files, 1866 passed. - `metadata-protocol`: 2775 passed, 19 skipped. - `runtime` (`--project local`): 4197 passed, 1 skipped. - `typecheck` is green on all three. `check:test-typecheck` is green on `service-automation` and `runtime`. `metadata-protocol`'s `tsconfig` includes its tests; `--listFiles` counts the edited test once. - Gates: `dispatch-gates.mjs --commands` derives 64 commands from this diff, and all 64 exit 0. `--ran` reconciles 64 derived, 64 run, 0 NOT-MEASURED and 0 UNRUN, every line carrying its exit code. - `check:dual-build-cjs-loads` first answered `PREREQUISITE NOT MET`, because 8 unrelated packages had no `dist/`. After building them it exited 0. - Lint: `eslint --no-inline-config` over the 8 changed `.ts` files reports 0 errors and 0 warnings. - The config lints `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` outside its never-linted directories. - It 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 leg ran on the committed fix and 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". The outer shell also carried a restore trap. | # | Put back | Pins that went red | |---|---|---| | A1 | the `http` row of the table | 8 of 15: the table no longer equals the declared set (`expected [ 'start.secret' ] to deeply equal [ Array(2) ]`), and each `signingSecret` placement (`… not to contain 'credential-position-sentinel-20590'`) | | A2 | the region walk | 9 of 15: every nested placement, for both kinds | | A3 | serving the cleared form as written | 1 of 15 | | A4 | the list branch swallowing the protocol throw | 4 of 6: `expected '{"success":true,"data":{"type":"flow"…' not to contain 'stored-hook-secret-20590'`, and the datasource password | | A5 | keeping every graft (no position check) | 3 of 33 in `metadata-protocol`: `expected [ 'inbound_hook', …(17) ] to not include 'stored-hook-secret-20552'`. In `runtime`, which reads `metadata-protocol` from `dist/`, the PUT pin went red once the mutation was rebuilt and `ablation-dist-preflight` found the marker in 2 built files. On restore, the package was rebuilt, the marker was absent from all 24 built files, the tree was clean, and 8 of 8 passed. | | A6 | skipping an id-less element (no anchor) | 3 of 33: `expected undefined to be 'stored-signing-secret-20590'` | A first run of the `dist` leg of A5 used a mutation that failed the package's DTS step (unused locals). Its JS bundles carried the mutation, but its preflight never ran. It was rerun with a mutation that type-checks, and the numbers above are from that rerun. ## Deviations - **Three test files outside the claim's named file surface**, each a test of a door this PR changes: - `runtime/src/domains/automation-flow-credential-projection.test.ts`, the automation-plane half of position 3; - `runtime/src/domains/meta-list-read-gate-parity.test.ts`, whose double modelled "unknown type" as a throw; - the new `runtime/src/domains/meta-list-protocol-fault.test.ts`. - **No change reaches a published package's `exports`.** The new constants in `flow-credential-projection.ts` are not re-exported from `@objectstack/service-automation`'s entry. `carryForwardRedactedValues` keeps its signature; its behaviour changes as described. `Clause-②: no` stands as claimed. ## Acceptance notes - **Position 4 measurement: none, as specified.** Nothing in this repository composes a `/meta`-serving host without the automation capability *over a store shared with one that loads it*. - Searched: the `requires` of every example and dogfood fixture, where every stack declaring flows requires `automation`. - Searched: `os serve`'s always-on slate, where `automation` is not on it and loads only by `requires`, and its presets. - Searched: the CLI commands that boot their own kernel. `os verify` boots in memory, and `os meta` goes through HTTP. - Searched: the one store-sharing seam, `bootStack`'s `databaseFile`, where all 4 in-repo uses pass `automation: true`. - **The plugin-absent half does exist.** `@objectstack/verify`'s `bootStack` loads the automation capability only when `automation: true` is passed (default `false`), whatever the stack's `requires` says. `os verify` boots it that way. - On `bootStack(showcase)` without the flag, the `flow` redactor was absent from the registry, `/automation/*` answered 501, and a member's `/meta/flow` read served an authored `api` flow's start-node secret. - Its store is private (in memory, seeded from the app's own source), so no secret live elsewhere is served there. Recorded for the seat, which owns the position-4 route. - **A second open-map position, same family, not covered here.** - `HttpConfigSchema.headers` is an open string map, and so is `connectorConfig.input`. A static credential typed into one is served with the definition, because it is marked by a header or parameter name, not by a declared key. - The enumeration pin covers declared keys only, and no shipped example authors one. - Named in the report for the seat. It is not filed here, per the family's fold rule. - **Remaining fall-throughs on the dispatcher list.** A protocol slot with no list verb, or a protocol answering no list, still reaches the metadata service's list without per-type redaction. The one in-repo protocol always answers a list, so this is dormant. - **Observations, not filed (no credential served):** - The dispatcher's item read swallows a protocol fault, then falls to a `getItem` that no in-repo metadata service implements, so a fault there reads as 404. - The dispatcher's `/published` read swallows a layered-read fault and serves the code-layer snapshot, which objectstack-ai#20585 made redacting. - **Boundary:** a plugin-registered node kind that declares a credential key is outside the table. The enumeration reads builtin and spec-declared contracts. ## Patch round 1 (the seat's append; the dev writes a body only once) - **R1**, the at-tier record `5886643746`: a node moved across regions no longer loses its credential. - When the stored path to the credential's container does not resolve in the incoming body, `carryForwardRedactedValues` finds the owning element by its `id` across the whole incoming body. It uses that element only on exactly one match, then lets the existing position check decide where the value lands. - The pins were committed red on `fee1d6b9` as `0661a9a0`, with 3 failed. The fix and the changeset sentence are `5116194e`. - **The pins:** a node moved out of a `loop` body, and a node moved into a `parallel` branch, each kept, both directly and through the save door. A node moved and changed in kind is dropped. An `id` duplicated across regions grafts nothing. - **The ablation** removed the by-id fallback. Exactly the 3 keep-pins went red, and the restore was proven. - The earlier test titled as a cross-region move is retitled to what it asserts. - A datasource path holds no identified element, so it is unaffected. - **Also in this round:** two stored paths that land on one incoming position carry neither, instead of one silently overwriting the other. With a flow's single id space this cannot be reached, and it errs on the side of not carrying. - **R2** (measured only): the inline `http` arm, and the durable arm's no-outbox fallback, send the request unsigned. Filed as objectstack-ai#20628. Not changed here. - **At `5116194e`:** `metadata-protocol` passed 2780, with 19 skipped. `runtime`'s pins for these doors passed 14. `typecheck` exit 0. `dispatch-gates --commands` derived 64 commands, all 64 exit 0, and `--ran` reconciles 64 / 64 / 0 / 0. ## Patch round 2 (the seat's append) - **N1**, the at-tier record `5888558573`: the relocation's match set is scoped to where the owner stood. - It counts only elements of arrays held under the same key as the owner's own array in the stored path. That key is read from the stored hops and never named in code. For a flow it is `nodes`, at the top level or in any region. - An edge, or a config value that carries the same `id`, no longer blocks the relocation. Uniqueness is still required within the scoped set. - An owner whose array sits directly inside another array has no key and is not relocated. No registered redactor produces such a path. - The pins were committed red on `5116194e` as `2b7e04d3`: the edge-twin case, directly and through the save door, 2 failed. The fix is `60a5f765`. - **Ablation:** putting back the whole-body match turned exactly the 2 edge-twin pins red. The restore was proven. - **Changeset:** the "Moving a node" sentence now states the condition the code enforces. - **At `60a5f765`:** `metadata-protocol` passed 2783, with 19 skipped. `runtime`'s pins for these doors passed 14. `typecheck` exit 0. After a full build, `dispatch-gates --commands` derived 64 commands, all 64 exit 0, and `--ran` reconciles 64 / 64 / 0 / 0. --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20552
Clause-②: yes (widening)
What this changes
An
apiflow'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.redactFlowCredentials(packages/services/service-automation/src/flow-credential-projection.ts) removesconfig.secretfrom everystartnode and nothing else. The automation plugin registers it atinitas theflowentry of the existing per-type read-path redactor registry (@objectstack/spec/kernel). That is the seam the datasource credential fix ([security] datasource credential in a nested config position is served in cleartext on read — redaction is top-level-key-only #13405's class) already uses, so no second redaction dialect exists. The key is dropped, not masked. A mask is a non-blank string thatvalidateApiTriggerSecretwould 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.MetadataManager.getPublished, the body both/publisheddoors serve when no runtime overlay exists. It now applies the type's registered redactor too, which also covers the built-in datasource redactor.servedFlowDefinition: the definition read, thePOST /andPUT /:namewrite answers (automation.create / automation.update response contracts — consumer-survey first, then back to the decision inbox (the un-ruled half of the SDK route-contract card) #12206: a write answers what the read serves), and the clone answer. That function applies the same registry entry throughredactMetadataItem('flow', …), so the two planes cannot disagree about what is withheld.automationService.getFlowitself 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.carryForwardRedactedValuesis the one inverse on both planes: the metadata save door, and now the automationPUT /:nameandPOST /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'snodes) by the stored element'sid, not by its index. An edit that reordersnodestherefore still carries the secret back onto the start node, not onto whichever node now sits at the old position. An element with noid, or one whoseidis shared with a sibling, is never carried into.kernel:readyand on everymetadata:reloaded, which covers every Studio publish. It read the servedgetMetaItems, which no longer holds the secret.ObjectStackProtocolImplementationtherefore gainsgetMetaItemsForExecution: the same body, sources and merge asgetMetaItems, 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
config, and nothing projected it anywhere.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, at25362c12'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.isolatedposture. A member of another organization reads the same definitions. Flows are environment-wide metadata (allowOrgOverride: false, ADR-0005), and an organization administrator withoutmanage_metadatais 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.getMetaItemsForExecutionis the fix.registerMetadataTypeRedactor), its generic inverse (carryForwardRedactedValues) and its drop-not-mask posture are all reused. The one extension is the array hop above.nav_flowsentry iscomponentRef: 'metadata:resource'withtype: '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.tscovers 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 bindingconfigasserted is the objecttrigger-api'sstart()reads the HMAC secret from.metadata-protocol/src/protocol.metadata-redaction.test.tscovers 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.tscovers the four automation exits for a member-level caller, and thePUT/POSTround trip with a rotation.metadata/src/metadata-service.test.tscoversgetPublished.Package suites:
metadata828 passed;metadata-protocol2765 passed, 19 skipped;service-automation1851 passed;runtime(unit project) 4190 passed, 1 skipped.typecheckis green on all four. The 65 gate commandsdispatch-gates.mjs --commandsderives from this diff all exit 0 at25362c12.eslint --no-inline-configover the 16 changed source files reports 0 findings. The config lints every*.tsoutside 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 andgit diff HEADempty". Each subject is loaded from the package's ownsrc, so nodistwas involved.servedFlowDefinitionreturns the flow unredactedPUTanswer.expected '{"success":true,"data":{"name":"inbou…' not to contain 'stored-hook-secret-20552'redactFlowCredentialswithholds nothingexpected [] to deeply equal [ 'nodes.1.config.secret' ]; the served-face controlexpected '{"items":[{"name":"inbound_hook","lab…' not to contain 'stored-hook-secret-20552'getMetaItemsexpected undefined to be 'stored-hook-secret-20552'expected [ 'flow', 'inbound_hook', …(14) ] to not include 'stored-hook-secret-20552', and the datasource list pinexpected 'hunter2' to be undefinedgetPublishedreturns the body unredactedexpected '{"name":"inbound_hook","label":"Inbou…' not to contain 'stored-hook-secret-20552'expected undefined to be 'stored-hook-secret-20552'Deviations
ObjectStackProtocolImplementation.getMetaItemsForExecution(@objectstack/metadata-protocol) is reachable from the package entry. TheIAutomationServiceandObjectStackProtocolcontracts inpackages/specare untouched, and no key is added to any wire payload.Clause-②: nois copied from the claim as dispatched. The seat may correct it toyes (widening), in which case@objectstack/metadata-protocolmoves tominorin the changeset.packages/metadatais touched (getPublished). The claim's file surface names "packages/metadata*orpackages/rest" for the metadata-plane read, so this is inside it.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
apiflows only once a secret is set again, and until then they are refused at registration, loudly.secretfrom 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.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)Clause-②line above was corrected fromnotoyes (widening)by the seat, not by the dev.getMetaItemsForExecutionis a new public method onObjectStackProtocolImplementation, which@objectstack/metadata-protocolexports from its entry. The claim was corrected in place in the same act. The changeset follows in patch round 1:@objectstack/metadata-protocolmoves tominor, and the changeset carries the same line.Seat append — patch round 2 (the dev's text, appended by the
domain:servicesseat)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.tsseed a registry-only (code-authored)apiflow with nosys_metadatarow. 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 at004f70bdand 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,carryForwardRedactedCredentialsnow compares the incoming body with the code layer the read served, throughreadCodeLayerForCarryForward. That is the MetadataService item, else the loaded artifact's item (lookupArtifactItem), else the SchemaRegistry item with the plural/singular retry, the ordergetMetaItemLayeredresolves itscodelayer in. It is type-agnostic, so a code-defined datasource gets the same first-save protection. There is still notry/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.mjson the committed tree: the line becamestored?.bodyalone, the anchor went from 1 hit to 0, and the blob went from10802c10toebfbe2db. 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 andgit diff HEADempty.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 (
flowis not overlay-allowed for an artifact-backed item), so this path is reached whenOS_METADATA_WRITABLEunlocksflow. 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 to02b73b05; the one later commit is the changeset sentence).metadata-protocol: 2768 passed, 19 skipped.service-automation: 1851 passed.typecheckis green on both. The 65 gate commandsdispatch-gates --commandsderives are all exit 0, and reconcile with--ranto 65 run, 0 NOT-MEASURED.origin/mainwas merged first (dc1e281f).Generated by Claude Code