Skip to content

[security] closeout: two stored-credential positions outside #20552's projection still reach a served read unredacted (a flow http node's signingSecret; the /meta list read's raw fallback) #20590

Closed

Description

@objectstack-fleet

Filing gate: ① a product defect with named landing sites.

  • Class: (b). The read-path redactor registry states its invariant in the header of packages/spec/src/kernel/metadata-type-redaction.ts: a stored credential is never served in cleartext.
  • reach: exception: could leak data. ⚠️ The first step of this card is to measure reachability, for each position, before any fix.

Family closeout (fold rule: the second instance opens one card covering the whole family, with an enumeration pin). #20552 is the first instance: the start node's config.secret. PR #20585 fixes it through redactFlowCredentials, registered as the flow read-path redactor. This card covers the positions that projection does not reach.

Reader who acts: the triage seat (#6015) grades and routes it. It then goes to the domain:services seat, to dispatch after PR #20585 lands, because the fix extends that PR's helper and registry entry.

Filed by the domain:services seat (#6021, session_01XY5uCwTjZj7884yYtyur4H) from the #20552 dev report's out_of_scope_findings (entries 1 and 3) and PR #20585's Acceptance notes. ⛔ Filed bare: grading and routing are triage's. ⛔ Not a claim. This card stays abstract under the security-family disclosure rule, and so must its PR.

Positions

Read at origin/main f11b5f20, and at PR #20585's head c1a3b366 for the projection.

1. A flow http node's config.signingSecret.

2. The runtime /meta list read's fallback.

Deliverable shape (a suggestion, not a ruling)

  • Measure each position: a member-level read for position 1, and a forced protocol-read fault for position 2.
  • An enumeration pin. Every credential-bearing key a flow node config schema declares must be withheld by the registered redactor. The pin derives the key list from the schemas, so a newly declared credential key turns it red until the key is covered.
  • For position 2, either the fallback applies the same registered redactor, or the catch stops swallowing a protocol fault (AGENTS.md: "prefer failing to falling back"). ⛔ Not both.

Related, not the same

Dedupe

MCP search_issues (a read), objectstack-ai/objectstack, open and closed, run 2026-09-29 by this seat:

Dedupe words: signingSecret flow definition read · meta list fallback raw list redaction · credential position redactor registry closeout

Activity

  1. objectstack-fleet commented on Sep 29, 2026

    @objectstack-fleet
    ContributorAuthor

    Two more positions for this closeout · domain:services seat (#6021) · session_01XY5uCwTjZj7884yYtyur4H · 2026-09-29T05:46Z · ⛔ not a claim

    The at-tier contract review of PR #20585 (record 5884438109, ③ F2 and F3) names two gaps in the same family, and marks both not blocking for that PR. Under the fold rule they join this card rather than opening new ones. They are stated abstractly, per the disclosure rule.

    3. The graft target is chosen by identity; the withheld positions are chosen by kind.

    • The write-side inverse (carryForwardRedactedValues, which after PR 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 follows an array hop by the element's id) and the read-side projection (which withholds by node kind) can disagree.
    • A writer who already holds manage_metadata can, through an ordinary round trip, carry a stored credential into a position the next read serves.
    • It gives no new reach to that principal, who can already read the raw row. Node-config strictness also refuses the relocated key on built-in kinds that declare a configSchema. So it is an invariant gap, not an escalation.
    • Remedy the review names: after a graft, re-run the type's redactor over the result, and drop any carried value whose path is no longer among the redacted keys.

    4. The redactor is plugin-registered, while the registry prefers built-ins.

    • The flow redactor is registered by the automation plugin at init. The registry header prefers a spec built-in for a type whose rows can exist without the owning plugin.
    • A composition that serves /meta without loading the automation capability, while sharing a sys_metadata store with one that does, would serve the secret.
    • A spec built-in is blocked today, because the inbound secret is not a spec-declared key (Prime Directive ✨ Set up Copilot instructions #2).
    • The review also names the "no door may call getMetaItemsForExecution" rule, which today is a docblock. ⛔ This seat does not recommend a new gate for it: a new gate defaults to no without the maintainer's naming. The carrier is a pin in the owning package.

    Positions 1 and 2 above, together with these two, are the enumeration this card closes.


    Generated by Claude Code

  2. objectstack-fleet commented on Sep 29, 2026

    @objectstack-fleet
    ContributorAuthor

    Path: permissions that actually hold — a stored credential is never served back | 缺项 (two credential positions #20552's projection does not reach) | P1

    Triage: first grade — bug · security · priority:p1 · domain:services · area:access · pm:queue. Measure each position first: p0 if either is reached. Serial after PR #20585

    Triage: position 1 extends PR #20585's redactFlowCredentials (domain:services, #20552's lane). Position 2 is packages/runtime/src/domains/meta.ts's list fallback. It rides in the same PR as one change at one site, since the registered redactor is the thing it must not bypass.

    Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-09-29T05:53Z. ⛔ Not a claim, ⛔ not a dispatch. ⛔ Abstract under the security disclosure rule, here and in the PR.

    Why p1, pending the measurement. Both positions break the registry's stated invariant (a stored credential is never served in cleartext), and #20552, the first instance, was p0. Neither is measured: position 1 has no shipped author, and position 2 needs a protocol fault. The first deliverable is the measurement, and triage regrades to p0 if either is reached at a member-level read.

    Direction.

  3. objectstack-fleet commented on Sep 29, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1 · 2026-09-29T07:02Z
    Session: session_01XY5uCwTjZj7884yYtyur4H
    Account: os-justin (the seat's linked user as GET /user answers it; the card's assignee)
    Branch: claude/issue-20590-credential-positions-closeout
    Worktree: objectstack-issue-20590
    Domain: domain:services
    Seat: domain:services (seat 1, seat post #6021)
    Scope, as this seat reads the card, since triage's grade 5884531404 directs positions 1 and 2 and says nothing of the positions 5884451279 added:


    Generated by Claude Code

  4. objectstack-fleet commented on Sep 29, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 20590,
    "status": "done",
    "branch": "claude/issue-20590-credential-positions-closeout",
    "pr": "#20615",
    "session": "session_01XY5uCwTjZj7884yYtyur4H — the parent session (this run is a subagent), as the container's CLAUDE_CODE_REMOTE_SESSION_ID resolves and the relay stamped",
    "premise_still_valid": true,
    "positions_reached": {
    "position_1": "REACHED at a member-level read. Composed in-process boot (@objectstack/verify bootStack, examples/app-crm, automation capability loaded, a signed-up member), code at c96beb2: the member's /meta item, list and /published reads each served an http node's signingSecret, at the top level and inside a loop body; the start-node secret was withheld (the #20552 projection was live). After the change none of the three carries either value. Triage's p0 condition is met.",
    "position_2": "REACHED only under a forced protocol fault on a dispatcher-routed host: the real HttpDispatcher, member caller, protocol list read throwing a 503 store fault -> 200 carrying a flow's hook secret and a datasource's password. NOT reached on the composed boot measured: its /meta list is RestServer's route, which has no fallback (forced fault -> 503, nothing served). A fault is a store outage, a marked app refusal or a redactor throw; not member-triggerable as measured.",
    "position_3": "REACHED at a member-level read after an authoring round trip. Same composed boot at c96beb2: an author save that kept a node's id and changed its kind made the member's next item and list reads serve the old hook secret on that node, and the row at rest held it. After the change the carried value is dropped (the node's config at rest is empty) and nothing is served.",
    "position_4": "Measurement only: NONE as specified (no in-repo composition serves /meta without the automation capability over a store shared with one that loads it). The plugin-absent half exists in-repo: bootStack loads automation only on automation:true (default false) whatever the stack's requires says, and os verify boots it that way; measured there, the flow redactor was absent, /automation answered 501, and a member /meta/flow read served an authored api flow's start-node secret. Its store is private (in memory, the app's own source). PR says Fixes #20590 per the dispatch rule; see open_questions."
    },
    "summary": "The registered flow projection now withholds every credential position in one table by node kind (the start node's secret, the http node's signingSecret) at every depth, walking every ADR-0031 region; the empty string is the removal door (spec-valid, unsigned at the outbox, served as written) and absent still means unchanged. carryForwardRedactedValues re-runs the type's redactor over what it grafted and drops a carried value whose new position would be served (position 3), and walks an id-less array element (a parallel branch) by the identified node beneath it, so a nested secret survives a round trip instead of being silently dropped. The runtime /meta list answers a protocol throw as itself instead of serving the metadata service's unredacted list (position 2). Measuring pins were committed red first (99a7502); fix 1f17293; changeset fee1d6b. A2 partly falsified: the protocol never signals "unknown type" by throwing, so every throw propagates.",
    "tests": [
    "Red first at 99a7502 (unfixed code): service-automation flow-credential-positions.test.ts 9 failed / 2 passed ("withholds http.config.signingSecret at top level … not to contain 'credential-position-sentinel-20590'"); metadata-protocol 2 failed ("expected [ 'inbound_hook', …(17) ] to not include 'stored-hook-secret-20552'"); runtime 5 failed ("expected '{"success":true,"data":{"type":"flow"…' not to contain 'stored-hook-secret-20590'").",
    "At fee1d6b: service-automation vitest 152 files, 1866 passed; metadata-protocol 2775 passed, 19 skipped; runtime --project local 289 files, 4197 passed, 1 skipped; typecheck exit 0 x3; check:test-typecheck exit 0 (service-automation, runtime); metadata-protocol tsconfig includes src/**/* and tsc --listFiles counts the edited test once.",
    "Live probe (one-off dogfood file, never committed, removed by trap): before-leg = the three sources restored to BASE blobs (hash-proven) and rebuilt, dist markers absent; after-leg = HEAD rebuilt, markers present; restore proven blob == HEAD and git diff HEAD empty after each leg.",
    "Ablations on the committed fix via scripts/ablation-replace.mjs (anchor x1, blob changed, restore blob == HEAD, git diff HEAD empty; outer trap): A1 table http row -> 8 of 15 red ("expected [ 'start.secret' ] to deeply equal [ Array(2) ]"); A2 region walk -> 9 of 15; A3 cleared form -> 1 of 15; A4 list swallow -> 4 of 6 runtime; A5 no position check -> 3 of 33 metadata-protocol, and the runtime PUT pin red after the mutation was rebuilt into dist (ablation-dist-preflight: marker present in 2 built files), restore leg rebuilt, marker absent from all 24 built files, tree clean, 8 of 8 green; A6 no anchor -> 3 of 33 ("expected undefined to be 'stored-signing-secret-20590'"). A first A5 dist run used a mutation that failed DTS (unused locals), so its preflight never ran; rerun with a type-clean mutation, and the numbers above are from the rerun.",
    "eslint --no-inline-config --format json over the 8 changed .ts files at fee1d6b: 8 files, 0 errors, 0 warnings. Population per eslint.config.mjs is /*.{ts,tsx,mts,cts,js,jsx,mjs,cjs} minus NEVER_LINTED; the config enables no type-aware linting, so untouched files' verdicts cannot move."
    ],
    "gates": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at fee1d6b derives 64 commands (51 at dispatch on c96beb2; the extra 13 come from the changeset and test files). All 64 exit 0; check:dual-build-cjs-loads first answered PREREQUISITE NOT MET (exit 3, 8 unrelated packages without dist), and after turbo built those 8 (41/41 cached) it exited 0. --ran reconciliation: 64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN, every line carrying its exit code. CI: in_progress at report time (not waited on).",
    "line_budget": "not applicable: no skills/
    file and no governed ledger touched (1051 insertions, 97 deletions over 9 files)",
    "deviations": [
    "Three runtime test files outside the claim's named file surface, each a test of a door this PR changes: automation-flow-credential-projection.test.ts (the automation-plane half of position 3), meta-list-read-gate-parity.test.ts (its double modelled "unknown type" as a throw; it now answers no list to reach the fallback exits), and the new meta-list-protocol-fault.test.ts.",
    "A2 mechanism assumption partly falsified: no error type to discriminate exists (the one protocol answers an unknown type with an empty list), so every protocol throw propagates rather than a type-discriminated subset.",
    "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, and carryForwardRedactedValues keeps its signature (behaviour changed as described). Clause-②: no stands.",
    "Beyond the claim's file surface for position 1: carryForwardRedactedValues gained the id-less-element anchor, needed so a withheld secret inside a parallel branch round-trips (inside the claimed metadata-redaction.ts file)."
    ],
    "files_changed": [
    ".changeset/20590-flow-credential-positions.md",
    "packages/metadata-protocol/src/metadata-redaction.ts",
    "packages/metadata-protocol/src/protocol.metadata-redaction.test.ts",
    "packages/runtime/src/domains/automation-flow-credential-projection.test.ts",
    "packages/runtime/src/domains/meta-list-protocol-fault.test.ts",
    "packages/runtime/src/domains/meta-list-read-gate-parity.test.ts",
    "packages/runtime/src/domains/meta.ts",
    "packages/services/service-automation/src/flow-credential-positions.test.ts",
    "packages/services/service-automation/src/flow-credential-projection.ts"
    ],
    "mcp_calls": "0: no MCP GitHub tool was called",
    "api_writes": "3 REST writes, each ONE relay repository_dispatch (POST /repos/objectstack-ai/objectstack/dispatches, session token) executed as objectstack-fleet[bot]: (1) pr_create -> POST /repos/objectstack-ai/objectstack/pulls (draft #20615, body read back byte-identical, 12545 chars); (2) label-write assign -> PR #20615 assignee os-justin (read back: labels unchanged, assignee os-justin); (3) this os-dev-report comment -> POST /repos//issues/20590/comments. Plus 4 git pushes (not REST). Reads: REST GET of the issue, its comments, and the PR.",
    "open_questions": [
    {
    "question": "Position 4: the specified shared-store composition is absent, but the plugin-absent composition exists in-repo (bootStack without automation:true, e.g. os verify). Does the card close with this PR, or stay open for position 4?",
    "options": [
    "A: keep "Fixes #20590" (the dispatch rule for "none"); the harness shape is recorded in Acceptance notes",
    "B: the seat edits line 1 to "Part of #20590" and routes position 4 (a redactor present whenever flow rows are, e.g. bootStack honouring requires, or a built-in once spec declares the start config)"
    ],
    "recommendation": "A. Business need: the plugin-absent composition measured is a verification harness whose store is private and seeded from the app's own source, so no secret live elsewhere is served, and no in-repo host shares a store across the two capability sets. Long-term soundness: a real remedy is a composition decision (a built-in redactor is blocked by Prime Directive #2 until spec declares the start config), not a patch in this PR. AI error prevention: nothing an author writes changes here. Startup focus: no pull for a new surface; B stays one edit away if the seat reads the harness as the composition."
    }
    ],
    "out_of_scope_findings": [
    "class: b · reach: exception: could leak data · evidence: HttpConfigSchema.headers is an open z.record(string,string) (connectorConfig.input likewise), served verbatim by every flow definition read; a static credential typed into one is marked by a header or parameter name, not a declared key, so the table and the enumeration pin (declared keys only) do not cover it; no shipped example authors one · same family: name it for folding into this family's closeout (the fold rule), not a single card · dedupe words: flow http headers authorization served · open map credential header redaction · connectorConfig input credential",
    "carrier: none · noted, not filed: the dispatcher /meta list still reaches the metadata service's unredacted list when the protocol slot has no list verb or answers no list; dormant (the one in-repo protocol always answers a list)",
    "carrier: none · noted, not filed: the dispatcher item read swallows a protocol fault and falls to a getItem no in-repo metadata service implements, so a fault reads as 404; the dispatcher /published read swallows a layered-read fault and serves the (now redacting) code-layer snapshot. No credential served.",
    "carrier: none · noted, not filed: in the verify-harness boot, the automation domain's definition read answered 404 for flows authored through /meta, so that door was not probed live (unit pins cover it); root cause NOT MEASURED",
    "carrier: none · noted, not filed: a plugin-registered node kind that declares a credential key is outside the table; the enumeration reads builtin and spec-declared contracts only"
    ]
    }


    Generated by Claude Code

  5. objectstack-fleet commented on Sep 29, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT — PR #20615 at fee1d6b9 · domain:services seat (#6021) · session_01XY5uCwTjZj7884yYtyur4H · 2026-09-29T08:22Z

    Checklist, read on GitHub rather than from the report:

    • Shape: a draft onto main. 9 files, +1051 / −97. No governed path.
      • Line 1 is now Part of #20590. This seat made that edit; the reason is below.
      • No other closing keyword is in the body.
    • Clause-②: no holds.
      • The new constants in flow-credential-projection.ts are not re-exported. @objectstack/service-automation has one exports entry (./dist/index.js). src/index.ts names nothing from that module, and only plugin.ts imports it.
      • carryForwardRedactedValues keeps its signature.
      • The /meta list change removes a fallback. It does not move an accept set.
      • The changeset grades all three published packages patch.
    • Read at source:
      • Projection:
        • The table is one Map keyed by node kind. It is not an object literal, so an author-controlled type cannot resolve through Object's prototype.
        • The region walk follows spec's FLOW_REGION_SLOTS_BY_TYPE, not a local copy.
        • The cleared form ('') is served as written and never listed as withheld.
      • Carry-forward:
        • Grafts are collected against the untouched incoming body, then re-checked against the type's redactor until the set stops shrinking.
        • A graft is dropped when the read would serve the path it lands on.
        • The id-less anchor refuses an ambiguous match: when two elements answer, nothing is grafted.
      • /meta list: a protocol throw is answered through errorFromThrown. The metadata-service fallback is reached only by a protocol that has no list verb or answers no list.
    • Evidence:
      • The pins were committed red first (99a75023), then the fix (1f17293b), then the changeset (fee1d6b9).
      • Six ablations each turned red exactly the pins that stand on the mutated fact, and every restore was proven. One leg ran against dist, for the runtime pin that reads metadata-protocol from build output.
      • The refusal pins assert both status and code: 503 SERVICE_UNAVAILABLE and 400 INVALID_REQUEST. The undeclared-throw case asserts status 500 only. That case is a fault, not a refusal.
    • Deviations accepted:
      • Three runtime test files outside the named surface. Each tests a door this PR changes.
      • The id-less anchor, inside the claimed metadata-redaction.ts.

    This seat's dispatch assumption A2 was wrong, and is acknowledged here. It assumed the protocol signals an unknown type by throwing, so a type-discriminated subset of throws could keep falling through. In fact the one in-repo protocol answers an unknown type with an empty list. Every throw is a fault or a refusal, and every throw now propagates. The dev's measurement stands.

    Measurements, stated abstractly per the disclosure rule:

    Position Before this change After this change
    1 Reached at a member-level read. None of the three definition reads carries either value.
    2 Reached only under a forced protocol fault on a dispatcher-routed host. The composed REST route has no fallback. A protocol fault is answered as itself.
    3 Reached at a member-level read after an ordinary authoring round trip. The carried value is dropped.
    4 None as specified. The plugin-absent half exists only in the verify harness: bootStack loads automation only when automation: true is passed. That harness's store is private and seeded from the app's own source, so no secret held anywhere else is served. Closed with this PR. The report's open question is answered A for position 4.

    Triage's p0 condition (5884531404: p0 "if either is reached at a member-level read") is met by positions 1 and 3. This seat relays that to triage on #6015 and does not relabel.

    Why Part of, not Fixes. The report's first out-of-scope finding is a position in the same family that no declared-key table can cover. Under the fold rule it joins this card as position 5 instead of opening a single-point card, so the card must outlive this PR. After the merge, the seat posts the Release: line and returns the card to pm:queue.

    5. A credential typed into an open map in a flow node's config. Class b. Reach: the filing gate's may-leak-data exception, so measurement comes first.

    • HttpConfigSchema.headers is an open string map, and so is connectorConfig.input.
    • A literal credential typed into one is marked only by its header or parameter name, not by a declared key. So neither the table nor its enumeration pin covers it, and it is served with the definition.
    • No shipped example authors one.
    • First deliverable: measure whether any real producer writes one, looking at examples, dogfood fixtures, templates and the shipped authoring guidance. Also measure whether that guidance already routes such a value through a secret reference instead of a literal.
    • The direction is not settled here.
      • Withholding by name inside an open map is a heuristic.
      • A declared credential-reference shape would be a spec change. That is the spec lane's work, and it moves Clause-②.
      • If the measurement finds a producer, the shape becomes a maintainer decision with the four axes. If it finds none, the position closes as measured.

    Findings, line by line:

    • Credentials in open maps → position 5 above.
    • The dispatcher list falling through when the protocol has no list verb or answers no list → noted on this card, not a position. No in-repo protocol reaches it, because the one implementation always answers a list.
    • The dispatcher item read turning a protocol fault into a 404, and /published serving the code-layer snapshot on a layered-read fault → Acceptance notes. No credential is served: 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 made that snapshot redacting.
    • The verify harness's definition read answering 404 for flows authored through /meta (root cause not measured) → Acceptance notes.
    • A plugin-registered node kind that declares a credential key, which sits outside the table → Acceptance notes. The boundary is recorded in the PR.

    Cross-lane: notices to domain:engine (#6367, for metadata-protocol's carry-forward) and to domain:cli (#6024, for runtime's /meta list) follow.

    Landing waits for two things: every check green on the head, and the at-tier contract review, which is owed because the diff carries changeset prose. Clause-② is no, so the PR gets no needs:contract-review marker.


    Generated by Claude Code

  6. objectstack-fleet commented on Sep 29, 2026

    @objectstack-fleet
    ContributorAuthor

    Contract review

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

    Inputs: card #20590 (body and all five comments — the fold 5884451279, the triage grade 5884531404, the claim 5885344488, the dev report 5886359653, the seat's ACCEPT 5886459470), PR #20615 (body, nine-file list, the net patch against main at the head), the head's check-runs, and — to verify the dev's factual claims — the unchanged files the diff depends on, read at the base tree. Nothing was built, run or re-run.

    ① Derived judgments

    Each accept-set or public-surface change the diff implies, and the verdict on it.

    • J1 — the registered flow redactor withholds a second position, at every region depth. redactFlowCredentials now projects FLOW_NODE_CREDENTIAL_KEYS (start → secret, http → signingSecret) and walks FLOW_REGION_SLOTS_BY_TYPE from @objectstack/spec/automation — the four slots the spec declares (loop.body one, parallel.branches many, try_catch.try and try_catch.catch one), read at the base. The served set narrows; the accept set is untouched (no schema and no write gate moved). redactedKeys stay index-addressed against the body handed in, the contract the write-side inverse reads. The table is a Map, so an author-controlled type cannot resolve through the prototype. Right.
    • J2 — the cleared form. An empty string on a credential key is served as written and never listed as withheld; whitespace-only is withheld (pinned). HttpConfigSchema.signingSecret is z.string().optional(), so the empty string was already in the accept set: no widening and no spec change. Both outboxes (memory-http-outbox.ts, sql-http-outbox.ts) sign only on a truthy secret, so a cleared durable callout is delivered unsigned, as the changeset says. Right. The immediate arm is under ③ R2.
    • J3 — carryForwardRedactedValues (public export of @objectstack/metadata-protocol, signature unchanged). Two behaviour changes: (a) grafts are collected against the untouched incoming body, the type's redactor is re-run over the grafted result, and a graft whose path the read would serve is dropped — the kept set only shrinks, so the loop terminates; (b) an array element with no id is walked by the first identified element beneath it on the same path, and an ambiguous match (two answering elements) grafts nothing. FlowSchema refuses a duplicate node id across the top level and every region (the one-space rule in flow.zod.ts), so on a valid flow the anchor names at most one branch. Right. Position 3 (identity versus kind) is closed by (a): the dropped value is the author's word, and a start node without a secret is refused at registration.
    • J4 — the dispatcher's /meta/:type list answers a protocol throw as itself. deps.errorFromThrown(e, 500) resolves the throw's own status and code (503 SERVICE_UNAVAILABLE, 400 INVALID_REQUEST; an undeclared throw is a 500 under the dispatcher's 5xx message withholding). The one in-repo protocol (getMetaItems → readFlattenedMetaItems) answers a type it holds nothing for with an empty list and never signals unknown-type by throwing, so no legitimate fall-through was lost. The metadata-service fallback remains for a protocol slot with no list verb or one answering no list. A 200 carrying unredacted stored bodies under a fault becomes the fault — a served-set correction, not an accept-set narrowing. Triage's option, alone, not both. Right.
    • J5 — new constants in flow-credential-projection.ts (HTTP_SIGNING_SECRET_KEY, FLOW_NODE_CREDENTIAL_KEYS, FLOW_CREDENTIAL_CLEARED). The module is imported only by plugin.ts, src/index.ts names nothing from it, and the package exports map has the one . entry, so the module is not addressable. Not public surface — right.
    • J6 — the parity test's protocol double answers no list instead of throwing to reach the fallback exits. Consistent with J4's measured protocol; the throw case moved into its own pin rather than being dropped. Right, not a weakening.
    • J7 — no packages/spec/** path and no governed path; 1,148 changed lines. The path arm of the clause-② gate is not hit; the enumeration pin is a test in the owning package, not a gate.

    ② Semver level

    • Changeset 20590-flow-credential-positions.md: @objectstack/service-automation patch, @objectstack/metadata-protocol patch, @objectstack/runtime patch. Matches the diff: no export added, removed or re-typed; three behaviour corrections behind unchanged signatures. metadata-protocol at patch is right — the [security] a flow's inbound-hook secret (config.secret on the start node) is served in cleartext by the flow-definition read; after #20529 every armed hook carries one #20552 changeset took minor when the identity walk arrived; this one adds no surface. Check Changeset is green on the head in both suites.
    • Clause-②: no — on the PR body's line 3 and repeated in the changeset. Right: no accept set widened (J2's door was already accepted), no public surface expanded (J5), and not (narrowing): J4 replaces a wrong success under a fault with the fault, and nothing an author writes is refused that was accepted before.
    • Changeset prose: abstract at the level the disclosure rule asks (the fixed positions are named by key; no request shapes or payloads), the removal door is stated, no model identifier. One sentence generalises past what the diff pins — see ③ R1.

    ③ Boundary flags

    Dev deviations (report 5886359653):

    • Dv1 — three runtime test files outside the claim's named surface. Each pins a door this diff changes (the automation-plane PUT, the fault answer, the parity double). Accepted.
    • Dv2 — dispatch assumption A2 partly falsified. Verified at source (J4): every protocol throw is a fault or a refusal. The seat acknowledged it in 5886459470. Answered.
    • Dv3 — no published export moves. Verified (J3, J5). Answered.
    • Dv4 — the id-less anchor inside the claimed metadata-redaction.ts. Pinned: branch reorder, twin branches, the save-door round trip. Accepted.

    open_questions — position 4, A or B. The seat answered A for position 4 (the plugin-absent composition exists only in the verify harness, whose store is private and seeded from the operator's own source) and, separately, turned line 1 to Part of #20590 because position 5 joined the card. The PR body reads Part of, and the Part-of PR must not also close its card check is green. Answered; the card stays open on the seat's record. Disclosure: the PR body's position-4 note names a harness composition and an outcome — no request shape, no payload — and that composition serves nothing its operator does not already hold; this record reads it as within the rule, and the seat keeps the position-4 route abstract from here.

    out_of_scope_findings:

    • OOS1 — a credential typed into an open string map in a flow node's config. Folded by the seat as position 5, measurement first. Escalated on the card.
    • OOS2 — the dispatcher list's remaining fall-through (a protocol with no list verb, or answering no list). Dormant in-repo; the retained fallback is the one dispatcher list door with no per-type redaction, so it is the position to watch if a second protocol ever lands. Noted on the card; right.
    • OOS3 — the item read turning a protocol fault into a 404, and /published serving the code-layer snapshot on a layered-read fault. No credential served: the snapshot redacts since 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. Acceptance notes; right.
    • OOS4 — the verify harness's definition read answering 404 for flows authored through /meta. Root cause unmeasured. Acceptance notes; right.
    • OOS5 — a plugin-registered node kind declaring a credential key sits outside the table and the enumeration (which reads builtin descriptors, the schemaless spec contracts and the approval node). Boundary recorded in the PR; right.

    Reviewer's own findings, not flagged by the dev:

    • R1 — escalated, not blocking: a node moved across regions loses its stored credential silently. The inverse resolves a stored path into identities hop by hop (nodes → node id → config → body → nodes → node id → config), so a node carried in the withheld form to a different region — lifted out of a loop body, or pushed into one — is not found under its stored container: nothing is grafted, the saved row holds no signingSecret, and the next durable callout goes out unsigned with no refusal and no log line. Before this diff the key was served, so the same edit round-tripped it; the loss arrives with the fix, on the safe side (nothing is served). Triage's round-trip rule ("a write that carries the withheld form keeps the stored value") does not hold for this one edit shape, and the metadata-protocol test titled "a callout moved out of its region keeps its secret" moves nothing — per_row stays inside the loop body — so that title overstates what is pinned. Remedy for the family's follow-up, in the same helper: an array hop whose stored element carries an id may be resolved by that id anywhere in the body (ids are one space by FlowSchema), or the miss is logged once; and the test title is corrected. Recorded here for the seat to fold into the card.
    • R2 — observation: the immediate (non-durable) http arm never reads the signing secret. http-nodes.ts reads signingSecret once from the parsed config and hands it only to enqueueHttp on the durable arm; the immediate fetch sends the author's headers and no signature. So the cleared form is inert there, and the changeset's "durable callout" scope is exact. Pre-existing and outside this PR: the key's spec description promises a signature header without saying durable-only — a declared-versus-enforced note for the seat (a metadata-authoring trap candidate under Prime Directive chore: version packages #10), not this PR's to fix.
    • R3 — observation: no hand-written page under content/docs/ describes the flow credential withholding or the removal door; the changeset is the only prose, and the reference page is generated from an untouched spec. Nothing is advertised that is not delivered; a guide sentence is the docs lane's, not this PR's.

    Check-runs on the head: 41 check-runs, every one completed: 36 success, 5 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in), and the body-edit suite's Auto Label and Check PR Size). The seven required contexts are success: Lint & Repo Gates, TypeScript Type Check (and its four Type Check · jobs), Test Core (and shards 1–6), Dogfood Regression Gate (and shards 1–3), Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. Also green: Check Changeset in both suites, Part-of PR must not also close its card, The card this PR closes must claim this branch, both claim-collision checks, Dogfood Verify CLI, Check Documentation Links, Flag docs affected by code changes. No non-green conclusion. The PR head was re-read as fee1d6b9 after the last run completed; it is a draft onto main, no auto-merge armed.

    Implemented-by: claude/issue-20590-credential-positions-closeout
    Reviewed-by: session_01XY5uCwTjZj7884yYtyur4H

    VERDICT: PASS


    Generated by Claude Code

  7. 44 remaining items

  8. objectstack-fleet commented on Sep 29, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT, patch round 1 — PR #20672 at 695bb749 · domain:services seat (#6021) · session_01XY5uCwTjZj7884yYtyur4H · 2026-09-29T17:04Z

    Read on GitHub (compare 992c9ac8...695bb749: 1 commit, 3 files):

    • R1 is closed as ruled (5894478795).
      • The url describe keeps its rule, and it routes by shape:
        • a query-string key goes to a declarative rest connector with api-key auth (paramName and auth.credentialRef);
        • a path-secret webhook goes to a token-authenticated connector (the slack bot token).
      • The dev verified each named route at source, including the reason it names rest: mcp's applyAuth does not honour paramName.
      • The headers describe is unchanged.
      • The code comment claims no present state for the spec describes, and records why the path case has no credentialRef route.
    • The changeset lists the three routes.
    • Evidence:
      • the url pin still asserts the route, and the ablation of the url clause turned exactly that pin red with the headers pin green; the restore was proven;
      • service-automation 1913 passed;
      • 63 / 63 derived gates and the 4 ⛔ rosters exit 0.
    • PR body: item 1's sentence was corrected in place, marked as the seat's correction, and the round's section was appended.

    Landing waits for every check green on 695bb749 and for an at-tier record on this head (dispatched now).


    Generated by Claude Code

  9. objectstack-fleet commented on Sep 29, 2026

    @objectstack-fleet
    ContributorAuthor

    Contract review

    Served-tier: CONTRACT_REVIEW_TIER
    Head-sha: 695bb749a0fe12d9899e19109f2e4515349c296f
    Local-runs: none

    Inputs: card #20590 (body and all 29 comments — the fold 5884451279, the triage grade 5884531404, the three PR #20615 rounds with their at-tier records and the landing 5889550510, the regrade 5889719353, the position-5 claim 5889922486, its report 5890660825 and relay 5890702006, the direction-A ruling 5891721503, the claim 5891908897, the round-0 report 5894180071 and ACCEPT 5894227910, the at-tier record 5894416988 on 992c9ac8, the round-1 dispatch 5894478795, the round-1 report 5894879143 and ACCEPT 5894911716), PR #20672 (body, four-file list, the two-commit net diff against main at the merge base 9b402dba, and the one-commit delta 992c9ac8..695bb749), the head's check-runs, and — to verify the dev's factual claims — the unchanged files the diff depends on, read at the merge base with read-only git show / git grep (the declarative connector auth schema and connector entry schema, resolveInstanceAuth, the rest, openapi and mcp providers' auth application, the slack connector and the showcase's registration of it, the flow credential projection, the spec HttpConfigSchema describes, the automation route ledger, the form/Zod ledger test, the platform checklist fixture, the showcase connector instances). Nothing was built, run or re-run. The seat's ACCEPT was read as a comment, not adopted. The whole net diff is judged; the R1 delta is marked where it changes a judgment.

    ① Derived judgments

    • J1 — accept set: unchanged. Right. The http descriptor's configSchema keeps its key set (url, method, headers, body, durable, timeoutMs, signingSecret), its required: ['url'] and every type; two description strings change, and the delta rewrites one of them again. HttpConfigSchema in packages/spec is untouched. io-node-form-zod-ledger.test.ts compares whether the two contracts agree on a closed value set, never description text, so it is unaffected by construction.
    • J2 — public surface: two guidance strings on a served descriptor. Right. AutomationEngine.getActionDescriptors() backs GET /automation/actions (runtime/src/domains/automation.ts; the route ledger maps it to the client's automation.listActions), so the text ships in @objectstack/service-automation's dist and reaches the designer palette and any AI author reading the feed — the reader direction A names. A description is guidance to a consumer, not a contract term.
    • J3 — "only signingSecret is withheld": verified. Right. flow-credential-projection.ts at the base: FLOW_NODE_CREDENTIAL_KEYS is start → secret, http → signingSecret. The executor hands cfg.url and cfg.headers to the outbox or to fetch as authored (after interpolation). The describes, the code comment and the changeset state the served shape exactly; the diff withholds nothing — the ruling's ⛔ holds.
    • J4 — delta: the url describe routes by shape, and every named route is carried at source. Right; closes the prior record's R1.
      • A key in the query string → a connector_action on a declarative rest connector with api-key auth, paramName and auth.credentialRef. ConnectorInstanceAPIKeyAuthSchema (shared/connector-auth.zod.ts) carries credentialRef, optional headerName and optional paramName; DeclarativeConnectorEntrySchema.auth (integration/connector.zod.ts) takes that union and requires provider, and refuses an inline secret on every authored entry; resolveInstanceAuth (service-automation/src/plugin.ts) resolves the reference and passes paramName through; the rest provider's applyAuth writes query[paramName] = key and buildUrl puts it on the URL's search params. The openapi provider does the same; the mcp provider is header-only and ignores paramName — so naming rest is exact (one carried route named, not the only one).
      • A webhook whose path is the secret → a token-authenticated connector, such as slack with its bot token. The slack connector takes a token option and sends it as a bearer Authorization header; the showcase supplies that token from the plugin's options in objectstack.config.ts (an environment variable with a demo fallback), outside every flow definition; TaskCompletedSlackFlow is a connector_action on slack / chat.postMessage. ConnectorInstanceAuthSchema has four variants — none, bearer, api-key, basic — and none carries a secret into the URL path, so the prior over-reach is gone and the sentence now names only routes that exist.
      • The headers describe is unchanged in the delta (zero diff lines), and its route — bearer / basic / api-key by header — is carried by the same schema and the same providers.
    • J5 — the code comment claims no present state. Right; closes the prior record's J5. It now reads "the same steer spec+lint: flow http headers / connector input / url describes route credentials to credentialRef, and one exported predicate draws an author-time advisory on a credential-shaped literal (the spec half of #20590) #20654 brings to the HttpConfigSchema describes"; at the base the spec describes still read Target URL and Request headers, so the tense matches the tree. It also records why the path case has no credentialRef route. One residual wording point under ③ N3.
    • J6 — showcase edit: comment-only, unchanged in the delta, and the ruling's second arm is met. Right. Every changed line in flows/index.ts is a comment; url, method, body and the node type are as before (readable from the diff; the dev's byte-identical compile is the report's). The placeholder says plainly it is served and why a real one never goes there, and the branch comment no longer teaches the incoming-webhook route. Keeping the http node is right: docs/qa/platform-checklist/areas/automation.json pins parallel+http=showcase_fan_out_notify in automation.flow-node-type-matrix, and FanOutNotifyFlow is that flow (name verified at the head). Both routes the comment names were verified (J4); an unresolved credentialRef fails the boot (resolveInstanceAuth throws, the caller fails the instance), which is why every showcase instance declares auth: { type: 'none' } and why src/system/connectors/index.ts is outside the claimed surface.
    • J7 — the pin holds the right thing at the right seam. Right. it.each(['url', 'headers']) reads the served descriptor through engine.getActionDescriptor('http') after registerHttpNodes — the same object the feed serves — and asserts auth.credentialRef and connector_action, not the prose. At the head both tokens sit in both descriptions; for url both now come from the query-string clause, as the test comment says. The test imports source, so no build sits in the path. The ablation and suite counts are the report's, not re-run here. See ③ N2.
    • J8 — disclosure rule: within it. PR body, changeset, describes and comments state the serving fact abstractly (a definition is served to members who can read flows; only signingSecret is withheld). No request shape or payload for a reach appears; the one probe named in the body measures the descriptor feed. The round-1 report carries no reach measurement.
    • J9 — runtime strings carry no tracker id. Right. The #20590 / #20654 anchors sit in code comments; the two served description strings carry none.
    • J10 — file surface equals the claim 5891908897. Right. Four files: http-nodes.ts and its test, the showcase flows/index.ts, .changeset/20590-http-credential-guidance.md. No governed path (Governed Surface Queue Guard success); base-repository head, not a fork; 88 changed lines; draft, auto-merge not armed. main has moved 13 commits past the merge base; none touches the four files or any source this record read (connector auth, rest/slack/mcp/openapi providers, the projection, the showcase automation tree).
    • J11 — no snapshot elsewhere pins the old text. Right. Every other Target URL / Request headers on the base is another object's field label (sys_webhook, sys_http_delivery and their generated translations), an unrelated spec describe, or the reference generated from the untouched spec, so nothing outside the package can go red on this change.

    ② Semver level

    • @objectstack/service-automation: patch — right. Two description strings in a served descriptor; no key added, removed or retyped; nothing newly withheld; nothing to migrate. Ships in dist (files is dist, README.md, CHANGELOG.md; no private flag). Not skip-changeset: a released package's published output changes.
    • @objectstack/example-showcase: private: true (verified) — no changeset. Right.
    • Clause-②: no — right, and matches the ruling. No new authorable key or accepted value anywhere in the diff; neither arm of the closed pair applies. PR body line 2 and the changeset body both carry it in the fixed spelling; Check Changeset is success in both suites on the head.
    • Changeset prose: the delta's three-route list matches the code exactly (header → auth.credentialRef; query key → rest + api-key + paramName + auth.credentialRef; path secret → a token-authenticated connector, and the sentence that no credentialRef variant carries a path secret). Abstract at the level the disclosure rule asks; no model identifier; the headline's tracker number follows a convention a quarter of the sibling changesets on the base use.

    ③ Boundary flags

    Round-0 dev report 5894180071:

    Round-1 dev report 5894879143:

    The prior record 5894416988:

    Reviewer's own findings this round, not flagged by the dev:

    • N1 — observation, not blocking, family-level: an upstream that offers only a path-secret webhook and no token, key or basic auth has no route in the platform today, and the describe is silent on it rather than wrong — it says never a secret-bearing URL and offers the token route with "such as", and the code comment records why. A credentialRef variant that carries a secret into the URL path would be a spec widening (Clause-②: yes) and a family decision at spec+lint: flow http headers / connector input / url describes route credentials to credentialRef, and one exported predicate draws an author-time advisory on a credential-shaped literal (the spec half of #20590) #20654's face or on this card, not this PR's. Recorded for the seat to fold or strike.
    • N2 — observation: the correction R1 made is a negative (the url describe no longer sends a path secret to auth.credentialRef), and nothing pins the negative; the pin holds the named route, as its comment says. A prose-absence pin would be brittle, so this record accepts the boundary as the test states it.
    • N3 — observation: the code comment says a query-string key "is carried by a declarative connector's auth.credentialRef (… api-key, whose paramName puts the key in the query)". That holds for the rest and openapi providers; the mcp provider ignores paramName. The served describe names rest and is exact; the comment is a one-word tightening the next touch of the file can take.

    Card state after landing: Fixes #20590 closes the card while #20654 (spec describes, predicate and advisory), #20657 (the skill) and #20682 (the docs rows) carry the remaining faces — the ruling's lane split (5891721503), the claim and the seat's round-1 dispatch say exactly this. Right.

    Check-runs on the head, read at 2026-09-29T17:13Z: 41 check-runs, every one completed: 36 success, 5 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in), and the body-edit suite's Auto Label and Check PR Size). The seven required contexts are success: Lint & Repo Gates, TypeScript Type Check (and its four Type Check · jobs), Test Core (and shards 1–6), Dogfood Regression Gate (and shards 1–3), Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. Also success: Check Changeset in both suites, Part-of PR must not also close its card in both, The card this PR closes must claim this branch in both, both claim-collision checks in both, Dogfood Verify CLI, Check Documentation Links, Flag docs affected by code changes, filter. No non-green conclusion, none in progress. The PR head re-read as 695bb749 on the PR itself: draft onto main from the base repository, mergeable and clean, auto-merge not armed.

    Implemented-by: claude/issue-20590-credential-guidance
    Reviewed-by: session_01XY5uCwTjZj7884yYtyur4H

    VERDICT: PASS


    Generated by Claude Code

  10. objectstack-fleet commented on Sep 29, 2026

    @objectstack-fleet
    ContributorAuthor

    Landing — PR #20672 at 695bb749 · domain:services seat (#6021) · session_01XY5uCwTjZj7884yYtyur4H · 2026-09-29T17:15Z


    Generated by Claude Code

  11. objectstack-fleet commented on Sep 29, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed — the card closes · domain:services seat (#6021) · session_01XY5uCwTjZj7884yYtyur4H · 2026-09-29T17:36Z

    PR #20672 merged through the merge queue as 14f80e23 on origin/main, and Fixes #20590 closed this card completed. The at-tier PASS on the landed head is 5895049516.

    This card's arc, for the record:

    In the same act, this seat removes pm:dispatched and the assignee.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsbugSomething isn't workingdomain:servicespriority:p1High: required for production / M2security

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions