Skip to content

security(flows): move a flow's inbound-hook secret out of flow metadata into the write-only secret seam #7799 established — no read, the generic data door included, returns it #20790

Closed

Description

@objectstack-fleet

Ruled: 5942356310 · letters A / R2 / C1 · 2026-10-01T23:01Z

Ruled: 5910098951 · letter 17.x — the secret seam ships in the 17.x train (Q1 B · Q2 A · Q3 A · Q4 A per 5909514730); re-claim after PR #20830 lands, after #20761 on the access axis · 2026-09-30T11:21Z

Filed by the triage seat (objectstack-wide, seat post #6015, session_01AavokzJ5DndAwitDXvKy4U) on the maintainer's ruling. ⛔ Not a claim, ⛔ not a dispatch. Graded here: enhancement · security · priority:p2 · domain:services · area:access · pm:queue.

⚠️ Disclosure discipline: this card and every comment and PR on it carry no request body, header or field path. The measurements stay in the dev's private scratch space.

The ruling

The maintainer answered 「同意」 in the live PM chat with the triage seat, 2026-09-30, to triage's proposal: move the flow inbound-hook secret into a write-only seam, the way #7799 moved the outbound webhook signing secret. The proposal was parked in triage's grade of #20552 (5882728818: "the durable answer, but it is a new authoring surface, so it goes to the maintainer as a decision after this lands").

Why it is still needed after #20552

Direction

Why p2. The residual reach is an administrator reading an at-rest row, a least-privilege gap. It is not reached below administrator as measured, and #20552 already closed the member-level reads.

Activity

  1. objectstack-fleet commented on Sep 30, 2026

    @objectstack-fleet
    ContributorAuthor

    Serial wait · domain:services seat (#6021) · session_01XY5uCwTjZj7884yYtyur4H · 2026-09-30T07:04Z

    This card is next in this lane by label order (priority:p2). It is not claimed yet, because its file surface intersects an in-flight claim.

    Expected surface, at area level. It is read off #20552's landing (PR #20585 as c96beb27) and this card's direction, and the dev re-measures it:

    • packages/services/service-automation/src/**: the flow-credential projection, the plugin wiring and the inbound door;
    • packages/metadata-protocol/src/** and packages/metadata/src/**;
    • packages/runtime/src/domains/automation.ts;
    • a migration for existing flows.

    The secret seam itself lives in packages/objectql (domain:engine). As the direction reads, it is used, not changed. A packages/spec change for the reference form would be cross-lane, and is measured before it is declared.

    In flight on that surface: #20596 stage 14 (claim 5905919247). It covers comment and docblock prose across every file under packages/services/service-automation/src/**, and was dispatched at 2026-09-30T07:00Z.

    Fold or serial: serial. The fold's first gate fails. A citation re-anchoring sweep and a secret-storage change are different defect shapes with different fixes.

    Next: this card is claimed when stage 14's PR lands. It waits from this comment's stamp.

    Premise reads at this act (the dev re-measures each):


    Generated by Claude Code

  2. objectstack-fleet commented on Sep 30, 2026

    @objectstack-fleet
    ContributorAuthor

    Serial wait, updated · domain:services seat (#6021) · session_01XY5uCwTjZj7884yYtyur4H · 2026-09-30T08:54Z

    A second intersection, read in this act from the open PRs' file lists. It adds to 5906001334.

    Next: this card is claimed when both PRs have landed, or earlier if one lands and the other's files turn out disjoint from the measured surface. It keeps waiting from 5906001334's stamp.


    Generated by Claude Code

  3. objectstack-fleet commented on Sep 30, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1 · 2026-09-30T09:56Z
    Session: session_01XY5uCwTjZj7884yYtyur4H
    Account: os-justin (the seat's linked user as GET /user answers it; the card's assignee)
    Branch: claude/issue-20790-flow-hook-secret-seam
    Worktree: objectstack-issue-20790
    Domain: domain:services
    Seat: domain:services (seat 1, seat post #6021)
    File surface. This is the cross-lane exception path: triage graded this card into domain:services, and its direction reaches files in other lanes, so the whole surface is declared here.


    Generated by Claude Code

  4. objectstack-fleet commented on Sep 30, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 20790,
    "status": "blocked",
    "branch": "claude/issue-20790-flow-hook-secret-seam",
    "pr": null,
    "session": "session_01XY5uCwTjZj7884yYtyur4H — mode:subagent, the dispatching domain:services seat's session id",
    "premise_still_valid": true,
    "summary": "I stopped before writing code, as the order and the claim require. The design cannot avoid packages/metadata-protocol/src/protocol.ts, or sys-metadata-repository.ts for the restore path, and open PR #20830 edits both. Only one point is crossed by every metadata-plane write of a flow before the body is hashed and written. The body is written to three places: the stored row, a per-version history row, and the audit ledger's snapshot. That point is saveMetaItem's carry-forward-then-put step (protocol.ts 16935-16944). Migrate-stored (17522) and duplicatePackage (20324) both go through it. restoreVersion re-puts a stored history body directly (sys-metadata-repository.ts 1064, called at protocol.ts 21235 and 21625). The runtime publish gate learns which withheld credential is present from one place only: restoredCredentialPathsFor (protocol.ts 7482-7506), which reads the stored body. So once the value leaves that body, every redacted-form republish of a moved inbound flow is refused with flow-api-trigger-secret-missing, unless the protocol tells the gate that the seam holds the secret. I measured every route inside the claimed surface and rejected each one (rejected_routes). The premise holds at 4b45afa. Nothing has changed the stored-row object definitions since #20585's live measurement (ancestor check exit 0, clone not shallow). Three decisions the ruling does not settle are in open_questions, so the redispatch after #20830 lands can carry the answers.",
    "off_surface_need": [
    "packages/metadata-protocol/src/protocol.ts: (1) a per-type pre-put sink, registered by the automation plugin, in saveMetaItem between the carry-forward and repo.put (16935-16944). The sink moves an explicit inbound-hook value into the seam and puts the body without it. Migrate-stored and duplicatePackage inherit it. (2) Seam-held positions added to the runtime gate's restored-credential set (7482-7506). (3) The publish/promote path (18144), so that a draft save's value rotates the live hook only when it is published. (4) The restore callers (21235, 21625), so that restoring a pre-move version moves the value again instead of putting it back at rest.",
    "packages/metadata-protocol/src/sys-metadata-repository.ts: only if the restore sink sits at the repository verb (restoreVersion 1064) rather than at its two protocol callers.",
    "packages/spec/src/contracts/automation-service.ts: only if Q2 option B is chosen (an async seam write reachable from the runtime clone door)."
    ],
    "measurements": [
    "B1 HELD, and the value has more at-rest copies than assumed. The start node's config is stored in three places. First, the flow's stored metadata row (draft and active). Second, a per-version history row, written in the same transaction with the same body (sys-metadata-repository.ts 639 and 682). Third, when the audit plugin is composed, the audit ledger's after-snapshot of that row (audit-writers.ts 1412, hooks 1617-1618; the row's object is not in its skip list). All three objects are declared get and list on the generic data door. Their anchors are in the scratch reading readings/at-rest-census.txt. All of this is source-read; #20585's landing record measured the first live. In-process consumers: the engine's api binding (engine.ts 3597-3600) hands the start-node config to trigger-api start() (api-trigger.ts 133), which verifies against it at 202. The publish-time refusal is validateApiTriggerSecret (engine.ts 10127, called at 4311). The lint rule validateFlowApiTriggerSecret (validate-flow-trigger-readiness.ts 852) reads it at 320, with the runtime gate's restored-path input at 866. The clone door (runtime/src/domains/automation.ts 2521) calls getFlow, then cloneFlowDefinition (flow-clone.ts 234, a structuredClone of the whole definition), then registerFlow (2549). The boot pull reads it at plugin.ts 992 (ObjectQL registry items). The execution-face rebind reads it at plugin.ts 1984, 2044 and 2092. The raw in-process getFlow is engine.ts 4608.",
    "B2 HELD. I read webhook-secret.ts whole. The plugin writes the plaintext once into a secret-typed column, and the engine encrypts it, failing closed. Every read masks it. resolveSecretField (engine.ts 8696) dereferences at driver level and refuses any field that is not secret-typed. Presence is decidable without decrypting (mask or null), and that is what a synchronous registration check can use.",
    "B3 measured by source, with no live boot. serve.ts wires the crypto provider only after runtime.start() returns (5041, then 5074). On os serve, therefore, nothing at kernel:ready has a provider. With no provider the engine refuses a secret write loudly (engine.ts 8339). It announces a later registration through onCryptoProviderChange (8245), the channel plugin-webhooks uses. The ruling settles the posture by construction, so it is not a fork. A save that carries an explicit value on a host with no provider is REFUSED: the seam's own fail-closed throw becomes the save's refusal, and nothing is kept in the definition or dropped. The one-time move runs when a provider registers. On a host that never registers one, it leaves each legacy row exactly as it is and reports the flow by name, never the value. This is #7901's precedent, the one the ruling names.",
    "B4 Choice: a companion object that service-automation owns and registers. It registers through the manifest service, the same way plugin.ts 581-592 registers the engine's run objects. The object has one secret-typed field, keyed by flow name and organization. A draft gets its own row, promoted on publish, so a draft save never rotates the live hook. On a flow delete, the automation plugin's registered mutation projector deletes the row, so no secret is left orphaned. Rejected: a secret-typed field on the stored metadata row itself. That object lives in packages/metadata-core, outside the claim, and the column would sit on every row of every metadata type.",
    "B5 measured: the stored reference CAN be the #20552 redacted form (the key absent), and it should be, because absence fails closed. No seam row means no secret, so the engine and the trigger refuse. A spelled reference kept in the same key would pass validateApiTriggerSecret and trigger-api start() as a non-blank secret. A lost seam row would then arm a hook that verifies against a publicly known string. The cost of absence: the runtime gate's restored-path set is computed from the stored body (protocol.ts 7506, over metadata-redaction.ts 515). The protocol must therefore add seam-held positions to that set, which is another need in protocol.ts. Clause-② for the eventual diff, measured from this design rather than copied from the claim: authors keep writing the literal, and absent still means keep. So the authoring accept set does not widen on a host with a provider. It NARROWS on a host with no provider, where an inbound-flow save accepted today is refused. It also narrows at the clone door if Q2 option A is taken. The exported FlowTriggerBinding interfaces (service-automation engine.ts 514, trigger-api api-trigger.ts 11) gain a resolver member, which widens the public surface. Expected line: Clause-②: yes (narrowing), to be re-measured on the real diff.",
    "B6 A packaged flow carries its value in the package artifact, which is the author's source. It is bound from the execution face and withheld from every served read by #20552. I searched this repository's object definitions: none stores a package artifact body behind the generic data door. The control plane's package store lives in another repository and was not read: NOT MEASURED. The door reaches a packaged flow's value only once an overlay row is saved. #20585 measured that save as refused (403) in the default posture, and allowed only under the writable-types escape hatch. Whether a packaged flow moves into the seam at install is Q3.",
    "B7 Today the clone door copies the whole definition, value included, into the clone's engine registration (flow-clone.ts 234, automation.ts 2549). The door persists nothing, so it creates no at-rest copy. After the move, a seam-held source has no value to copy. The engine refuses a secretless inbound flow at registration, so the clone door would answer 400 for it. The ruling does not settle this: Q2.",
    "B8 The http node's signing secret is stored in the same row, history row and audit snapshot as the inbound secret. #20615's projection acts on served reads only. It is reported in out_of_scope_findings and not folded in.",
    "B9 The one-time move belongs in service-automation. It runs on crypto-provider registration, not at kernel:ready, because os serve has no provider then. It works per stored row, and so per organization. It is idempotent: a row without the value is skipped, and the seam write is an upsert. It logs only the flow name and outcome. It cannot go through saveMetaItem, whose carry-forward reads an absent key as keep, so the protocol's public save cannot remove a value. It needs the same protocol-level sink. The ADR-0087 category measured as not-required (no-migration-prescription): nothing is left for an author or an upgrading agent to rewrite, and no spelling is retired. A rotation instruction is not a FROM/TO block. The gate was not run, because no changeset exists."
    ],
    "rejected_routes": [
    "ObjectQL write hooks on the stored-row and history objects. They would be a second writer below ADR-0008's single repository route. The row's checksum is a sha256 over the canonical body with the value in it (sys-metadata-repository.ts 536, canonicalize.ts 135). So a stripped row keeps an offline guess-verifier of the secret, and no longer hashes to its own checksum. A draft save would also rotate the live hook.",
    "A metadata authoring gate that mutates the body. The gate's contract is veto only. It is skipped on the package-author channel (protocol.ts 16739). It runs before the carry-forward and the put, so a 409 after the seam write rotates the hook for a save that never landed. Publish reuses the draft's gate, and restore re-puts a history body.",
    "The flow canonicalizer stripping the value. It is synchronous, so it cannot write the seam. When it throws, the save persists the raw body (protocol.ts 16527 and 16584), so the value would land at rest exactly when it fails.",
    "A migration only, with the write path unchanged. Every later Studio save or rotation would land the value at rest again. The runtime gate would refuse the redacted-form republish of every moved flow (B5).",
    "Wrapping the protocol service's saveMetaItem from the automation plugin. That monkey-patches another package's service (Route & surface ownership rules 1-2, Prime Directive 5), and restoreVersion still bypasses it."
    ],
    "tests": "No code was written, so no test, typecheck or gate ran. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack exited 2: 'this branch changes nothing against origin/main (merge base 4b45afa) — nothing to derive'. The readings, each with its exit code, are in the scratch folder issue-20790/readings/: b1-consumers.txt (git grep, EXIT=0), b-census.txt (EXIT=0), at-rest-census.txt (EXIT=0), premise-drift.txt (merge-base --is-ancestor c96beb2 HEAD exits 0 on a non-shallow clone, and no commit since touches the stored-row object definitions), gates-derived.txt (EXIT=2) and push0.log (EXIT=0). NOT MEASURED live: every B reading is source-read at 4b45afa. The history and audit copies were not reproduced on a composed boot. The decisive facts are structural, and the stop does not depend on them.",
    "mcp_calls": "14, all read-only: mcp__github__issue_read x7 (#20790 get and comments, #20552 get and comments, #7799 get and comments, #20590 get) and mcp__github__pull_request_read x7 (#20585, #7901, #20615, #20672 get; #20830 get_files and get; #20823 get_files). No write tool.",
    "api_writes": "1: this report comment, through scripts/pm/post-stamped.mjs (the fleet relay). The push of the empty branch is git, not REST. No PR was created, and no label was written.",
    "open_questions": [
    {
    "question": "Q1. The move can leave copies of a legacy flow's value outside its definition. Earlier copies sit in the per-version history rows and, with the audit plugin composed, in the audit ledger's change snapshots. The generic data door serves both to an administrator. The ruling's migration names 'flows whose definition still carries the value', and its first pin says no read surface returns the value. Do the older copies get scrubbed, or does the move only stop new copies and require a rotation?",
    "options": [
    "A: Scrub. The one-time move also removes the value from every history row of the flow and from the audit snapshots.",
    "B: Rotate, don't scrub. The sink runs before the body is hashed, so no later row or snapshot carries a value. History and audit stay append-only. The move reports each legacy flow by name as 'exposed at rest before the move; rotate its secret', and the changeset says the same. The 'no read surface' pin is asserted for a secret written or rotated after the move.",
    "C: Both A and B."
    ],
    "recommendation": "B. Real business need: every Studio-authored inbound flow has had these copies readable to any administrator since it was authored. Scrubbing now does not unexpose a value that was already readable; only a rotation does. Long-term soundness: each history row's checksum is a sha256 over the full body, value included. A scrub either leaves an offline guess-verifier in place or re-hashes and breaks the previous-checksum lineage that diff and restore read. The audit ledger is append-only by design, and B keeps one write sink in one place. Preventing AI authoring mistakes: B makes the remaining exposure loud per flow, while A adds two more writers to engine-owned tables. No scope creep at this stage: A widens the card to two more tables and the audit plugin's ledger rules; B adds nothing."
    },
    {
    "question": "Q2. What does the clone door do for a seam-held inbound flow? Today it copies the value into the clone's engine registration. After the move there is no value to copy, and the engine refuses a secretless inbound flow, so the door would answer 400.",
    "options": [
    "A: Refuse loudly with a prescription. The clone door names the missing secret, and the administrator authors the copy with its own secret through the metadata plane. No new key; an inbound flow can no longer be cloned in one step (a narrowing).",
    "B: The closed clone body gains an optional secret, written to the clone's own seam row. This widens the clone body and needs an async seam write that the runtime door can reach, through a new IAutomationService method in packages/spec.",
    "C: The clone gets its own seam row holding a copy of the source's value. Two hooks then share one credential."
    ],
    "recommendation": "A. Real business need: the clone exists for ADR-0126 section 7.1 packaged-flow customization. A packaged inbound flow keeps its value in the artifact (Q3), so its clone is unaffected. Only a Studio-authored inbound flow reaches this fork. No example, doc, skill or client-SDK call reaches the flow clone door: 0 hits, against a positive control of 3 in the runtime source (readings/clone-usage.txt). Studio's caller lives in another repository and was not read. Long-term soundness: A adds nothing; B adds a contract method and a key; C makes one secret verify two hooks. Preventing AI authoring mistakes: A is a loud, contract-tightening refusal; C shares a credential silently; B widens an accept set. No scope creep at this stage: A is the smallest."
    },
    {
    "question": "Q3. Do packaged (code-authored) flows move into the seam? A packaged flow's value lives in the package artifact, the author's source. No stored row holds it until an overlay is saved, and the save sink covers that overlay row. The ruling says the value is 'resolved only at verification time inside the inbound door'. Does that require one source for every flow?",
    "options": [
    "A: Out of scope. The seam holds only values that would otherwise land in a stored row. At verification time the inbound door resolves the seam row when one exists, and otherwise the bound definition's value.",
    "B: Move at install or boot. Upsert every packaged flow's literal into the seam and bind without it, so the door has one source."
    ],
    "recommendation": "A. Real business need: the defect the ruling closes is the at-rest row an administrator reads, and a packaged flow has no such row. The artifact still carries its literal after B, so B gains no secrecy. Long-term soundness: B needs a crypto provider at boot, and os serve wires one only after kernel:ready (serve.ts 5041, then 5074). Every packaged hook would then start disarmed and re-arm on provider registration, and would never arm on a host that has no provider. Preventing AI authoring mistakes: under A an author's defineFlow literal keeps meaning what it says; under B the source stays authoritative while a seam copy can drift from it. No scope creep at this stage: A adds nothing."
    }
    ],
    "out_of_scope_findings": [
    "class: b · reach: exception: security (could leak data). This is source-read and not live-measured; no shipped example authors the key. · evidence: the http node's signing secret is a declared authorable key (FLOW_NODE_CREDENTIAL_KEYS, flow-credential-projection.ts 128-131). It is stored in the same flow row, per-version history row and audit snapshot as the inbound secret. #20585's landing record measured an administrator reading that row through the generic data door, and #20615 withholds the key on served definition reads only. Contract: the redactor registry's invariant that stored credentials must never serve cleartext (packages/spec/src/kernel/metadata-type-redaction.ts header). Seam: spec:HttpConfigSchema.signingSecret → runtime: service-automation's http node durable arm | the metadata store's flow row. It belongs to the same family and needs the same seam decision as #20790. It should ride #20790's redispatch if the maintainer extends the scope, or become a sibling that decision names; it is not a single-point card. · dedupe words: http node signingSecret at rest · outbound signing secret stored flow row · flow credential write-only seam",
    "carrier: 承接者:无 · noted, not filed. Source-read: the /automation create, update and clone doors only register a flow in the engine (automation.ts 2257, 2549) and persist nothing. A flow authored there, and any clone, does not survive a restart. Whether that is by design was not established. It matters to Q2, because a clone door that persists nothing has no seam row of its own to write."
    ],
    "deviations": [
    "I read everything in full as ordered, except #20590's 32 comments. From #20590 I read the body and both landing PR bodies (#20615, #20672).",
    "The branch was pushed empty as the claim's landing marker. It equals origin/main 4b45afa, and nothing else was pushed.",
    "No PR, no changeset and no label write. The PR assignee, draft create and label budget went unused."
    ],
    "cleanup": "The worktree objectstack-issue-20790 is clean (no edits). It is removed after this comment posts: rm -rf node_modules, then git worktree remove, with no --force."
    }


    Generated by Claude Code

  5. objectstack-fleet commented on Sep 30, 2026

    @objectstack-fleet
    ContributorAuthor

    决策请求 · domain:services seat (#6021) · session_01XY5uCwTjZj7884yYtyur4H · 2026-09-30T10:20Z

    ⚠️ 披露纪律:本评论只按类别描述暴露面,不含任何请求、请求头、URL 或字段路径。实测细节只留在 dev 的私有暂存区。

    背景

    Governing text

    • 您的裁决(本卡正文 "The ruling" 与 "Direction" 两节,2026-09-30「同意」):同一个密钥通道,不建第二套机制;不再逐门加隐去;定义里只留引用;一次性迁移并带 ADR-0087 处置;四个钉子。
    • packages/spec/src/kernel/metadata-type-redaction.ts:5:「stored credentials must never serve cleartext」([security] GET /api/v1/meta/datasources still serves stored cleartext credentials — the metadata read path has no per-type redaction hook #8154 的安全不变量)。
    • ADR-0126 §7.1:克隆是一个普通的兄弟产物,与本体没有记录关联。
    • ADR-0008:元数据仓库与变更日志(历史行与存储行同一事务写入)。
    • 协议声明:本卡不改 spec 契约。只有 Q2 选 B 时,才需要在 packages/spec 加一个契约方法(属 spec 车道)。

    前提(每条带复核命令,裁决时可现跑)

    • P1 写入口唯一:git grep -n "carryForwardRedactedCredentials" -- packages/metadata-protocol/src/protocol.ts,命中处之后紧跟 repo.put。
    • P2 发布闸门只读存储体:git grep -n "private async restoredCredentialPathsFor" -- packages/metadata-protocol/src/protocol.ts。
    • P3 fix(metadata-protocol): refusals, hints and log lines state each decision in words instead of a tracker number (stage 2) #20830 持有这两个文件:curl -s https://api.github.com/repos/objectstack-ai/objectstack/pulls/20830/files | grep -E 'protocol.ts|sys-metadata-repository.ts'。
    • P4 加密提供者在 runtime.start() 之后才接上:grep -n "setCryptoProvider(sharedCryptoProvider)" packages/cli/src/commands/serve.ts。
    • P5 克隆门复制整份定义、自身不落盘:git grep -n "structuredClone" -- packages/runtime/src/flow-clone.ts。

    Q1 历史行与审计快照里的旧副本:擦除,还是要求轮换?

    一句话问题: 迁移把密钥移出流程定义之后,这个流程过去每个版本的历史行里仍有这个密钥;若部署装了审计插件,审计账本的变更快照里也有。管理员今天和迁移之后都读得到。

    选项 × 真实代价:

    选项 做什么 客户可感知的后果
    A 擦除 迁移同时改写该流程的所有历史行和审计快照,去掉密钥 审计与历史不再只追加;历史校验链要么断,要么留下可离线验证密钥的哈希
    B 轮换不擦除 迁移只保证今后不再产生副本;按流程名逐一报告"迁移前已在存储面暴露,请轮换密钥",changeset 同样写明 旧值留在历史中,直到管理员轮换;轮换后旧值作废
    C 两者都做 A + B A 的全部代价,加上报告

    业务直译:

    • A 等于把已泄露过的钥匙从旧监控录像里抹掉。
    • B 等于换锁:旧钥匙留在录像里,但开不了门。
    • C 两样都做。

    四轴(业务立场):

    1. 长远: 两年后的平台应该是这样:凭据从不以明文落盘;一旦发现曾经落盘,标准动作是轮换,不是改写日志。GitHub 和 Stripe 对泄露 token 的处置都是"作废并重发"。B 保住审计只追加、单一写入点;A 要在引擎自有的两张表上再加两个写入者。
    2. 业务拉动: 每个在 Studio 建的入站流程,从创建那天起,它的密钥就对所有管理员可读(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 实测过主行;历史行与审计快照是源码读取,未实测)。擦除挽回不了已经发生的可读,只有轮换能。
    3. 防 AI 犯错: 出错时谁看到什么?B 下,每个旧流程都有一条响亮的逐流程提示;A 下,擦除一旦漏掉一张表,就会静默残留,没人知道。
    4. 不扩散: B 不新增写入者和表规则;A 把卡扩到两张表和审计插件的规则。

    推荐:B。 回退是 C。

    • 置信缺口:历史行与审计快照这两个副本是源码读出的,没有在组合启动上实测;生产部署是否装了审计插件,本席看不到(云仓不可读)。
    • 自检:只看①选 B;②③④ 是否翻转:否。

    Q2 克隆门遇到"密钥已进通道"的入站流程,怎么办?

    一句话问题: 今天克隆一个入站流程,会把密钥原样复制进副本。迁移之后,定义里已经没有密钥可复制,引擎又拒绝没有密钥的入站流程,所以克隆门会直接报 400。

    选项 × 真实代价:

    选项 做什么 客户可感知的后果
    A 响亮拒绝并给出处方 克隆门拒绝,并指明缺的是密钥;管理员经元数据面给副本配上自己的密钥 入站流程不能再"一步克隆"(收窄);不加新键
    B 克隆体可带一个新密钥 克隆请求体新增一个可选密钥,写进副本自己的通道行 一步完成;但要在 packages/spec 加一个异步契约方法,并放宽克隆请求体
    C 副本复制源的密钥进自己的通道行 两个钩子共用一把钥匙 无感知,但一个凭据同时开两扇门

    业务直译:

    • A 等于复印钥匙前先去前台登记新钥匙。
    • B 等于复印时顺手刻一把新的。
    • C 等于把同一把钥匙挂在两扇门上。

    四轴:

    1. 长远: 两年后,复制一个入站 webhook 应该得到一把新密钥,就像 GitHub 复制 webhook 要重新填 secret。A 与 B 都做到每门一钥:A 不新增契约面,B 新增一个契约方法和一个键;C 违背每门一钥。
    2. 业务拉动: 克隆门服务于 ADR-0126 §7.1 的打包流程定制,而打包入站流程的密钥在产物里(见 Q3),不受影响。受影响的只有 Studio 建的入站流程。本仓示例、文档、skill、客户端 SDK 调用克隆门:0 处(正对照:运行时源码 3 处)。Studio 的调用方在另一仓,未读。
    3. 防 AI 犯错: A 是响亮拒绝并给出处方;C 是静默共享凭据;B 放宽了接受集。
    4. 不扩散: A 最小。

    推荐:A。 回退是 B。

    • 置信缺口:Studio 是否依赖一步克隆入站流程,未测。
    • 自检:只看①选 A;②③④ 是否翻转:否。

    Q3 打包(代码编写)的流程也迁进通道吗?

    一句话问题: 打包流程的密钥写在包产物里,也就是作者的源码。它没有存储行,除非有人存了一个覆盖行;而覆盖行会走 P1 的写入口,自动被覆盖。裁决说"只在入站门验签时解析",这是否要求所有流程只有一个来源?

    选项 × 真实代价:

    选项 做什么 客户可感知的后果
    A 不纳入 通道只收"否则会落进存储行"的值;入站门验签时有通道行就用通道行,没有就用已绑定定义里的值 打包流程行为不变
    B 安装或启动时迁入 把每个打包流程的字面量写进通道,绑定时去掉 每个打包钩子启动时都要等加密提供者就绪才能挂上;没有提供者的主机上永远挂不上

    业务直译:

    • A 等于写在说明书里的密码仍以说明书为准。
    • B 等于每次开机都把说明书上的密码抄进保险箱,而说明书还在。

    四轴:

    1. 长远: 本卡要关的缺陷,是管理员能读的存储行;打包流程没有这种行。B 之后产物照样带着字面量,保密性没有任何提升,反而多了一个可能与源码漂移的副本。更长远的方向,是打包流程不写字面密钥(改为凭据引用),那是另一张卡。
    2. 业务拉动: B 在 os serve 上会让所有打包入站钩子先断后续(P4)。
    3. 防 AI 犯错: A 下,作者在 defineFlow 里写的字面量含义不变;B 下,源码权威与通道副本可能不一致。
    4. 不扩散: A 不新增任何东西。

    推荐:A。 回退是 B。

    • 置信缺口:控制面的包存储在另一仓,是否在通用数据门后面存有产物体,未测。
    • 自检:只看①选 A;②③④ 是否翻转:否。

    Q4 出站 http 节点的签名密钥:并入本卡,还是另立?

    一句话问题: 流程里的出站 http 节点签名密钥,与入站密钥存在同一个存储行、同一个历史行、同一个审计快照里,管理员同样读得到。#20615 只在对外读取时隐去它。您的裁决只点名了入站密钥。

    选项 × 真实代价:

    选项 做什么 客户可感知的后果
    A 并入本卡 写入口按 #20552 的凭据位置表工作,两个位置一起迁,钉子各一套 一次关掉同族的两个暴露;本卡范围扩大一行表加一套钉子
    B 另立兄弟卡 本卡落地后复用同一写入口 出站密钥在此期间继续可读
    C 只记入 Acceptance notes 不修 暴露长期存在

    业务直译:

    • A 等于同一次换锁把后门也换了。
    • B 等于先换前门,后门排下周。
    • C 等于后门不换。

    四轴:

    1. 长远: 一个通道收全部流程凭据,写入口按表驱动。A 不产生特例;B 需要为同一写入口走第二轮改动。
    2. 业务拉动: 同一批管理员今天就读得到;本仓示例没有作者写这个键(源码读取,未实测)。
    3. 防 AI 犯错: A 下,凭据表是唯一的清单,新增凭据位置时自动被覆盖;B 或 C 下,两个位置行为不一致。
    4. 不扩散: 这是关闭同族的安全缺口,不是新能力。

    推荐:A。 回退是 B。

    • 置信缺口:出站密钥的历史与审计副本同 Q1,属源码读取。
    • 自检:只看①选 A;②③④ 是否翻转:否。

    裁后执行

    1. 裁决到手后,本席在本卡同一分支上重新认领。文件面加入 protocol.ts 的写入口与闸门集合(必要时加 sys-metadata-repository.ts 的恢复路径),向 domain:engine 席发跨车道通知,并等 PR fix(metadata-protocol): refusals, hints and log lines state each decision in words instead of a tracker number (stage 2) #20830 落地后派发。
    2. 派发令逐字引用您的字母选择,四个钉子按所选选项收紧:Q1 选 B 时,"无读面返回密钥"对迁移后写入或轮换的值成立;Q4 选 A 时,再加出站一套。
    3. Clause-② 按 dev 实测:加密提供者缺席时收窄,Q2 选 A 时克隆门收窄,绑定接口加一个解析成员。预期为 yes (narrowing),落地前由达档契约复核确认。

    os-decision-facets

    • ① 项目长远合理性:只用一个密钥通道、一个写入口,不建第二机制;Q2 选 A 不新增契约面,Q4 选 A 由凭据表驱动、不留特例。
    • ② 实际业务拉动:今天每个 Studio 入站流程的密钥对管理员可读(主行已实测);克隆门在本仓零调用方(Studio 未读)。
    • ③ 防 AI 犯错:缺密钥即响亮拒绝(引用缺省即关闭),不静默共享、不静默丢弃,旧流程逐个响亮提示轮换。
    • ④ 创业阶段不扩散:Q1-B、Q2-A、Q3-A 都不新增表写入者或契约键;Q4-A 是同族收口,不是新能力。
      Prior rulings read: scrub,rotate,clone,packaged,history,audit → 33 hits; ADR-0094 D5, ADR-0005 Decision §5, ADR-0029 D6/D9/D9.4/D9.7/D9.9, ADR-0033 Decision §10, ADR-0035 Decision §3, ADR-0041 Decision §4 — each a single-term match on another subject, none answers Q1–Q4; thread: none
      推荐:Q1 B · Q2 A · Q3 A · Q4 A。自检:只看①选 B/A/A/A;②③④ 是否翻转:否。
      置信缺口:历史行、审计快照与出站密钥的副本均为源码读取,未在组合启动上实测;Studio 与云端包存储不可读。

    回批一行即可,例如 Q1 B · Q2 A · Q3 A · Q4 A。

    Release: session session_01XY5uCwTjZj7884yYtyur4H. The cause is four forks the ruling does not settle (Q1–Q4 above), plus a file (protocol.ts) that open PR #20830 holds. The card moves to needs-user-decision, and the assignee is cleared in the same act. It is re-claimed on this branch (claude/issue-20790-flow-hook-secret-seam, pushed empty at 4b45afae) after the ruling, once PR #20830 has landed.


    Generated by Claude Code

  6. 25 remaining items

  7. objectstack-fleet commented on Oct 2, 2026

    @objectstack-fleet
    ContributorAuthor

    Dispatch: rebuild round 1 under ruling A / R2 / C1 · domain:services seat 2 (#21118) · session_01DiCSbmJrkzNhuEAier4VoJ · 2026-10-02T03:57Z


    Generated by Claude Code · https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ

  8. objectstack-fleet commented on Oct 2, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 20790,
    "status": "done",
    "branch": "claude/issue-20790-flow-hook-secret-seam",
    "pr": "#21377",
    "session": "session_01DiCSbmJrkzNhuEAier4VoJ — mode:subagent, the dispatching domain:services seat 2 session id",
    "premise_still_valid": true,
    "summary": "Built the ruled design (A, R2, C1). A flow's two credentials, the inbound hook secret on its start node and an http node's outbound signing secret, now live in one service-automation platform object, sys_flow_credential. Its one value field is secret-typed and goes through the existing secret seam, so it is encrypted, masked on every read, and dereferenced only by the privileged resolver. The metadata save door stores the body the registered channel returns, so the stored row, new history rows and the content hash carry no credential. The verification and http signing paths read the value at use time. The restore verb gains deriveRestoredBody, and rollback and revert strip through it (R2). The clone door refuses any credential-holding source with 409 RESOURCE_CONFLICT (C1), and the accepted cost is stated. With no crypto provider, a save is refused 503 before anything is written. A one-time move re-saves stored credential-carrying flow rows through the save door, logs a per-flow ROTATE notice that names flows and classes only, and writes a sys_migration receipt; it rewrites no history or audit row and leaves packaged flows as they are (Q3 A). Premise shift, as in round 0: on current main the MCP stdio door had already stopped serving a flow credential (the #21228 change), but the stored row and history row still held both in cleartext and an administrator engine read returned them, so the class the card names still held. #21207 remains open.",
    "tests": "All at head 80b4647, foreground, under os-verify-lock slot issue-20790-dev. Exit codes were captured before any pipe. Pin set (units.sh): service-automation 8 files, 81 passed; trigger-api 1 file, 15 passed; runtime 2 files, 23 passed; metadata-protocol 2 files, 52 passed; spec platform-object-names 10 passed. Dogfood pin flow-credential-channel.dogfood.test.ts 8/8, after a rebuild of the dogfood dependency closure (turbo 63/63 tasks). Full suites: service-automation 166 files, 2051 passed; metadata-protocol 2 shards, 202 files plus 3 skipped, 2985 passed, 19 skipped; trigger-api 2 files, 30 passed; runtime local project 3 shards, 304 files, 4335 passed, 11 skipped; spec local project 2 shards, 598 files, 17512 passed, 1 todo. Typecheck: service-automation, metadata-protocol, trigger-api, runtime and dogfood exit 0, and spec tsc exit 0. The runtime full suite caught one stale pin: the #20552 projection test asserted that a credential-holding source clones whole. Commit 80b4647 re-pins it to the C1 refusal (409, RESOURCE_CONFLICT, no copy registered, no credential in the answer). Ablations, with every negative pin ablated: A1 to A18 ran at a38db79 through scripts/ablation-replace.mjs. Every anchor hit, every pin went red (1 to 6 failing tests per ablation), and the tree read empty against HEAD after each restore. The ablations covered: channel store keeping the credential; value not secret-typed; a draft writing the live row; prune keeping rows; a swallowed no-provider refusal; the cleared secret reader; http sending unsigned when unresolvable; a cleared http secret asking the channel; migration deferral writing; the rotation notice carrying a value; the trigger verifying an unreadable secret; clone not refused; the save door ignoring the channel body; the save door swallowing a refusal; rollback with no strip; the restore verb ignoring the derivation; and the publish and save gates without held paths. D1, the dogfood-level ablation (the channel returns the body unstripped), ran at 457f434: mutate, build, preflight found the marker in 2 dist files, and the pin went red on 6 of 8 (tests 6 and 7 green as expected); then restore, rebuild, preflight --absent passed, the pin went 8/8 green, and the diff against HEAD was empty. The mutated build's DTS step failed (TS2872 from the marker) while its JS legs emitted, which is why the preflight was the proof that the marker reached dist. Declared to CI: the full dogfood suite, the runtime repo project, and repo-wide lint.",
    "gates": {
    "derived_at": "80b4647b2",
    "command": "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands",
    "derived": 129,
    "ran": 129,
    "nonzero": 0,
    "not_measured": 0,
    "reconcile": "Run reconciliation — 129 derived, 129 run, 0 NOT-MEASURED, 0 UNRUN.",
    "stale_note": "Derivation flagged origin/main at least 10 commits ahead after the run, with one derivation input changed (a release script outside this diff). git merge-tree against current origin/main is clean. Not re-merged.",
    "fixed_on_the_way": [
    "check:keyed-text-bounds (bounded digest key)",
    "check:platform-object-tenancy-census (--write)",
    "check:tenant-audit-census (--write plus page prose)",
    "check:durability-log-level (failure at error)",
    "check:engine-double-contract (predicate assert, --write)",
    "check:objectql-double-limit and check:where-matcher (test double)",
    "check:test-source-alias (dogfood alias)"
    ],
    "ci_at_report": "in_progress — check runs on 80b4647: 9 completed, 0 failed, 24 in progress"
    },
    "line_budget": {
    "changed_lines": 3532,
    "added": 3443,
    "removed": 89,
    "files": 36,
    "split": {
    "implementation_src": "+1748 -39",
    "tests": "+1578 -11",
    "generated_ledgers_and_lockfile": "+77 -37",
    "docs_and_changeset": "the rest"
    },
    "ruled_band": "2300 ± 500",
    "threshold": 5000,
    "over_band": true,
    "split_needed": false
    },
    "deviations": [
    "Size is 3532 changed lines, above the ruled 2300 ± 500 band and under 5000. Implementation is +1748, tests +1578. There is no split.",
    "packages/runtime/src/domains/automation.ts was edited (the clone handler consults the C1 refusal). It is in claim 5935167060 but outside revision 5942559287's file list. It is named in the PR body's cross-lane list.",
    "The protocol.ts edit includes the publish gate's held-position read for a draft promotion (promoteDraftForPublish passes the channel's held paths). This is the 'publish gate's restored-credential read' the revision names, applied to the draft promotion too.",
    "Outside the named packages, the PR touches the shared dogfood harness (package.json dev dependency on trigger-api, a vitest source alias, three lockfile lines) and four generated artifacts, each written by its own --write: the tenancy census, the tenant-audit census page and counts, and the engine-double-contract ledger.",
    "The PR body's Size note says 'about 1,800 of the lines are pins'. The measured figure is 1,578 added test lines, plus 77 generated-ledger and lockfile lines. Per the one-write rule I did not PATCH the body; the seat may correct that sentence to '1,578 lines are tests'.",
    "Branch history: two merges of origin/main (6c67c3a and 6dd8c07), plain fast-forward pushes, no force push. The second merge was for a stale-tree derivation. main has moved again since; the test merge is clean.",
    "Lock: several queue-timeouts (exit 99), each read as NOT MEASURED and retried under the same slot. The runtime, metadata-protocol and spec suites were sharded so that each locked call fits under the foreground cap. Every shard recorded exit 0 at 80b4647.",
    "The harness attribution reminder asked for a model-named Co-Authored-By trailer and a different PR footer. Per the agent definition's priority clause and the dispatch, commits carry the model-free pair and the PR carries the session-URL footer."
    ],
    "files_changed": [
    ".changeset/20790-flow-credential-channel.md",
    "content/docs/automation/flows.mdx",
    "content/docs/concepts/metadata-lifecycle.mdx",
    "content/docs/permissions/tenant-audit-census.mdx",
    "docs/audits/2026-08-tenant-audit-write-call-sites.counts.md",
    "packages/metadata-protocol/src/index.ts",
    "packages/metadata-protocol/src/protocol.credential-channel.test.ts",
    "packages/metadata-protocol/src/protocol.ts",
    "packages/metadata-protocol/src/sys-metadata-repository.ts",
    "packages/qa/dogfood/package.json",
    "packages/qa/dogfood/test/flow-credential-channel.dogfood.test.ts",
    "packages/qa/dogfood/vitest.config.ts",
    "packages/runtime/src/domains/automation-flow-clone-credential.test.ts",
    "packages/runtime/src/domains/automation-flow-credential-projection.test.ts",
    "packages/runtime/src/domains/automation.ts",
    "packages/runtime/src/flow-clone.ts",
    "packages/services/service-automation/src/activation-ledger-registration.test.ts",
    "packages/services/service-automation/src/api-trigger-secret-channel.test.ts",
    "packages/services/service-automation/src/builtin/http-node-channel-signing.test.ts",
    "packages/services/service-automation/src/builtin/http-nodes.ts",
    "packages/services/service-automation/src/engine.ts",
    "packages/services/service-automation/src/flow-credential-channel.test.ts",
    "packages/services/service-automation/src/flow-credential-channel.ts",
    "packages/services/service-automation/src/flow-credential-migration.test.ts",
    "packages/services/service-automation/src/flow-credential-migration.ts",
    "packages/services/service-automation/src/flow-credential-projection.ts",
    "packages/services/service-automation/src/index.ts",
    "packages/services/service-automation/src/plugin-suspended-run-wiring.test.ts",
    "packages/services/service-automation/src/plugin.ts",
    "packages/services/service-automation/src/sys-flow-credential.object.ts",
    "packages/spec/src/system/constants/platform-object-names.ts",
    "packages/triggers/trigger-api/src/api-trigger.test.ts",
    "packages/triggers/trigger-api/src/api-trigger.ts",
    "pnpm-lock.yaml",
    "scripts/engine-double-contract.pinned.json",
    "scripts/platform-object-tenancy-census.json"
    ],
    "mcp_calls": "0 — no MCP tool was called. GitHub reads used the REST CLI: PR 21377 read-back and a check-runs snapshot.",
    "api_writes": "3 — all through the fleet-write relay (POST /repos/objectstack-ai/objectstack/dispatches): (1) pr_create, draft PR 21377, read back identical (10112 bytes sent and stored); (2) label-write assignee os-bill on PR 21377, read back matching, no labels named; (3) this os-dev-report comment on #20790 via scripts/pm/post-stamped.mjs. Branch pushes went through git, not REST.",
    "open_questions": [],
    "out_of_scope_findings": [
    "carrier: PR 21377 Acceptance notes · noted, not filed — duplicating a package that holds an inbound flow whose secret the channel holds is now refused by the runtime authoring gate, because the copy holds no secret. This is consistent with C1.",
    "carrier: PR 21377 Acceptance notes · noted, not filed — in inert mode, the stored re-save tool refuses a literal-carrying flow row when no provider is registered; a legacy draft carrying a literal and published with no provider is refused 503. Both are the no-provider rule.",
    "carrier: PR 21377 Acceptance notes · noted, not filed — the channel keys by flow name and state, env-wide like the engine's flow map, so same-named stored rows in two packages share one slot.",
    "carrier: PR 21377 Acceptance notes · noted, not filed — the channel write precedes the stored put; the migration receipt write is not on the durability-critical callee list; the 'holds' check reads a per-process index refreshed at boot, at kernel ready, on metadata reload and on local writes, while the value is always read live.",
    "carrier: #21207 (承接者: that card's seat) · noted, not filed — for flows only, this PR removes the credential from what the content hash covers; #21207 remains open for the other types."
    ],
    "cleanup": "After this comment posts: the worktree's node_modules is removed first, then git worktree remove, with no --force. No dev server or monitor was started."
    }


    Generated by Claude Code

  9. objectstack-fleet commented on Oct 2, 2026

    @objectstack-fleet
    ContributorAuthor

    PM review: REVISE (prose only) · PR #21377 at 80b4647b2 · 2026-10-02T09:20Z

    Seat domain:services#2 · session session_01DiCSbmJrkzNhuEAier4VoJ · under ruling 5942356310 (A / R2 / C1). ⛔ Classes and positions only.

    The code is accepted as built against the ruling. The PR comes back for two prose sentences and one PR-body figure. No code change is asked. The contract review of record (Clause-②: yes (widening)) runs once this round is back, on the final head.

    Accepted (read against the diff, not the report)

    • A: sys_flow_credential is owned by service-automation. It has one Field.secret value, access: { default: 'private' }, no data door (apiEnabled: false), and a unique (flow_name, state, position) index. Its value is dereferenced only through resolveSecretField, the seam sys_webhook's secrets use. The spec change is the one registry line.
    • Save door order (protocol.ts saveMetaItem): authoring gate, then carry-forward, then channel.store, then repo.put. The stored row and the new history rows carry the stripped body. With no provider, the channel refuses 503 before any write.
    • R2: restoreVersion takes deriveRestoredBody and derives the body before the put. Rollback and revert both pass the channel's strip. The publish gate reads held paths on an active save and on a draft promotion. The draft promotion goes one step beyond the revision's "restored-credential read", and is accepted as the same read.
    • C1: the clone handler refuses 409 RESOURCE_CONFLICT when flowCredentialHoldings (literal or channel-held) is non-empty. The message names classes and the remedy, never a value. The stale [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 projection pin was re-pinned to the refusal.
    • Q3 A: hookSecretResolver asks the channel first and falls back to a packaged literal. A held credential that does not come back rejects, so trigger-api answers 503 and never verifies against nothing.
    • The migration goes through the save door. It logs one ROTATE notice per flow, naming the flow and the class, and writes the sys_migration receipt flow-credential-channel. Packaged rows are left as they are.
    • Pins, ablations and gates: A1 to A18, plus D1 at the dogfood level with the dist preflight. 129 gate commands derived and 129 run.
    • Size: 3,532 changed lines, over the ruled 2,300 ± 500 band but under 5,000. The ruling's ⛔ no-split applies, so no split.
    • Noted, not a defect: the channel write precedes repo.put, so a put refused after it (a version conflict, say) still leaves the new value live. Nothing leaks and nothing fails open, and the author's retry converges. It stays in the PR's Acceptance notes.

    Revise

    1. content/docs/automation/flows.mdx:274: "Only signingSecret and a start node's secret are kept out of the definition: the metadata save door stores each in a write-only, encrypted flow credential store …" is false for a packaged flow. Its literal stays in its package source (Q3 A, engine.ts hookSecretResolver), and it is only withheld when the definition is served (the redactor still runs). Scope the sentence to a flow saved through the metadata API. Add a short clause for the packaged one: it keeps its literal in its package source, the literal is withheld when the definition is served, and a channel row, where one exists, wins at verification.
    2. The changeset's ⚠️ paragraph: "On the first boot with a crypto provider, each stored flow … is moved into the channel once." The move also runs at the next crypto-provider registration when the boot had none (flow-credential-migration.ts, the deferral). Say both: the first boot with a provider, or the provider's registration after a boot without one.
    3. PR body, Size: "about 1,800 of the lines are pins" should read 1,578 added test lines (your report). Patch the body once.

    Then merge origin/main (a merge commit; the derivation flagged it 10+ commits ahead). Re-run the changeset gate and the docs checks that dispatch-gates.mjs --commands names for these two paths. Push and report.

  10. objectstack-fleet commented on Oct 2, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim revision (file surface, as built) · domain:services seat 2 (#21118) · session_01DiCSbmJrkzNhuEAier4VoJ · 2026-10-02T09:21Z · amends 5942559287 under ruling 5942356310. ⛔ Classes and positions only.

    PR #21377 touches these paths beyond revision 5942559287's list. Each is read against the diff and accepted as the ruled design's consequence:

    • domain:engine: packages/metadata-protocol/src/index.ts, one type re-export (MetadataCredentialChannel) beside the authoring-gate types. The protocol.ts publish gate's held-path read also covers the draft promotion.
    • domain:cli:
    • domain:spec (generated, each written by its own gate's --write): scripts/platform-object-tenancy-census.json and scripts/engine-double-contract.pinned.json.
    • Docs and census pages:
      • content/docs/automation/flows.mdx, content/docs/concepts/metadata-lifecycle.mdx (sentences this change makes false);
      • content/docs/permissions/tenant-audit-census.mdx and docs/audits/2026-08-tenant-audit-write-call-sites.counts.md (generated).

    The size is 3,532 changed lines against the ruled 2,300 ± 500 band, under 5,000. ⛔ No split, as ruled. Each lane gets a supplementary notice in this act.


    Generated by Claude Code · https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ

  11. objectstack-fleet commented on Oct 2, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 20790,
    "status": "done",
    "branch": "claude/issue-20790-flow-hook-secret-seam",
    "pr": "#21377",
    "session": "session_01DiCSbmJrkzNhuEAier4VoJ — mode:subagent, the dispatching domain:services seat 2 session id",
    "premise_still_valid": true,
    "summary": "Prose fix-up round for PM review 5949073444 (REVISE, prose only). No code changed. (1) The flows page's credential sentence now covers only a flow saved through the metadata API, and a new clause covers a packaged flow: it keeps its literal in its package source, the literal is withheld when the definition is served, and a credential store row for that flow, where one exists, wins when the engine verifies or signs. 'Signs' is there because the http node also prefers a held signing secret over the literal. (2) The changeset's rotation paragraph names both triggers of the move: the first boot with a crypto provider, or a provider registering after a boot without one (the plugin reschedules the move on every provider change, and the move is idempotent). (3) The PR body's Size sentence now reads '1,578 of the lines are added test lines'; nothing else in the body changed. origin/main was merged as a merge commit with no conflicts. New head be755de.",
    "tests": "All at be755de (prose commit bd9a133, then merge be755de). service-automation full suite under os-verify-lock: 166 files, 2051 passed. metadata-protocol full suite, 2 shards under the lock: shard 1 103 files, 1323 passed; shard 2 99 files plus 3 skipped, 1662 passed, 19 skipped. Total 2985 passed, 19 skipped. To read built output in the fresh worktree, two builds ran under the lock: the turbo closure of lint, service-automation and metadata-protocol (21/21 tasks), and the client and client-react closures (34/34 tasks). AGENTS.md was restored after each build, and the tree was clean after every step. No ablation this round: no pin or code changed.",
    "gates": {
    "derived_at": "be755def9",
    "derived": 129,
    "scope_this_round": "As the PM's order scoped it: every derived family whose match includes .changeset/ or content/docs/ (50 runnable commands, plus 2 CI-only matrix templates not runnable locally), plus the ratchet and census families (6): platform-object-tenancy-census and its self-test, driver-memory-census, query-options-erasure, type-check-coverage, type-check-debt.",
    "ran": 56,
    "nonzero_final": 0,
    "prerequisite_reruns": "5 families first answered PREREQUISITE NOT MET on the fresh worktree (exit 3, or exit 1 for spec check:docs with no generated schema): lint check:doc-formula-expressions, lint check:doc-security-posture, spec check:docs, check:docs-transcript-drift, spec check:skill-examples. They are recorded as not measured, not as failures. After the builds above, all 5 were re-run and exited 0.",
    "reconcile": "Run reconciliation — 129 derived, 56 run, 0 NOT-MEASURED, 73 UNRUN.",
    "unrun_note": "The 73 unrun families are the declared narrowing this order set. Each exited 0 at 80b4647 in the round-1 union, and the diff since then is two one-line prose edits plus the main merge. CI runs the full farm on be755de.",
    "stale_note": "origin/main is 4 commits ahead again after the merge, with 6 derivation inputs changed. git merge-tree against it is clean. Not re-merged.",
    "ci_at_report": "in_progress — check runs on be755de: 28 completed, 0 failed, 6 in progress"
    },
    "line_budget": {
    "changed_lines": 3532,
    "added": 3443,
    "removed": 89,
    "files": 36,
    "ruled_band": "2300 ± 500",
    "threshold": 5000,
    "over_band": true,
    "split_needed": false,
    "this_round": "two one-line replacements; total unchanged"
    },
    "deviations": [
    "The flows clause says a held row 'wins when the engine verifies or signs' rather than only 'at verification'. The http node also prefers a held signing secret over a packaged literal, so 'verification' alone would have been half-true.",
    "The gate run was narrowed to the order's scope (docs and changeset families) plus the 6 ratchet and census families, which the standing post-review rule requires. Reconciliation reports 73 unrun, all green at 80b4647. The narrowing is declared, not silent.",
    "Not changed, outside the order: the PR body's Evidence heading still names head 80b4647. This round's head and measurements are in this report."
    ],
    "files_changed": [
    ".changeset/20790-flow-credential-channel.md",
    "content/docs/automation/flows.mdx",
    "content/docs/concepts/metadata-lifecycle.mdx",
    "content/docs/permissions/tenant-audit-census.mdx",
    "docs/audits/2026-08-tenant-audit-write-call-sites.counts.md",
    "packages/metadata-protocol/src/index.ts",
    "packages/metadata-protocol/src/protocol.credential-channel.test.ts",
    "packages/metadata-protocol/src/protocol.ts",
    "packages/metadata-protocol/src/sys-metadata-repository.ts",
    "packages/qa/dogfood/package.json",
    "packages/qa/dogfood/test/flow-credential-channel.dogfood.test.ts",
    "packages/qa/dogfood/vitest.config.ts",
    "packages/runtime/src/domains/automation-flow-clone-credential.test.ts",
    "packages/runtime/src/domains/automation-flow-credential-projection.test.ts",
    "packages/runtime/src/domains/automation.ts",
    "packages/runtime/src/flow-clone.ts",
    "packages/services/service-automation/src/activation-ledger-registration.test.ts",
    "packages/services/service-automation/src/api-trigger-secret-channel.test.ts",
    "packages/services/service-automation/src/builtin/http-node-channel-signing.test.ts",
    "packages/services/service-automation/src/builtin/http-nodes.ts",
    "packages/services/service-automation/src/engine.ts",
    "packages/services/service-automation/src/flow-credential-channel.test.ts",
    "packages/services/service-automation/src/flow-credential-channel.ts",
    "packages/services/service-automation/src/flow-credential-migration.test.ts",
    "packages/services/service-automation/src/flow-credential-migration.ts",
    "packages/services/service-automation/src/flow-credential-projection.ts",
    "packages/services/service-automation/src/index.ts",
    "packages/services/service-automation/src/plugin-suspended-run-wiring.test.ts",
    "packages/services/service-automation/src/plugin.ts",
    "packages/services/service-automation/src/sys-flow-credential.object.ts",
    "packages/spec/src/system/constants/platform-object-names.ts",
    "packages/triggers/trigger-api/src/api-trigger.test.ts",
    "packages/triggers/trigger-api/src/api-trigger.ts",
    "pnpm-lock.yaml",
    "scripts/engine-double-contract.pinned.json",
    "scripts/platform-object-tenancy-census.json"
    ],
    "files_changed_this_round": [
    ".changeset/20790-flow-credential-channel.md",
    "content/docs/automation/flows.mdx"
    ],
    "mcp_calls": "0 — no MCP tool was called. GitHub reads used the REST CLI: the #20790 comment list, PR 21377 before and after the patch, and a check-runs snapshot.",
    "api_writes": "2 — both through the fleet-write relay (POST /repos/objectstack-ai/objectstack/dispatches): (1) issue_patch of PR 21377's body (only the Size sentence changed), read back identical, 10118 bytes sent and stored; (2) this os-dev-report comment on #20790 via scripts/pm/post-stamped.mjs. The branch push (80b4647..be755de) went through git, not REST.",
    "open_questions": [],
    "out_of_scope_findings": [],
    "cleanup": "After this comment posts: the worktree's node_modules is removed first, then git worktree remove, with no --force. No dev server or monitor was started."
    }


    Generated by Claude Code

  12. objectstack-fleet commented on Oct 2, 2026

    @objectstack-fleet
    ContributorAuthor

    PM review: ACCEPT (seat) · PR #21377 at be755def9 · 2026-10-02T10:12Z

    Seat domain:services#2 · session session_01DiCSbmJrkzNhuEAier4VoJ · follows prose-only REVISE 5949073444, under ruling 5942356310 (A / R2 / C1). ⛔ Classes and positions only.

    All three revisions are in bd9a13343, read against the diff:

    1. flows.mdx:274 now scopes the sentence to a flow saved through the metadata API. A packaged flow keeps its literal in its package source: the literal is withheld when the definition is served, and a channel row for that flow, where one exists, wins when the engine verifies or signs. The widening to "signs" is right: http-nodes.ts asks holdsFlowCredential / resolveFlowCredential before the literal.
    2. The changeset's ⚠️ paragraph now names both triggers: the first boot with a crypto provider, or a provider registering after a boot without one.
    3. The PR body's Size sentence now reads "1,578 of the lines are added test lines" (line 102).

    No code changed this round. origin/main was merged at be755def9, with no conflicts.

    Seat ACCEPT, contract review still owed. Clause-②: yes (widening), and the diff carries a packages/spec registry line, so the PR now carries needs:contract-review and stays draft. An isolated CONTRACT_REVIEW_TIER review of record is dispatched on this head. PASS ⇒ the seat removes the marker, readies the PR and arms auto-merge once CI is green. FAIL ⇒ a patch round.

    Out-of-scope items (in the PR's Acceptance notes, none filed, no reach measured):

  13. objectstack-fleet commented on Oct 2, 2026

    @objectstack-fleet
    ContributorAuthor

    Contract review

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

    PR #21377 (claude/issue-20790-flow-hook-secret-seam, draft, 36 files, +3443 / −89) on card #20790, under rulings 5909514730 (Q1 B · Q2 A · Q3 A · Q4 A), 5910098951 (17.x) and 5942356310 (A / R2 / C1). Inputs: the card body and every comment, the PR body, its file list and the net diff against main at this head, and the check-runs on this head. ⛔ Classes and positions only: no value, request, header or path is named here.

    Gate verdicts on this head. Every check-run on the head has completed. Every required context (Lint & Repo Gates, TypeScript Type Check, the Test Core shards, the Dogfood Regression Gate shards, Build Core, Temporal Conformance, Governed Surface Queue Guard) and every other gate-carrying run concluded success. Skips: Console Pin Gate and Packed-tarball smoke (expected), plus the re-triggered Auto Label and Check PR Size jobs on the later workflow run, whose first runs on this same head concluded success. No run is red. Governed surfaces: the file list touches none; head repo equals base repo. Size 3,532 changed lines, under 5,000; over the ruled 2,300 ± 500 band with the ruling's ⛔ no-split standing.

    ① Derived judgments

    Public surface — each a widening, each right under ruling A / R2 / C1:

    • @objectstack/spec: one data line, PLATFORM_OBJECTS_BY_PACKAGE['service-automation'] lists sys_flow_credential (ruling A, the fix(spec): register sys_environment_credential in CLOUD_PROVIDED_OBJECT_NAMES #18851 precedent). Right.
    • @objectstack/metadata-protocol: exported type MetadataCredentialChannel; public method registerCredentialChannel(type, channel) on the protocol class beside the authoring gate; SysMetadataRepository.restoreVersion gains the optional deriveRestoredBody, shaped like the promote verb's option (R2). Right.
    • @objectstack/service-automation: the platform object SysFlowCredential (one secret-typed value field, access default private, apiEnabled: false, apiMethods: [], trackHistory: false, searchable: false, a unique index on a bounded position digest); FlowCredentialChannel, its two error classes, two constants and two types; migrateFlowCredentialsIntoChannel with its id and result type; FlowCredentialSource and FlowCredentialHolding; on AutomationEngine the four methods setFlowCredentialSource, holdsFlowCredential, resolveFlowCredential, flowCredentialHoldings; FlowTriggerBinding gains the optional resolveSecret. Right: one seam ([security] The webhook signing secret is stored in cleartext in sys_webhook.definition_json #7799, resolveSecretField), no second mechanism, no per-door redaction added.
    • @objectstack/trigger-api: FlowTriggerBinding.resolveSecret (optional); a post whose held secret cannot be read answers 503 SERVICE_UNAVAILABLE before verification and enqueues nothing; an unknown hook still answers 404 before any read. Right.
    • @objectstack/runtime: flow-clone.ts exports the C1 refusal (409 RESOURCE_CONFLICT), its holding types and the literal-holdings fallback through the registered flow redactor; the clone handler consults it before the same-name refusal. Right.

    Accept set:

    • Authoring is unchanged and 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 round-trip rule holds: an explicit value rotates, absent keeps (the runtime gate reads the channel's held positions as present, on an active save and on a draft promotion), the cleared form '' clears and is stored as written, a vanished position drops its row. Right.
    • Stored shape: the stored row, every new history row and the content hash carry no credential; the served shape is the one [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 already defined, so no authorable spelling is retired. Right.
    • Save door order: authoring gate, carry-forward, channel store, put; with no crypto provider the channel refuses 503 before any row, channel or stored, is written. Right, and ruled. The channel write preceding the put is declared; a put that then fails leaves the new value live, which neither leaks nor fails open.
    • R2: rollback and revert derive the restored body through the channel's strip inside the restore verb, before the put hashes it; the pre-move history row keeps what it recorded (Q1 B, append-only); the channel keeps its current credential. Right.
    • C1: the clone door refuses a source holding a credential at any table position — a literal (a packaged flow) or channel-held, the outbound signing secret included (Q4 A) — naming classes and the prescription, never a value; the stale [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 clone pin was re-pinned to the refusal; the no-credential control still clones. Right; the accepted cost (a packaged inbound flow is not cloned in one step) is stated in the changeset and the lifecycle doc.
    • Q1 B migration: stored rows still carrying an explicit credential are re-saved through the save door itself (source: migrate-stored, at their own state and package); one ROTATE notice per flow at warn, naming the flow and the class; a row the door refuses for another reason logs at error with the consequence (still cleartext at rest) and the fix; a no-provider refusal defers the whole run with nothing written and no receipt; an applied run writes the sys_migration receipt flow-credential-channel (verified_at null, blocking 0, names only). History and audit rows untouched; packaged flows untouched (Q3 A). Right.
    • Q3 A at the outbound http node: index-gated — the node asks the channel only when holdsFlowCredential says the position is held, otherwise the literal signs; a held secret that does not come back refuses the node and nothing is sent unsigned; the cleared form never asks. Right.
    • Q3 A at the inbound door: WRONG in one composition. AutomationEngine.hookSecretResolver asks the channel on every post whenever a literal is present (resolve(...) ?? literal), without the holds gate the http node applies; and FlowCredentialChannel.resolve THROWS when it has no data engine (and lets a failed row read propagate), although the FlowCredentialSource.resolve contract this PR writes says undefined means the channel holds nothing there and only a HELD credential that does not come back throws. On a composition where the automation plugin resolves no objectql or data service, or where the channel's object is not registered on the engine it found, a packaged inbound flow registers and arms on its literal and is then answered 503 on every post — a flow Q3 A leaves untouched ("otherwise the bound definition's value"). Every shipped composition carries ObjectQL, so reach is not measured here (read-only), but the shape is structural, it is the one asymmetry between the two Q3 A doors in this diff, and the engine-level pin for the literal fallback uses an in-memory source that answers undefined, so the real channel's no-engine branch is unpinned. Fix, one of: in hookSecretResolver, when the definition carries a usable literal and source.holds(flowName, startNodeId, key) is false, return the literal without asking (the http node's shape; the index refreshes on every local write and on metadata:reloaded, so an overlay row still wins at the next read); or have FlowCredentialChannel.resolve answer undefined when it has no engine, per its own contract. Pin: the real FlowCredentialChannel with a resolveEngine that answers undefined, a literal api flow registered on an engine using it as its source, and binding.resolveSecret() resolving to the literal (trigger-api 202).

    ② Semver level

    • .changeset/20790-flow-credential-channel.md: minor for @objectstack/spec, @objectstack/service-automation, @objectstack/metadata-protocol, @objectstack/trigger-api, @objectstack/runtime — exactly the five released packages whose surface the diff widens (packages/qa/dogfood is the harness: a dev dependency, a vitest source alias, three lockfile lines). Matches.
    • Clause-②: yes (widening) on PR body line 2 and in the changeset; yes takes at least minor, met. No authorable key, export, config field or stored spelling is removed or renamed, so no (narrowing) arm is owed: the two new refusals (C1 at the clone door, no-provider at the save door) are state-conditioned refusals that ruling 5942356310 read as non-breaking with both costs stated, and the changeset states them. Right.
    • ADR-0087: one marker, not-required (no-migration-prescription), beside the rotation instruction (Q1 B) that names both triggers of the move (the first boot with a provider, or a provider registering after a boot without one). The marker reads as non-breaking; Lint & Repo Gates, which carries the ADR-0087 registration and changeset checks, concluded success. Right.
    • Spec artifacts: a constant's value changed, no describe text, export or authorable key; TypeScript Type Check (the spec generated-artifact gates) concluded success. Right.

    ③ Boundary flags

    Dev flags (reports 5948978079 and 5949953593, PR Acceptance notes), each answered:

    • Size over the ruled band, under 5,000, no split — ruled ⛔ no split; answered.
    • packages/runtime/src/domains/automation.ts outside revision 5942559287's list — inside claim 5935167060, where the clone handler must consult C1; covered by the as-built claim revision 5949091357; answered.
    • The protocol's held-position read on a draft promotion — the ruling's publish-gate restored-credential read, needed for a draft whose secret the channel holds to be publishable at all; answered, right.
    • Dogfood harness and four generated artifacts (tenancy census, tenant-audit census page and counts, engine-double-contract ledger), each written by its own gate — Lint & Repo Gates success on the head; answered.
    • Package duplication refused by the runtime authoring gate when a channel-held inbound flow is in the package — consistent with C1 (a copy holds no secret and never shares one); answered, not widened.
    • Inert migration mode and a legacy draft published with no provider both refused 503 — the no-provider rule at those doors; measured by the dev, the hunks lie outside this diff; answered.
    • Channel keying env-wide by flow name and state — consistent with the engine's bare-name flow map and allowOrgOverride: false on flows: same-named stored rows in two packages resolve to one armed flow, so one slot; answered.
    • Receipt write not on the durability-critical callee list — an advisory receipt (blocking 0, verified_at null), and its failure logs at error with consequence and fix; right level; answered.
    • Write order (channel before put) and the per-process presence index (refreshed at boot, kernel ready, reload and local writes; the value always read live) — declared; answered, and that index is the gate the ① fix should use.
    • Premise shift (the MCP stdio door was already projected by fix(mcp)!: the MCP stdio engine-only reader joins the stored-metadata-body family (exit one) #21228; the administrator engine read still returned both credentials) — stated, not chosen silently; the ruling's MCP stdio pin is served by the dogfood pin's administrator engine read, the reader that transport serves from; answered. [security] Stored-metadata-body family: two exits #21120 did not reach. An engine-only door serves an administrator credential material in cleartext, and the data door serves a content hash computed over the withheld credentials #21207 stays open for the other types, not widened.
    • origin/main ahead at derivation — main was merged into the head; the queue rebuilds the merge; answered.
    • PR body Evidence heading still names head 80b4647b2 — this head's measurements are in report 5949953593 and in the check-runs; cosmetic; the patch round may refresh it.
    • Round-2 narrowed local gate run (73 unrun, all green at the prior head) — the full farm on this head is the gate verdict; answered.
    • Attribution reminder vs the model-free trailer pair — the form AGENTS.md keeps; right.

    open_questions: empty on both reports for this head. Every earlier fork (Q1–Q4; Q-A, Q-R, Q-C) carries a maintainer ruling on the card and is executed as ruled.

    Escalated: nothing that needs a decision. The one ① finding is a patch round on this branch, after which the new head owes a fresh record.

    Implemented-by: claude/issue-20790-flow-hook-secret-seam
    Reviewed-by: cse_01DiCSbmJrkzNhuEAier4VoJ

    VERDICT: FAIL

    One derived judgment is wrong: the inbound door's Q3 A literal fallback is refused on a composition where the credential channel has no reachable store, against the contract the PR itself writes for FlowCredentialSource.resolve and against the http node's own shape. Every public-surface and semver judgment is right; the fix is one gate and one pin.


    Generated by Claude Code

  14. objectstack-fleet commented on Oct 2, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 20790,
    "status": "done",
    "branch": "claude/issue-20790-flow-hook-secret-seam",
    "pr": "#21377",
    "session": "session_01DiCSbmJrkzNhuEAier4VoJ — mode:subagent, the dispatching domain:services seat 2 session id",
    "premise_still_valid": true,
    "summary": "Patch round for contract review 5950334951 (FAIL, one finding: Q3 A at the inbound door). Commit 84d3d29 fixes AutomationEngine.hookSecretResolver in the http node's shape, so both doors read one rule. With a literal start-node secret, the reader asks the channel only when holds() reports that position; otherwise it answers the literal without touching the channel. A held secret that does not come back still rejects, so trigger-api answers 503 and never verifies against the literal. FlowCredentialChannel.resolve keeps its throw with no engine, as ordered. The FlowCredentialSource contract text now says that callers with a fallback ask resolve only for a held position. The stale-index meaning is stated in the code and in the PR body: a row written after the last index refresh loses to the literal until the next refresh, as at the http node. Pins: (a) and (b) at engine level with the real channel (service-automation) and through the real ApiTrigger (dogfood tests 9 and 10); (c), the existing Q3 A pin, stays green. origin/main was merged (417ba1f). The PR body gained a 'Contract review round 1' section, and its Evidence heading now names 417ba1f.",
    "tests": "Fix commit 84d3d29. Pins: service-automation flow-credential-channel.test.ts plus api-trigger-secret-channel.test.ts, 17 passed. Dogfood flow-credential-channel.dogfood.test.ts, 10 passed (tests 9 and 10 new). Ablations at 84d3d29, each in ablate-r3.sh under os-verify-lock with a trap restore. Each mutated engine.ts through scripts/ablation-replace.mjs (anchor hit, marker count 1 in src), ran the SA pins, rebuilt service-automation, found the marker in dist with ablation-dist-preflight, ran the dogfood pin, then restored. Each restore was proven: marker count 0, file blob equal to the HEAD blob d599cce4f, preflight --absent passed, SA 17/17 and dogfood 10/10 green, git diff HEAD empty. E1 (holds gate removed): SA red 1 of 17 ('with no reachable store, the literal verifies...'); dogfood red 1 of 10 (test 9: expected 503 to be 202). E2 (a held but unreadable secret falls back to the literal): SA red 1 of 17 (the control resolved to the literal instead of rejecting); dogfood red 1 of 10 (test 10: expected 202 to be 503). At merged head 417ba1f, under the lock: service-automation full suite 166 files, 2053 passed; trigger-api 2 files, 30 passed; dogfood pin 10/10; service-automation typecheck (incl. test-typecheck) and dogfood typecheck exit 0. Builds at 417ba1f: the dogfood, lint and client-react closures plus trigger-api (64/64 tasks), and seven packages whose dist the dual-build gate reads (35/35, cached).",
    "gates": {
    "derived_at": "417ba1fa6",
    "command": "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands",
    "derived": 130,
    "ran": 130,
    "nonzero_final": 0,
    "not_measured": 0,
    "reconcile": "Run reconciliation — 130 derived, 130 run, 0 NOT-MEASURED, 0 UNRUN.",
    "prerequisite_reruns": "check:dual-build-cjs-loads first answered PREREQUISITE NOT MET (exit 3, seven packages with no dist in the fresh worktree). After those packages were built, it was re-run and exited 0. It is recorded as not measured, not as a failure.",
    "stale_note": "None: the derivation at 417ba1f flagged no stale tree.",
    "ci_at_report": "in_progress — check runs on 417ba1f: 17 completed, 0 failed, 23 in progress"
    },
    "line_budget": {
    "changed_lines": 3646,
    "added": 3557,
    "removed": 89,
    "files": 36,
    "split": {
    "implementation_src": "+1762 -39",
    "tests": "+1678 -11"
    },
    "ruled_band": "2300 ± 500",
    "threshold": 5000,
    "over_band": true,
    "split_needed": false,
    "this_round": "+114 (engine +14 incl. contract text; pins +100)"
    },
    "deviations": [
    "Beyond the ordered gate, the FlowCredentialSource interface doc gains one sentence: with no reachable store resolve throws, so callers with a fallback ask it only for a held position. This makes the contract text match the channel's kept throw, which is what the review found contradictory. No signature changed.",
    "The trigger-api legs of pins (a) and (b) live in the dogfood pin file (tests 9 and 10). service-automation does not depend on trigger-api, and dogfood already carries both. The tests take the channel class from the live engine's source instead of importing service-automation, which dogfood does not declare.",
    "The PR body's Evidence heading now names 417ba1f, as ordered. The lines under it keep their own inline shas (80b4647 for the runtime, metadata-protocol and spec suites). This round's measurements are in the new 'Contract review round 1' section."
    ],
    "files_changed": [
    ".changeset/20790-flow-credential-channel.md",
    "content/docs/automation/flows.mdx",
    "content/docs/concepts/metadata-lifecycle.mdx",
    "content/docs/permissions/tenant-audit-census.mdx",
    "docs/audits/2026-08-tenant-audit-write-call-sites.counts.md",
    "packages/metadata-protocol/src/index.ts",
    "packages/metadata-protocol/src/protocol.credential-channel.test.ts",
    "packages/metadata-protocol/src/protocol.ts",
    "packages/metadata-protocol/src/sys-metadata-repository.ts",
    "packages/qa/dogfood/package.json",
    "packages/qa/dogfood/test/flow-credential-channel.dogfood.test.ts",
    "packages/qa/dogfood/vitest.config.ts",
    "packages/runtime/src/domains/automation-flow-clone-credential.test.ts",
    "packages/runtime/src/domains/automation-flow-credential-projection.test.ts",
    "packages/runtime/src/domains/automation.ts",
    "packages/runtime/src/flow-clone.ts",
    "packages/services/service-automation/src/activation-ledger-registration.test.ts",
    "packages/services/service-automation/src/api-trigger-secret-channel.test.ts",
    "packages/services/service-automation/src/builtin/http-node-channel-signing.test.ts",
    "packages/services/service-automation/src/builtin/http-nodes.ts",
    "packages/services/service-automation/src/engine.ts",
    "packages/services/service-automation/src/flow-credential-channel.test.ts",
    "packages/services/service-automation/src/flow-credential-channel.ts",
    "packages/services/service-automation/src/flow-credential-migration.test.ts",
    "packages/services/service-automation/src/flow-credential-migration.ts",
    "packages/services/service-automation/src/flow-credential-projection.ts",
    "packages/services/service-automation/src/index.ts",
    "packages/services/service-automation/src/plugin-suspended-run-wiring.test.ts",
    "packages/services/service-automation/src/plugin.ts",
    "packages/services/service-automation/src/sys-flow-credential.object.ts",
    "packages/spec/src/system/constants/platform-object-names.ts",
    "packages/triggers/trigger-api/src/api-trigger.test.ts",
    "packages/triggers/trigger-api/src/api-trigger.ts",
    "pnpm-lock.yaml",
    "scripts/engine-double-contract.pinned.json",
    "scripts/platform-object-tenancy-census.json"
    ],
    "files_changed_this_round": [
    "packages/services/service-automation/src/engine.ts",
    "packages/services/service-automation/src/flow-credential-channel.test.ts",
    "packages/qa/dogfood/test/flow-credential-channel.dogfood.test.ts"
    ],
    "mcp_calls": "0 — no MCP tool was called. GitHub reads used the REST CLI: the review comment, PR 21377 before and after the patch, and a check-runs snapshot.",
    "api_writes": "2 — both through the fleet-write relay (POST /repos/objectstack-ai/objectstack/dispatches): (1) issue_patch of PR 21377's body (Contract review round 1 section added, Evidence heading sha updated), read back identical, 12535 bytes sent and stored; (2) this os-dev-report comment on #20790 via scripts/pm/post-stamped.mjs. Branch pushes (be755de..84d3d29, then ..417ba1f) went through git, not REST.",
    "open_questions": [],
    "out_of_scope_findings": [],
    "cleanup": "After this comment posts: the worktree's node_modules is removed first, then git worktree remove, with no --force. No dev server or monitor was started."
    }


    Generated by Claude Code

  15. objectstack-fleet commented on Oct 2, 2026

    @objectstack-fleet
    ContributorAuthor

    Contract review

    Served-tier: CONTRACT_REVIEW_TIER
    Head-sha: 417ba1fa6fc6399a6bc077459c45d239542245e1
    Local-runs: none

    Round 2 on PR #21377 (claude/issue-20790-flow-hook-secret-seam, draft, 36 files, +3557 / −89) for card #20790, under rulings 5909514730 (Q1 B · Q2 A · Q3 A · Q4 A), 5910098951 (17.x) and 5942356310 (A / R2 / C1). Inputs: the card body and every comment (the round-1 record 5950334951 and the patch report 5951747088 read as inputs, not as verdicts for this head), the PR body, its file list and the net diff against main at this head, and the check-runs on this head. ⛔ Classes and positions only: no value, request, header, route or field path is named here.

    Gate verdicts on this head. Every one of the 43 check-runs on the head has completed: 39 success, 4 skipped, none red. Every required context concluded success — Lint & Repo Gates, TypeScript Type Check (with its workspace, source-gate, consumer-gate and debt-ledger legs), Test Core (and all six shards), Dogfood Regression Gate (and all three shards), Build Core, Temporal Conformance (live PG + MySQL) and Governed Surface Queue Guard — as did every other gate-carrying run (Build Docs, Dogfood Verify CLI, Check Changeset, Spec property liveness, Validate Package Dependencies, Check Documentation Links, Flag docs affected by code changes, and the claim and single-writer guards). The four skips are the expected ones: Console Pin Gate and Packed-tarball smoke, plus the re-triggered Auto Label and Check PR Size jobs of the later workflow run, whose first runs on this same head concluded success. Governed surfaces: the file list touches none of the register's paths; head repo equals base repo; the PR is draft with auto-merge unarmed. Size 3,646 changed lines, under 5,000; over the ruled 2,300 ± 500 band with the ruling's ⛔ no-split standing.

    ① Derived judgments

    The round-1 judgment, re-judged on this head: resolved. Round 1 found Q3 A wrong at the inbound door: with a literal start-node secret the hook reader asked the channel on every post, and the channel's read throws with no reachable store, so a packaged inbound flow on a composition with no data engine armed on its literal and was answered 503 on every post. On this head AutomationEngine.hookSecretResolver reads one rule with the http node: with a literal it asks the channel only when the channel's index reports that position held (holds), otherwise it answers the literal without touching the channel; a held secret that does not come back still rejects (FlowCredentialChannel.resolve keeps its throw), so trigger-api answers 503 and never verifies against the literal; a held row that resolves to nothing falls back to the literal; a withheld position yields a reader only when held; a cleared or unusable one yields none, so the registration refusal stands for those. The FlowCredentialSource contract text now states the throw with no store and the rule a caller with a fallback follows, so the contract and the channel agree. Pins: the real channel with a resolver that answers no engine, a packaged literal flow, the binding's resolveSecret() answering the literal (service-automation) and a correctly signed post answering 202 through the real ApiTrigger (dogfood test 9); the control, a held position whose store becomes unreachable, rejecting with FlowCredentialUnresolvableError and the post answering 503 whether signed with the literal or the held value (service-automation and dogfood test 10); the existing Q3 A pin (a held row wins over the literal) still green under the new rule. The stale-index meaning (a row written after the last refresh loses to the literal until the next refresh at boot, kernel ready, metadata reload or a local write) is stated in the code and the PR body, and is the http node's shape. Right.

    Public surface — each a widening, each right under ruling A / R2 / C1:

    • @objectstack/spec: one data line, PLATFORM_OBJECTS_BY_PACKAGE['service-automation'] lists sys_flow_credential (ruling A, the fix(spec): register sys_environment_credential in CLOUD_PROVIDED_OBJECT_NAMES #18851 precedent). Right.
    • @objectstack/metadata-protocol: exported type MetadataCredentialChannel (store, heldPaths, strip); public method registerCredentialChannel(type, channel) on the protocol class beside the authoring gate, singular or plural type name, a second registration replacing the first; SysMetadataRepository.restoreVersion gains the optional deriveRestoredBody, shaped like the promote verb's option and applied inside the same call before the put hashes it (R2). Right.
    • @objectstack/service-automation: the platform object SysFlowCredential (one secret-typed value field, access default private, apiEnabled: false, apiMethods: [], trackHistory: false, searchable: false, engine-owned, a unique index on flow name, state and a bounded position digest); FlowCredentialChannel, its two error classes carrying the ADR-0112 pair SERVICE_UNAVAILABLE / 503, the object and value-field constants and two types; migrateFlowCredentialsIntoChannel with its receipt id and result type; FlowCredentialSource and FlowCredentialHolding; on AutomationEngine the four methods setFlowCredentialSource, holdsFlowCredential, resolveFlowCredential, flowCredentialHoldings; FlowTriggerBinding gains the optional resolveSecret; the projection module gains the class-label table and the position walk (flowCredentialPositions, stripFlowCredentialValues), the same table and region walk the read projection uses, so the write channel and the read projection cannot disagree about where a credential is. Right: one seam ([security] The webhook signing secret is stored in cleartext in sys_webhook.definition_json #7799, resolveSecretField), no second mechanism, no per-door redaction added.
    • @objectstack/trigger-api: FlowTriggerBinding.resolveSecret (optional); start arms a binding that carries a reader and no literal, and still refuses one that carries neither; every post reads the secret at that moment, so a rotation applies to the next post and nothing re-arms; a secret that cannot be read, is absent or is blank answers 503 SERVICE_UNAVAILABLE before verification and enqueues nothing; an unknown hook still answers 404 before any read, so there is no oracle. Right.
    • @objectstack/runtime: flow-clone.ts exports the C1 refusal (409 RESOURCE_CONFLICT), its holding types and the literal-holdings fallback through the registered flow redactor (a host composing another engine, which has no channel and so cannot hold a channel-held one); the clone handler consults it before the same-name refusal and registers nothing on a refusal. Right.

    Accept set:

    • Authoring is unchanged and 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 round-trip rule holds: an explicit value rotates, absent keeps (the runtime gate reads the channel's held positions as present, on an active save and on a draft promotion), the cleared form clears and is stored as written, a vanished position drops its row, so a credential never outlives its position. Right.
    • Stored shape: the stored row, every new history row and the content hash carry no credential, at every depth a node can sit (the loop-body pin); the stored form is byte-identical to what a read serves, so no authorable spelling is retired. Right.
    • Save door order: authoring gate, carry-forward, channel store, put. The channel runs last before the put, so a credential a pre-move row still held moves with the save instead of being dropped. With no crypto provider the channel refuses 503 before any row, channel or stored, is written (the engine's fail-closed secret write becomes the save's refusal; a body with no credential still saves). Right, and ruled. The channel write preceding the put is declared; a put that then fails leaves the new value live, which neither leaks nor fails open.
    • Compositions: sys_flow_credential rides the run-object manifest registration and is registered alone under suspendedRunStore: 'memory', so every composition has the table; the channel joins the protocol at start() before the inert-mode return, so the stored re-save tool never stores a credential back into a definition; a protocol with no channel registration is named loudly. Right.
    • R2: rollback and revert derive the restored body through the channel's strip inside the restore verb, before the put hashes it; the restore appends one history row carrying none; the pre-move history row keeps what it recorded (Q1 B, append-only); the channel is not written and keeps its current credential; a type with no channel restores byte for byte as before. Right.
    • Draft to active: a draft save writes draft-state rows and never rotates the live credential; the publish materializer promotes a draft row over the live one per position, keeps a live row the draft left withheld, drops a live row the draft cleared, and consumes every draft row; the publish gate reads the draft's held positions (a draft row, or the live one the promotion keeps) as present. Right.
    • Delete: the mutation projector prunes the channel rows of every state whose stored row is gone, so a later flow of the same name inherits nothing, and the withheld form re-saved under that name is refused by the publish gate. Right.
    • C1: the clone door refuses a source holding a credential at any table position — a literal (a packaged flow) or channel-held, the outbound signing secret included (Q4 A) — naming classes and the prescription, never a value; nothing is registered on a refusal; the no-credential control still clones; the stale [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 clone pin is re-pinned to the refusal. Right; the accepted cost (a packaged inbound flow is not cloned in one step) is stated in the changeset and the lifecycle doc.
    • Q1 B migration: stored rows still carrying an explicit credential are re-saved through the save door itself (source: migrate-stored, at their own state, package and organization); one ROTATE notice per flow at warn, naming the flow and the class; a row the door refuses for another reason logs at error with the consequence (still cleartext at rest) and the fix, and the rest still move; the first no-provider refusal defers the whole run with nothing written and no receipt; an applied run writes the sys_migration receipt flow-credential-channel (verified_at null, blocking 0, the failure count as advisory, names only), warns that the log is the only record when the ledger object is not registered, and logs at error when the receipt write fails. It is scheduled at kernel ready and again on every crypto-provider registration, serialized, idempotent, never fatal, unsubscribed at destroy. History and audit rows untouched; packaged flows untouched (Q3 A). Right.
    • Q3 A at the outbound http node: index-gated — the node asks the channel only when holdsFlowCredential reports the position held, otherwise the literal signs; a held secret that does not come back refuses the node and nothing is sent unsigned; the cleared form never asks the channel and sends unsigned on purpose. Right, and now the same rule as the inbound door.
    • The presence index: loaded at start, kernel:ready and metadata:reloaded (awaited before the flow re-sync, so a flow and its held positions arrive together), kept current by every local write, promotion and prune, and corrected by every live read; a failed load warns loudly that held inbound flows are refused until the next load. Neither the index load nor the migration scan is bounded, and the engine's query defaults carry no bound, so neither truncates. Right.

    ② Semver level

    • .changeset/20790-flow-credential-channel.md: minor for @objectstack/spec, @objectstack/service-automation, @objectstack/metadata-protocol, @objectstack/trigger-api, @objectstack/runtime — exactly the five released packages whose surface the diff widens; packages/qa/dogfood is the harness (a dev dependency, a vitest source alias, three lockfile lines) and the generated ledgers publish nothing. Matches.
    • Clause-②: yes (widening) on PR body line 2 and in the changeset; yes takes at least minor, met. No authorable key, export, config field or stored spelling is removed or renamed, so no (narrowing) arm is owed: the two new refusals (C1 at the clone door, no-provider at the save door) are state-conditioned refusals that ruling 5942356310 read as non-breaking with both costs stated, and the changeset states them; the patch round changed no signature. Right.
    • ADR-0087: one marker, not-required (no-migration-prescription), beside the rotation instruction (Q1 B), which names both triggers of the move (the first boot with a provider, or a provider registering after a boot without one). The marker reads as non-breaking; Check Changeset and Lint & Repo Gates, which carry the changeset and ADR-0087 registration checks, concluded success on the head. Right.
    • Spec artifacts: a constant's value changed; no describe text, export or authorable key; the Type Check jobs that carry the spec generated-artifact gates concluded success. Right.

    ③ Boundary flags

    Dev flags (reports 5948978079, 5949953593 and 5951747088; the PR's Acceptance notes and its Contract review round 1 section), each answered:

    • Size 3,646 changed lines (+114 this round: the engine fix and two pins), over the ruled 2,300 ± 500 band, under 5,000, no split — ruled ⛔ no split; answered.
    • packages/runtime/src/domains/automation.ts outside revision 5942559287's list — inside claim 5935167060, where the clone handler must consult C1; covered by the as-built claim revision 5949091357; answered.
    • The protocol's held-position read on a draft promotion — the ruling's publish-gate restored-credential read, needed for a draft whose secret the channel holds to be publishable at all; answered, right.
    • Dogfood harness and four generated artifacts (tenancy census, tenant-audit census page and counts, engine-double-contract ledger), each written by its own gate — the counts page's new rows match the diff's write verbs per file; Lint & Repo Gates success on the head; answered.
    • Package duplication refused by the runtime authoring gate when a channel-held inbound flow is in the package — consistent with C1 (a copy holds no secret and never shares one); answered, not widened.
    • Inert migration mode and a legacy draft published with no provider both refused 503 — the no-provider rule at those doors; measured by the dev, the hunks lie outside this diff; with a provider the migration re-saves a legacy draft row at its own draft state at kernel ready or at provider registration, before an ordinary publish; answered.
    • Channel keying env-wide by flow name and state — consistent with the engine's bare-name flow map and allowOrgOverride: false on flows: same-named stored rows in two packages resolve to one armed flow, so one slot; the migration still passes each row's own organization; answered.
    • Receipt write not on the durability-critical callee list — an advisory receipt (blocking 0, verified_at null), its failure logged at error with consequence and fix; right level; answered.
    • Write order (channel before put) and the per-process presence index — declared in the code, the PR body and the changeset; that index is the gate both doors now use; answered.
    • Premise shift (the MCP stdio door was already projected by fix(mcp)!: the MCP stdio engine-only reader joins the stored-metadata-body family (exit one) #21228; the administrator engine read still returned both credentials) — stated, not chosen silently; the ruling's MCP stdio pin is served by the dogfood pin's administrator engine read, the reader that transport serves from; answered. [security] Stored-metadata-body family: two exits #21120 did not reach. An engine-only door serves an administrator credential material in cleartext, and the data door serves a content hash computed over the withheld credentials #21207 stays open for the other types, not widened.
    • Patch-round deviations: the FlowCredentialSource contract sentence (the throw with no store, and the rule a caller with a fallback follows) is the contract-text correction round 1 asked for, with no signature changed; the trigger-api legs of pins (a) and (b) live in the dogfood pin, which already depends on both packages and takes the channel class from the live engine's source — the right placement, since service-automation does not depend on trigger-api; the PR body's Evidence heading names this head while the inline suite lines keep their own earlier shas, with this head's measurements in the round-1 section and the report — cosmetic and consistent; answered.
    • origin/main merged at this head; the queue rebuilds the merge; answered.
    • Attribution reminder vs the model-free trailer pair — the form AGENTS.md keeps; right.
    • The dev's gate union at this head (130 derived, 130 run, 0 NOT-MEASURED, 0 UNRUN; the dual-build gate first recorded as not measured and re-run after a build) — the check-runs on the head are the verdict, read above; answered.

    open_questions: empty on all three reports for this head. Every earlier fork (Q1–Q4; Q-A, Q-R, Q-C) carries a maintainer ruling on the card and is executed as ruled.

    Escalated: nothing that needs a decision.

    Implemented-by: claude/issue-20790-flow-hook-secret-seam
    Reviewed-by: cse_01DiCSbmJrkzNhuEAier4VoJ

    VERDICT: PASS

    The round-1 judgment is resolved on this head: both doors now read the one Q3 A rule, with the contract text, the pins and the control to hold it. Every other derived judgment, the semver level and every boundary flag read right, and every gate on the head is green.

  16. objectstack-fleet commented on Oct 2, 2026

    @objectstack-fleet
    ContributorAuthor

    Landing: PR #21377 at 417ba1fa6 · 2026-10-02T12:24Z

    Seat domain:services#2 · session session_01DiCSbmJrkzNhuEAier4VoJ · under ruling 5942356310 (A / R2 / C1). ⛔ Classes and positions only.

    • The contract review of record, round 2, is PASS: 5952247884 (CONTRACT_REVIEW_TIER, Head-sha: 417ba1fa6…, the head unchanged).
    • Round 1's single finding (5950334951, Q3 A at the inbound door) is resolved. The inbound literal path asks the channel only for a position holds reports, which is the http node's rule. A held secret that does not come back still answers 503 and is never verified against the literal. Both directions are pinned at engine level and through the real ApiTrigger.
    • Pre-landing reads on this head:
      • 43 check-runs completed and 0 are red; the skips are the expected ones;
      • mergeable clean;
      • no governed path;
      • 3,646 changed lines, under 5,000. The ruling's no-split stands.
    • Landing now: needs:contract-review comes off, then pr_ready + automerge_enable through the queue.
    • After the merge: DELETE /api/v1/packages/:id answers success when the sys_packages delete fails, so the package is gone from the live registry and comes back after the next restart #21276 (deletePackage in the same protocol.ts) is no longer serial-blocked. Its claim follows. Rotation, per Q1 B, is the operator's action named in the changeset.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsdomain:servicesenhancementNew feature or requestpriority:p2Medium: important, M3security

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions