Skip to content

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

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-20552-flow-hook-secret-read-projection
Sep 29, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-20552-flow-hook-secret-read-projection

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

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 ([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 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 (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 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

…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>
@github-actions

github-actions Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/metadata-protocol, @objectstack/metadata, @objectstack/runtime, @objectstack/service-automation, touching 35 documentable anchor(s).

13 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via getBookTree (sdk, the bare tail of client method meta.getBookTree, bound to GET /api/v1/meta/book/:name/tree), meta.getBookTree (sdk, the route ledger binds it to GET /api/v1/meta/book/:name/tree, selected by route anchor /book/:name/tree))
  • content/docs/automation/flows.mdx (via AutomationServicePlugin (symbol, a top-level class))
  • content/docs/concepts/metadata-lifecycle.mdx (via MetadataManager (symbol, a top-level class), ObjectStackProtocolImplementation (symbol, a top-level class), getMetaItems (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/deployment/environment-variables.mdx (via getMetaItems (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/kernel/cluster.mdx (via MetadataManager (symbol, a top-level class))
  • content/docs/kernel/contracts/metadata-service.mdx (via getPublished (symbol, a method of class MetadataManager), getPublished (sdk, the bare tail of client method meta.getPublished, bound to GET /api/v1/meta/:type/:name/published; the bare tail of client method meta.getPublished, bound to GET /meta/:type/:name/published))
  • content/docs/kernel/services-checklist.mdx (via getMetaItems (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/permissions/system-context.mdx (via handleAutomationRequest (symbol, a top-level function))
  • content/docs/plugins/adding-a-metadata-type.mdx (via MetadataManager (symbol, a top-level class))
  • content/docs/protocol/kernel/metadata-service.mdx (via MetadataManager (symbol, a top-level class))
  • content/docs/ui/apps.mdx (via /book/:name/tree (route, bridged from symbol getMetaItems — its route source's handler names it))
  • content/docs/ui/forms.mdx (via /forms/:slug (route, bridged from symbol getMetaItems — its route source's handler names it), /forms/:slug/lookup/:field (route, bridged from symbol getMetaItems — its route source's handler names it))
  • content/docs/ui/public-data-collection.mdx (via /forms/:slug (route, bridged from symbol getMetaItems — its route source's handler names it))

⛔ 5 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via MetadataManager (symbol, a top-level class))
  • content/docs/releases/v14.mdx (via /book/:name/tree (route, bridged from symbol getMetaItems — its route source's handler names it))
  • content/docs/releases/v16.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via MetadataManager (symbol, a top-level class), ObjectStackProtocolImplementation (symbol, a top-level class))
  • content/docs/releases/v17/17-3.mdx (via /meta/:type/:name/published (route, a path literal in a comment in MetadataManager))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 36 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json e666636fd99a363d76753f5455a5708b9c9876cb → packageMentionDocs.

Which tree this was computed on

This run read content/docs from d5f818ad30118f420785de6128be50fe9355ce10 — the merge of head ce8475eab9b68d1e80e8ff77b9d7249cd27d6748 into base e666636fd99a363d76753f5455a5708b9c9876cb, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs e666636fd99a363d76753f5455a5708b9c9876cb → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: c1a3b366bee61dbe84c350e5754a76c34cb0e839
Local-runs: none

Inputs: card #20552 (body, triage 5882728818, claim 5883417172, dev reports 5884217361 and 5884270830), PR #20585 (body, 17-file list, net diff origin/main...head at base c876a742, merge-base 1c761c0d), and the 31 check-runs on the head. Read-only: the shared checkout's git objects, the REST reads, nothing built or run.

Check-runs on the head, as read: success — filter, Auto Label, Check Changeset, Check PR Size, Check Documentation Links, Flag docs affected by code changes, Governed Surface Queue Guard, the single-writer-path guard, the same-issue guard, the Part-of guard, the card-claims-this-branch guard. skipped — Console Pin Gate, Build Docs, Packed-tarball smoke (opt-in). in_progress — Build Core, Lint and Repo Gates, Type Check (workspace, source gates, debt ledger, consumer gates), Test Core 1–6, Dogfood Regression Gate 1–3, Dogfood Verify CLI, Temporal Conformance. The seat checks convergence before landing; the verdict below judges ①②③ and does not wait on them.

① Derived judgments

Each accept-set or public-surface change the diff implies, named right or wrong.

  1. ObjectStackProtocolImplementation.getMetaItemsForExecution — a new public method on an entry-exported class of @objectstack/metadata-protocol. RIGHT. getMetaItems is refactored into a private readFlattenedMetaItems(request, audience) whose only audience-dependent line is the final decorateMetadataItems application; every source, merge, gate and filter above it is shared, so the served and executed lists cannot disagree about which items exist. The served face is byte-identical to before. The real protocol service is the implementation instance itself (assembleMetadataProtocol registers new ObjectStackProtocolImplementation(...)), so the method is reachable at runtime, not only in the fakes. Its docblock forbids any door from calling it; a mechanical guard for that rule does not exist yet (see ③).

  2. The automation plugin binds and re-binds flows and connectors from the execution face, and warns then returns null when the protocol service lacks it. RIGHT. The A3 falsification is real: with the flow redactor registered, a binder reading getMetaItems would register every api flow without its secret and validateApiTriggerSecret would refuse it. null is already the documented "could not read, do not tear down" answer, so an older protocol double degrades to the pre-fix(automation): bind flow triggers on cold boot, not just after HMR reload #2560 behaviour with a warn, not a silent unbind. trigger-api reads the secret from binding.config only, so verification keeps reading the stored value. stripReadDecorations is still applied to each execution-face entry (plugin.ts) — now inert, harmless.

  3. redactFlowCredentials registered as the flow entry of the spec kernel redactor registry at plugin init; the key is dropped, not masked. RIGHT, with one registry caveat. Drop-not-mask is the correct posture here: a mask is a non-blank string the registration gate accepts as the HMAC secret. The new module is NOT re-exported from packages/services/service-automation/src/index.ts (verified at the head), so it widens no published surface. The caveat: the registry's own header prefers a spec BUILT-IN for a type whose sys_metadata rows can exist without the owning plugin, and os serve loads AutomationServicePlugin as the automation capability provider (CAPABILITY_PROVIDERS in cli/src/commands/serve.ts), not unconditionally. The module header answers that the credential is live only where a hook is armed, and the key is not spec-declared (packages/spec/src/automation declares only the outbound webhook.secret and the http node's signingSecret), so Prime Directive ✨ Set up Copilot instructions #2 blocks a spec built-in today. Accepted; the residual — a /meta-serving composition without the automation capability sharing a sys_metadata store with one that arms the hook — goes to the family closeout (③ F3).

  4. Every metadata-plane read exit withholds the secret; MetadataManager.getPublished now applies the type's registered redactor. RIGHT. getPublished has exactly two callers at the head, both /published doors (rest-server.ts, runtime/src/domains/meta.ts); no in-process executor reads it, so the change breaks no binder. The plural-spelling fold to the singular registry key is in place and pinned. Not caught on a throwing redactor — the same position redactMetadataItem takes.

  5. The automation domain's four definition exits (GET /:name, the POST / and PUT /:name answers, the clone answer) serve redactMetadataItem('flow', …); getFlow stays raw. RIGHT. I enumerated every deps.success(…) in the domain at the head: the list exit answers bare names, the toggle answers { name, enabled }, the run exits answer run records; the four named exits are the complete set that answers with a definition. The raw getFlow consumers outside the engine are the approvals plugin (three in-process reads), flow-dispatch-status (existence only), the clone (whole-definition copy, ADR-0126 §7.1) and the MCP action summary, which reads flow.variables and screen-field specs only — none of them emits start-node config. Projecting at the exits rather than inside getFlow is therefore the right seam.

  6. carryForwardRedactedValues now resolves an array hop by the stored element's id (once, against the stored body), refusing a missing or duplicated id. RIGHT as far as it goes; the array walk is copy-on-write and the reorder, rotation, removed-container, missing-id and twin-id cases are pinned. But see ③ F2: the inverse selects its graft target by element IDENTITY while the projection selects what it withholds by node KIND, and those two predicates can disagree.

  7. The automation PUT /:name and POST / onto an existing name accept the projected body and graft the engine-held secret back (keepStoredFlowCredentials, comparing against the raw getFlow). RIGHT for this plane. The engine map is the source the read served from, so the first edit of a code-authored flow through this door carries the secret. The write doors sit behind manage_metadata (Automation flow write routes (POST/PUT/DELETE /api/v1/automation) lack the manage_metadata gate — a plain tenant edits/deletes flows for every organization on a walled shared-database deployment #10145), unchanged.

  8. The metadata-plane save door's round trip for a flow — saveMetaItem → carryForwardRedactedCredentials → carryForwardRedactedValues. WRONG on the dominant authoring shape, and this is the verdict's carrier. carryForwardRedactedCredentials (protocol.ts, unchanged by the diff but made load-bearing for flow by it) finds stored through the overlay repository only — repo.get(ref, { state }) with the documented draft→active fallback — and returns the incoming item untouched when no row exists. A code-authored api flow (the shipped reference: examples/app-showcase/src/automation/flows/index.ts authors its secret as a literal; ADR-0041's documented pattern is the same) has NO sys_metadata row until its first Studio save. So the FIRST save of the served (projected) body — the Studio designer's draft save and publish is this seam — persists an overlay row with no secret, and the save door has no api-secret gate (FlowSchema is spec; validateApiTriggerSecret lives in the engine), so the author is told the save succeeded. The overlay then wins over the packaged artifact in both merges (readFlattenedMetaItems: "an overlay row wins"; the boot pull's resolveFlowPrecedence, ADR-0005). On metadata:reloaded the re-sync's registerFlow throws, is logged at warn, and the previously armed body stays in the map (its name is still in freshNames), so the hook keeps verifying — until the next boot, when the boot pull registers the winning overlay, it is refused, and the hook is never armed. Persisted state and runtime state disagree while everything looks normal: exactly the class the card named ("watch that the read, edit, republish round-trip does not wipe it") and the degradation shape AGENTS.md grades at error. Before this diff the same save round-tripped the secret in cleartext (the leak), so this is a regression the diff introduces, not one it inherits. The PR's pins do not reach it: protocol.metadata-redaction.test.ts's save round trip seeds a sys_metadata row first (seedFlowRow), and the live line in the report does not say whether the edited flow was code-authored or plane-created. The datasource precedent carries the same seam limitation, but for datasources the code-authored shape is rare; for flows it is the norm. Remedy, abstractly: when no overlay row exists at either state, carryForwardRedactedCredentials must compare against the body the read actually served from — the artifact layer (lookupArtifactItem / the registry item) — and a pin must seed a registry-only api flow, save its projected body, and read the secret back from the persisted overlay.

  9. Wire-shape change: the served flow definition on both planes no longer carries config.secret on start nodes. RIGHT as a security fix; not an ADR-0087 event (no authorable key is retired — the key stays authorable, only the served projection drops it), so no disposition marker is owed and check-adr-0087-registration is green as read.

  10. Documentation. No hand-written doc changed; the drift comment lists 13 pages by symbol only. I checked the two that could state the mechanism (services-checklist.mdx names getMetaItems in a contract ledger row; automation/flows.mdx does not describe the bind source or the secret's readability). Nothing now lies. Advisory.

② Semver level

Changeset .changeset/20552-flow-hook-secret-read-projection.md: @objectstack/metadata-protocol: minor, @objectstack/service-automation: patch, @objectstack/metadata: patch, @objectstack/runtime: patch. RIGHT for what the diff publishes. The one entry-reachable widening is getMetaItemsForExecution on @objectstack/metadata-protocol (plus the compatible extension of the exported carryForwardRedactedValues), so minor there is correct and sufficient. service-automation adds a module that its barrel does not export — patch is right; had index.ts re-exported it, this line would read wrong. metadata and runtime change served behaviour of existing exits as a fix — patch. The body carries Clause-②: yes (widening) at the start of its own line, and the changeset carries the same line and names the widening in one paragraph; the seat's in-place correction of the claim and body is recorded in 5883417172 and the PR's seat append, and patch round 1 (5884270830) brought the changeset to the same reading. Non-breaking, so no ADR-0087 disposition is owed. Check Changeset is success on the head. The Clause-② line is judged consistent with the diff.

③ Boundary flags

Dev report 5884217361, deviations:

  • D1 — new public method, Clause-② correction offered. ANSWERED: right; corrected by the seat, the changeset follows, and I verified it is the only widening a consumer can import (① 1, ② above).
  • D2 — landing points in metadata-protocol, metadata, runtime/src/domains/automation.ts. ANSWERED: inside the claim's stated file surface (packages/metadata* covers metadata-protocol; the automation domain is named explicitly) and admitted cross-lane by triage 5882728818. Accepted.
  • D3 — projection at the domain's exits, not inside the engine's getFlow. ANSWERED: right; every raw getFlow consumer is in-process or emits no start-node config (① 5).
  • D4 — origin/main not merged, two disjoint commits behind. ANSWERED: CI runs the merge ref (the drift comment names merge commit 947ac28d), the PR reads mergeable. Accepted.

Dev report 5884217361, out_of_scope_findings:

  • OOS1 — http node signingSecret is a second credential position the projection does not cover. ANSWERED: correct reading; it is a spec-declared, documented authorable key. Not folded here (optional key, a missed carry-forward deletes it silently — a different round-trip risk). ESCALATED to the family closeout as a filed card, together with F2 and F3 below; the seat files it.
  • OOS2 — the generic data door serves the raw sys_metadata row, secret included, to an administrator. ANSWERED: evidence for the maintainer decision triage routed (a write-only secret seam, the [security] The webhook signing secret is stored in cleartext in sys_webhook.definition_json #7799 precedent). Noted for that carrier; it also bounds F2 (that principal already reads the raw row).
  • OOS3 — the runtime twin of the metadata list read falls back to the raw metadataService.list() when the protocol read throws, bypassing per-type redaction for every type. ANSWERED: this is a contract violation of the redaction invariant on a served exit, source-read; Prime Directive chore: version packages #10 wants it filed, not noted in acceptance notes. ESCALATED: the seat files it (with OOS1).

Patch round 1 (5884270830), deviations: commit made before the changeset family ran, push after green. ANSWERED: procedural ordering only; nothing left the worktree before the gates read the committed head. Accepted.

open_questions: none in either report.

Reviewer's own flags:

  • F1 — the metadata-plane first-overlay wipe (① 8). FAIL carrier. Fix in this PR: the artifact-layer fallback in carryForwardRedactedCredentials and its pin, as described in ① 8. The automation-domain half (① 7) already compares against the source the read served from and needs no change.
  • F2 — identity-versus-kind mismatch between the inverse and the projection. The inverse resolves the graft target by element id; the projection withholds by node kind. A writer holding manage_metadata can therefore, through an ordinary round trip, cause a stored credential to be carried into a position the next read serves. No new reach for that principal (OOS2 shows it reads the raw row already), and node-config schema strictness refuses the relocated key on the builtin kinds that declare a configSchema, so this is an invariant gap, not an escalation of privilege. Abstract by the disclosure rule; no recipe here. Remedy: after a graft, re-run the type's redactor over the result and drop any carried value whose path is no longer among redactedKeys. Goes with the family closeout card.
  • F3 — plugin-registered redactor versus the registry's built-in preference (① 3). Residual fail-open for a /meta-serving composition that does not load the automation capability while sharing a store with one that does. Goes with the family closeout card, alongside the "no door may call getMetaItemsForExecution" rule, which today is a docblock and would be one grep-shaped gate.

Implemented-by: claude/issue-20552-flow-hook-secret-read-projection
Reviewed-by: session_01XY5uCwTjZj7884yYtyur4H

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

…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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: ce8475eab9b68d1e80e8ff77b9d7249cd27d6748
Local-runs: none

Re-review of PR #20585 after patch round 2. The previous record on this PR is 5884438109 (head c1a3b366, FAIL, carried by F1). Inputs: card #20552 (body and every comment, now including the seat's ACCEPT 5884314685 and the dev's patch round 2 report 5884796251), PR #20585 (body with both seat appends, 17-file list, net diff origin/main...head at base e666636f), and the 41 check-runs on the head. Read-only: git objects and REST reads, nothing built or run.

What moved since c1a3b366, by blob identity: the branch merged origin/main (dc1e281f, one upstream commit, a dependency bump — it touches none of the PR's 17 files, and upstream did not touch protocol.ts or the redaction test between the two bases), then three commits touching exactly packages/metadata-protocol/src/protocol.ts (+46), protocol.metadata-redaction.test.ts (+82) and the changeset (one sentence). The other 14 PR files are byte-identical to the head I reviewed before, so the ① items that rest on them are carried forward below and marked as such. Net diff at this head: 17 files, +1276 / −91.

Check-runs on the head, as read: every run completed; no in_progress. success — Lint and Repo Gates, TypeScript Type Check (and its four legs), Test Core (and 1–6), Dogfood Regression Gate (and 1–3), Dogfood Verify CLI, Build Core, Temporal Conformance, Governed Surface Queue Guard, Check Changeset (twice), Check Documentation Links, Flag docs affected by code changes, Auto Label, Check PR Size, and the four claim/part-of/single-writer guards (twice each — the seat's body append re-triggered the body-reading workflows; the second runs agree with the first). skipped — Console Pin Gate, Build Docs, Packed-tarball smoke (opt-in), and one Auto Label and one Check PR Size leg of the re-trigger. All seven required contexts are green.

① Derived judgments

Carried forward, code byte-unchanged since 5884438109 (each was judged RIGHT there and nothing in this head touches it): (1) getMetaItemsForExecution and the private readFlattenedMetaItems(request, audience) split — the served face unchanged, the real protocol service is the implementation instance; (2) the automation plugin binding from the execution face with a warn and null when the method is absent; (3) redactFlowCredentials registered at plugin init, dropped not masked, not barrel-exported — the registry's built-in-versus-plugin caveat stays a family-closeout note; (4) MetadataManager.getPublished applying the type's redactor — its two callers are both /published doors; (5) the automation domain's four definition exits serving redactMetadataItem('flow', …) with getFlow left raw — the enumerated set of exits is complete and no raw getFlow consumer emits start-node config; (6) the id-resolved array hop in carryForwardRedactedValues; (7) the automation PUT / POST-onto-existing carry-forward against the engine map; (9) the served wire shape losing config.secret as a security fix with no ADR-0087 event; (10) no doc changed and none now lies. Note on (5)/(7): protocol.ts is the one changed source file and the automation domain is not in it.

Re-judged at this head:

  1. The metadata-plane save door's round trip for an item with no overlay row. Was WRONG at c1a3b366; RIGHT here. carryForwardRedactedCredentials now reads const body = stored?.body ?? await this.readCodeLayerForCarryForward(type, name, packageId). The new private helper resolves the code layer in the order getMetaItemLayered resolves its code layer: the MetadataService item (readItemFromMetadataService, ADR-0110 verdict kept), else the loaded artifact's item (lookupArtifactItem, preferred over the plain registry key so a hydrated overlay copy cannot pose as the code default), else registry.getItem with the plural/singular retry. I read getMetaItemLayered's block and getMetaItem's: the single-item served read resolves MetadataService then the plain registry key; with no overlay row the artifact and plain keys name the same object, so the body compared is the body served. The helper is type-agnostic — a code-defined datasource gets the same first-save protection, which the changeset now states in one sentence. It keeps the carry-forward's no-try/catch rule: a throwing MetadataService read fails the save, and a degraded read with nothing found anywhere fails it with the same 503 the read doors answer. Accept-set change implied: the first save of a brand-new runtime-only item of a redactor-registered type now fails 503 when the MetadataService is degraded and holds nothing, where it used to proceed — RIGHT: the alternative is persisting a body whose credential the save could not confirm, and the read doors already answer that state the same way. Pins: three new tests seed a registry-only api flow with no sys_metadata row, read it through the served item read, and save the projected body — a direct save with a node reorder, a draft save then publish, and an explicit rotation — asserting the persisted overlay row carries the stored (or rotated) secret. Red-first is recorded in the report (2 of 26 red on the unfixed door with expected undefined to be …, green with the fix, red again under the ablation that reduced the line to stored?.body, restore proven). The pins exercise the registry leg of the fallback (the stub engine has no metadata service and no loaded artifact); the MetadataService leg is covered by the dev's live composed run (below), the artifact leg by neither — noted, not blocking, since the artifact lookup is the same lookupArtifactItem every read exit already uses.

  2. The merge of origin/main (dc1e281f). RIGHT: a clean first-parent merge bringing one upstream commit; no PR file moved in it; the post-merge branch commits touch exactly the three files named above; the head's check-runs ran on this merged tree.

② Semver level

Unchanged levels: @objectstack/metadata-protocol: minor, @objectstack/service-automation: patch, @objectstack/metadata: patch, @objectstack/runtime: patch. Still RIGHT: the only entry-reachable widening is getMetaItemsForExecution (plus the compatible extension of the exported carryForwardRedactedValues); the new readCodeLayerForCarryForward is private, so the changeset adds a sentence, not a level. The added sentence states the new first-save behaviour for every redactor-registered type — the right place for a behaviour a patch-level consumer would otherwise meet unannounced. The PR body's line reads Clause-②: yes (widening) at the start of its own line and the changeset carries the same line; non-breaking, no ADR-0087 marker owed; Check Changeset is success on both of its runs on this head. The Clause-② line is judged consistent with the diff.

③ Boundary flags

F1 — 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 api flow dropped its secret into an overlay that then wins every merge. The fix compares against the code layer the read served, exactly the missing leg; the three pins cover the direct, draft-then-publish and rotation shapes on a registry-only item; the red-first run and the ablation show the pins are load-bearing; and the live run (composed showcase, one persistent store, two boots, hatch open) shows the first overlay persisted with its secret, the hook verifying in the first process, and the edited overlay armed and verifying after the restart with the original secret and a wrong one refused. That is the card's second pin ("an edit-and-republish keeps the hook verifiable with the original secret") held on the shape the previous record found it broken on. Closed.

The dev's reach deviation (patch round 2, deviation 2). ANSWERED, in two halves. The measurement is right as far as it goes: flow is allowOrgOverride: false (supportsOverlay: false, allowRuntimeCreate: true) in DEFAULT_METADATA_TYPE_REGISTRY, so isOverlayAllowed('flow') is false unless OS_METADATA_WRITABLE names it, and the save door's two-tier gate answers an artifact-backed item of such a type with NOT_OVERRIDABLE 403 whose text names that hatch. On the measured boot, the wipe was therefore reachable only with the hatch open, and my previous record's "dominant authoring shape" overstated its default-posture reach — corrected here. The other half, a source reading the dev's one topology cannot show: that NOT_OVERRIDABLE branch sits inside if (this.environmentId !== undefined), the ADR-0005 carve-out that keeps the overlay whitelist off single-kernel deployments (the #5086 note in the same block records that a host-config boot runs with no environmentId and that gate disengaged). On such a boot the default posture admits the overlay save and the pre-fix wipe needed no hatch. So the severity reads as measured on environment-scoped kernels and as my earlier reading on single-kernel ones; the fix is type- and topology-agnostic and closes both, so nothing turns on which reading a deployment falls under. Accepted.

Patch round 2, deviation 1 — the changeset gained one sentence, levels unchanged. ANSWERED: right, and required (② above). Accepted.

Patch round 2, open_questions: none. out_of_scope_findings: none.

Standing from 5884438109, as this head leaves them: F2 (the inverse grafts by element id while the projection withholds by node kind), F3 (plugin-registered redactor versus the registry's built-in preference for a type whose rows can exist without the plugin), OOS1 (the http node's signingSecret) and OOS3 (the runtime meta-list raw fallback) — the seat's ACCEPT 5884314685 files OOS1 and OOS3 as #20590, and the dev's report says F2 and F3 stay with #20590 as instructed. #20590 is outside this record's inputs and was not read; the filing is taken from the card's own comments. None of the four is blocking, and none moved on this head — the fix is confined to the save door's fallback. OOS2 (the generic data door serving the raw row to an administrator) stays in the acceptance notes as evidence for the maintainer's write-only-seam decision. The docblock rule that no door may call getMetaItemsForExecution is still prose only (F3's rider) — family closeout.

Implemented-by: claude/issue-20552-flow-hook-secret-read-projection
Reviewed-by: session_01XY5uCwTjZj7884yYtyur4H

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 29, 2026 06:27
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 29, 2026
Merged via the queue into main with commit c96beb2 Sep 29, 2026
46 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20552-flow-hook-secret-read-projection branch September 29, 2026 06:58
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants