Repository navigation
feat(plugin-webhooks): declared webhook names resolve by (package, name) through the shared resolver - #31744
Conversation
…kage-scoped resolver A declared webhook's name is answered by resolvePackageScopedName as a caller with no package; a name two packages hold is not dispatched and the by-name read throws a 409 naming every candidate. The booted pin on a clash held from before phase 1 moves from the first-registered package to that refusal. Claude-Session: https://claude.ai/code/session_01ADHkmR4astMzES5UkW47jW Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 27 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 11 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 5052e40329ff0dea8bde5b26abf34a6fa77ad9b3 && git checkout 5052e40329ff0dea8bde5b26abf34a6fa77ad9b3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 90cea476dedee4bb2586f60b4f469bba67904cf3 7d39171db1c4e92ac8f7e877c7ac0cb2eb315d47 && git checkout -B drift-repro 90cea476dedee4bb2586f60b4f469bba67904cf3 && git merge --no-ff 7d39171db1c4e92ac8f7e877c7ac0cb2eb315d47
node scripts/docs-audit/affected-docs.mjs --json 90cea476dedee4bb2586f60b4f469bba67904cf3
|
|
epic:#31527#1 · os-steve · session_01ADHkmP7o3uDTti21wLTfPf Epic red-line review: E5 #31677, webhooks half, head 7d39171 · 2026-11-09T01:51Z Verdict: PASS.
The D9 backstop obligation recorded for connectors (6729233545) applies to webhooks as well: when B #31699 narrows phase 1, a composition with no resolver slice must fail closed. Email stays on R2 #31716 (Q2). Seat 3 accepts and lands this after green CI. It is not a governed surface. |
|
domain:server#3 · os-steve · session_01ADHkmQXLfvyB5R53VDm3Ka 席位核对 · ACCEPT · #31744 (Part of #31677, ADR-0150 E5 webhooks) · 2026-11-09T02:16ZHead-sha:
|
… the calling flow's package (objectstack-ai#31863) <!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9B9qYj7GTyDGfaMgXwXx7rZ)_ Fixes objectstack-ai#31677 Clause-②: no Metadata-protocol: none Narrowing: a `connector_action` in a packaged flow whose `connectorConfig.connectorId` does not resolve for the flow's package is refused, naming every candidate, instead of dispatching to whatever instance the engine holds under the name. Three sub-cases: the only holder is outside the flow package's declared closure (the closure-outside sub-case of objectstack-ai#31527 comment 6728487836, a sibling package in the same artifact included); the only instance is environment-authored (ADR-0150 D3 (a)); two packages in the closure hold the name. First-party count: 0 (every first-party packaged flow names its own package's instance or a connector a plugin registers in code; HotCRM 6961ab1, ADR-0150's census). This closes card objectstack-ai#31677 (ADR-0150 child E5, epic objectstack-ai#31527): it is the card's last PR. Connectors landed in objectstack-ai#31725, webhooks in objectstack-ai#31744, email templates in objectstack-ai#31825. This part is the remainder those PRs named: `connector_action` resolving `connectorConfig.connectorId` from the calling flow's package (seat answer 6728849524, Q3), now that E1 objectstack-ai#31738 gives a run its flow's package. **Epic review R1 (6735080780), fixed here:** the caller package now follows the body the engine ARMS under the flow's name, not the loader's set by bare name. An environment override of P's flow (a package-less stored row, or P's declared `.custom` override) names its references with no package, per ADR-0150 D3's overlay paragraph. That corrects E1's `subflow`/`map` reader too, back to main's answer before E1 for overrides. It is a loosening, not a new narrowing. Names were accepted by seat 3 (6735158321). Card thread `domain:server#3` · `os-steve` · `session_01ADHkmR4astMzES5UkW47jW`; claim 6728454873, file-surface amendments 6728498905 and 6728849524. ## 维护者速读 - 改了什么:flow 里的 `connector_action` 按名字找连接器实例前,先用 R 的共享解析器 `resolvePackageScopedName`,以「这个 flow 当前生效的定义所属的软件包」为调用方去问;解析器拒绝时节点失败并列出全部候选。引擎在每次注册 flow 时记下生效定义的来源(包本身、`.custom` 覆盖、环境自建),只有包本身的定义才以该包为调用方;覆盖和环境自建一律按无包调用方解析(E1 的 subflow 也一并纠正)。 - 为什么改:ADR-0150 把连接器改为按(软件包, 名称)寻址;以前包里的 flow 会派发到引擎里同名的任何实例,哪怕它属于闭包外的包或只是环境自建。 - 风险与回滚:只影响「包内 flow 指向自己够不着的已声明连接器」的部署:只有闭包外的包持有、只有环境自建、或闭包内两个包同名。第一方计数 0。环境自建的 flow、代码注册的连接器(slack、rest 等)照常派发。回滚即还原本 PR。 - 先看:`engine.ts` 的 `armedFlowPackage` 和 `registerFlow` 的 `armedFrom`,`flow-precedence.ts` 里识别协议视图下发的覆盖文档那一段,再看 `connectorNameRefusal`。 - 两分钟验证:`pnpm --filter @objectstack/service-automation exec vitest run src/flow-package-scoped-resolution.test.ts -t "connector_action|environment override"`,预期 17 条通过:连接器的 8 条,加上三种覆盖形态各自的 connectorId 与 subflow 都按无包解析(6 条)、包本身定义的两条对照、写入口的一条。 - 需要你做的:无,席位落地。本 PR 合并后 objectstack-ai#31677 关闭。 ## Step 0: where the flow's package comes from | What | Where on `main` | Used here as | |:--|:--|:--| | The run's flow name | `AutomationContext.flowName`, set for every run `execute` starts | the key | | Which package ships that flow | `AutomationEngine.packagedFlowOwner(name)`, fed by `setPackagedFlowSource(packagedFlowReader(ctx))` (E1 objectstack-ai#31738; the protocol's `packagedArtifactOwner`) | the caller package | | Both together | private `flowCallerPackage(context)` (E1): `context.flowName` → `packagedFlowOwner`, `null` for an environment-authored flow or a call outside a run | **corrected (R1)**: `context.flowName` → `armedFlowPackage` | | Which body is armed under the name | `resolveFlowPrecedence` → `classifyContender` at the boot pull, the `kernel:ready` bind, the `metadata:reloaded` re-sync and the mutation sync; the class was dropped after the pick, and a single-contender name was never classified | each winner carries its `contender`, recorded at `registerFlow` | Measured on the real composition (`flow-declared-override-first-boot-pull.integration.test.ts`: ObjectKernel, ObjectQLPlugin over driver-sql/better-sqlite3, two cold boots): at the boot pull the declared override arrives as a stored row carrying its binding, so `classifyContender` already answers `override`. At `kernel:ready` the protocol's view serves it with no binding, stamped `_packageId` = the base beside `overrides.package` = the base, and `isCodeArtifactBody` answered **`package`**. That is the stamp trap; the classifier now recognises that shape. ## What changes - `engine.ts`: a `ConnectorNameResolver` type, `setConnectorNameResolver`, and `connectorNameRefusal(connectorId, context)`. It asks the resolver for the flow's package and returns the refusal when there is one. A caller with no package, a name the registry holds nothing under (a connector a plugin registers in code), or a host with no resolver returns `undefined` and dispatches by name as before. - `builtin/connector-nodes.ts`: `connector_action` checks `connectorNameRefusal` before `resolveConnectorAction` and fails the node with the resolver's message, prefixed with the node id. - `connector-package-scope.ts`: `connectorNameResolver(ctx)`, the ONE resolver (`resolvePackageScopedName`, type `connector`) over the registry ObjectQL serves, read at question time like E1's `flowNameResolver`. - `plugin.ts`: attaches it at `init()`, beside `setFlowNameResolver`. R1, the shared reader: - `engine.ts`: `registerFlow(name, definition, armedFrom?: FlowContender)` records the arm in a `WeakMap` keyed by the parsed body it stores. With no `armedFrom`, the arm is `runtime` (a write door's body is the caller's own). The exception is a body that IS the parsed object the engine armed (the `/automation` door putting back `held`), which keeps its arm by identity. `armedFlowPackage(name)` (public, read-only) returns `packagedFlowOwner(name)` only when the armed body's arm is `package`, else `undefined`. `flowCallerPackage` reads it. - `flow-precedence.ts`: every winner carries `contender`, single-contender names included. `classifyContender` also answers `override` for the served shape: `_packageId` equal to the owner AND `isServedOverrideRowOf` holding for the row the pair would store it as (the reading `servedOverrideBaseOf` makes, through the exported predicate). A package-less row that declares the same carries no envelope and stays `runtime`. - `plugin.ts`: the four arming steps pass `entry.contender`. Kept for the connector backstop child (ADR-0150 D9), as the review recorded: dispatch is still `resolveConnectorAction(connectorId)` by bare name, which is right only while phase 1's one-holder refusal keeps one instance per name. Why a refusal is the only answer that needs acting on: the engine holds one instance per name, materialized for the no-package answer (the declared-connector reconcile from objectstack-ai#31725 already refuses a name two packages hold). In the registry an environment item exists only for a name no package holds (a bare registration under a package-held name keeps the package holder), so a package caller whose name resolves at all resolves to the instance the engine serves. No second resolver, no first-registered fallback. | Reader on `main`, by symbol | Pick today | Caller package | After this PR | |:--|:--|:--|:--| | `connector_action` executor (`registerConnectorNodes`) → `engine.resolveConnectorAction(connectorId, actionId)` | the engine's one instance under the name | the flow's package (`flowCallerPackage`, E1) | a refusal for that package fails the node naming every candidate; any other answer dispatches as before | ## ADR-0150's four readings - **(a) phase-1 one-holder tests stay green.** The four `bare-name-one-holder*` dogfood files are in the full run below. - **(b) bare-name behaviour unchanged for every name with one holder the caller can reach.** Pinned: own package, the one closure holder, an environment-authored flow, a code-registered connector. The change is the declared narrowing above, plus R1's loosening for overrides back to main's pre-E1 answer. No URL, MCP or SDK surface moves. - **(c) no migration.** Nothing stored is read or written differently. - **(d)** `Clause-②: no`, `Metadata-protocol: none`; no ADR-0087 entry. ## Tests `service-automation/src/flow-package-scoped-resolution.test.ts`, new describe "ADR-0150 D3 — a connector_action names its connector from the calling flow's package (epic objectstack-ai#31527 child E5)", over a real `SchemaRegistry` with E1's install/ship/author helpers. 8 tests: four dispatch as before (own package, closure holder, environment-authored flow, code-registered connector); red line 1 (holder only outside the closure, refused naming it, nothing dispatched); D3 (a) (environment-only, refused); red line 2 (two closure holders, refused naming both); and a booted `AutomationServicePlugin` on a `LiteKernel` attaching the resolver at init. R1, same file, describe "ADR-0150 D3 — an environment override of a packaged flow names its references with no package". Each body is armed as the plugin arms it (`resolveFlowPrecedence`, then `registerFlow(…, winner.contender)`). There are three override shapes: a package-less row, the `.custom` override as a stored row, and the `.custom` override as the served envelope. Each is pinned for both `connectorId` (an environment-only instance is reached) and `subflow` (an environment-only flow is reached), 6 tests. Two CONTROLs: P's own body (a package arm) still answers P, so the same references are refused. One more pin: a write door's body is `runtime`, and putting back the held body restores its `package` arm. `flow-declared-override-first-boot-pull.integration.test.ts`, new describe on the real composition, 4 cases: the loader's body alone and beside a package-less row answer P; the declared override, alone or beside a row, answers no package. Each is checked at the boot pull and after `kernel:ready`. Changed existing test code: `flow-precedence-loader-set.test.ts` "asks the set once per contested name, and never for an uncontested one" becomes "once per name, contested or not" (see Verification). E1's `ship()` helper now arms with `{ source: 'package', packageId }`, as the plugin does, and the plugin pin arms P's `charge` before asking. Ablations (`scripts/ablation-replace.mjs`, run over both test files, each restored to the HEAD blob, `git diff HEAD` empty after; the suite imports `src`, so no `dist` leg): | Mutation | Result | |:--|:--| | `connectorNameRefusal` returns `undefined` for every answer (engine) | 3 failed: red line 1, D3 (a), red line 2 | | the `connector_action` refusal check removed (node) | 3 failed: the same three | | `setConnectorNameResolver(connectorNameResolver(ctx))` removed (plugin) | 1 failed: the plugin pin, `expected undefined to be 'refused'` | | R1: `flowCallerPackage` back to `packagedFlowOwner(caller)` (main's E1 reader) | 6 failed: every override shape × connectorId and subflow; both CONTROLs stay green | | R1: the served-envelope `override` branch disabled (classifier) | 4 failed: the served-shape connectorId and subflow pins, and the two real-kernel override cases at `kernel:ready` | | R1: the plugin's four `registerFlow(…, entry.contender)` calls without the arm | 2 failed: the real-kernel cases whose loader body must answer P | ## Verification Head c6a0810 (main 2e9121e merged in as 5e37bfd; c6a0810 adds one test-only commit on top): - `@objectstack/service-automation` test exit 0 (267 files, 3518 tests) and typecheck exit 0 at c6a0810, under `os-verify-lock` (one `&&` command, VERDICT command-exit 0). Root `eslint --no-inline-config packages/services/service-automation`: exit 0. - The first local run, at 5e37bfd, had 1 red: `flow-precedence-loader-set.test.ts` pinned "never asks the loader's set for an uncontested name". R1 classifies every name for its arm, so c6a0810 restates that pin as "once per name, an uncontested one included", and adds that the uncontested body's claimed id buys nothing. CI's Test Core 5/8 showed the same 1 red at 5e37bfd. - `turbo run build --filter='@objectstack/dogfood^...'`: 70/70. - Full dogfood run (owed for the declared narrowing), `pnpm --filter @objectstack/dogfood test` under the lock at 5e37bfd: VERDICT command-exit 0, Test Files 441 passed / 3 skipped (444), Tests 3662 passed / 42 skipped (3704). The only change since is that test file. - Derived gates at c6a0810 (the same 73 as at 5e37bfd): 73 derived, all 73 `exit 0`; `--ran`: "73 derived, 73 run, 0 NOT-MEASURED, 0 UNRUN" (a derived zero). - `test:repo` not run locally (CI's `Test Core`); this diff touches no repo-project test. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01ADHkmR4astMzES5UkW47jW <!-- signed: domain:server#3 · os-steve · session_01ADHkmR4astMzES5UkW47jW --> --------- Co-authored-by: Claude <noreply@anthropic.com>
Requested by Steve · project thread
Part of #31677
Clause-②: no
Metadata-protocol: none
Narrowing: D3 (a) declared by the card, measured 0 here (no reader in this PR has a caller package; first-party package-caller reads wired: 0, so the closure-outside sub-case of #31527 comment 6728487836 measures 0, sibling-in-the-same-artifact included). Beside it, ruled on #31677 (Q1 letter A, 6729831159): a declared webhook name two packages held from before phase 1 (the cold-boot seal's warn list) is refused like every other two-holder name instead of being dispatched from the first-registered package. First-party count: 0 (HotCRM 6961ab1, ADR-0150's census).
Part of epic #31527 (ADR-0150 child E5), webhooks half. Connectors landed in #31725. The email template half stays open on #31677 until R2 #31716 stops the resolver from counting a dependent's added locale (#28802) as a second holder.
Card thread
domain:server#3·os-steve·session_01ADHkmR4astMzES5UkW47jW; claim 6728454873, file-surface amendments 6728498905 and 6728849524 (Q4:plugin-webhooks/vitest.config.ts).维护者速读
resolvePackageScopedName(无包调用方):先环境自建项,再全环境唯一的包持有者,否则拒绝并列出全部候选。packages/plugins/plugin-webhooks/src/declared-webhooks.ts的chooseDeclaration、parseDeclared与declaredWebhookNamed;dogfoodbare-name-one-holder-flow-family.dogfood.test.ts中改动的两处断言。pnpm --filter @objectstack/plugin-webhooks exec vitest run src/declared-webhooks.test.ts -t "ADR-0150",预期 4 条通过,其中两个包同名的用例断言不派发、日志一次点名两个包、按名读取抛 409。What changes
Every reader here that turns a declared webhook name into one item now asks the ONE package-scoped resolver (
resolvePackageScopedName,@objectstack/objectql, R #31637) and never picks by list order. Each reader is context-free, so it asks as a caller with no package (ADR-0150 D3): the environment's own item, else the unique package holder, else a refusal naming every candidate.main, by symbolDeclaredWebhookSource.parseDeclared→list()(theAutoEnqueuerdispatcher)declaredWebhookNamed(redelivery vetojudgeDeclared, runtimecatalog-activation.tswebhook lookup,bindDeclaredWebhookNameGuard)DeclaredWebhookAmbiguousError(409RESOURCE_CONFLICT,candidates), so no door reads "not declared" for itNot touched:
readDeclaredWebhookNames(the boot report lists names only, picks no item). The runtime activation door maps the reader's throw to its existing 503 "could not be read" answer, naming the candidates, and writes nothing; mapping it to 409 is the runtime door's (G1 #31691).@objectstack/objectqlmoves fromdevDependenciestodependencies(claim amendment 6728498905). No cycle: objectql's workspace closure (deps, devDeps and peers, 14 packages) does not contain plugin-webhooks.Forced test change (the ruled narrowing):
packages/qa/dogfood/test/bare-name-one-holder-flow-family.dogfood.test.tspinneddeclaredWebhookPackage(HOOK) === 'com.qa.bare.alpha'(phase 1's first-registered answer for the clash twoAppPluginpackages hold at cold boot) in two tests. Both now pin the refusal instead: the by-name read throws 409RESOURCE_CONFLICTwith candidatescom.qa.bare.alphaandcom.qa.bare.beta, and the message names both. Nothing else in that file changed.ADR-0150's four readings
registry-bare-name-one-holder*suites pluspackage-scoped-resolver.test.ts: 187 passed (untouched by this PR). The fourbare-name-one-holder*dogfood files run in the targeted subset below, with the moved pin.declared-webhooks.test.tspins the one-holder case on a realSchemaRegistry(dispatches and carries its package), and the package's full suite stays green. The one change is the ruled narrowing above. No URL, MCP or SDK surface moves.Clause-②: no,Metadata-protocol: none; no ADR-0087 entry.Tests
plugin-webhooks/src/declared-webhooks.test.ts, new describe "a name resolves by (package, name) (ADR-0150 D3)": one holder dispatches; two holders dispatch nothing and warn once naming both; the by-name read throws the 409 naming both and the redelivery veto propagates it; a registry-less double listing a name twice reads as a refusal.vitest.config.tsgainsenv: { OS_REGISTRY_LOG: 'warn' }(the suite now builds a realSchemaRegistry;check:registry-log-declared).Ablations (
scripts/ablation-replace.mjs, mutation proved on disk, restored to the HEAD blob,git diff HEADempty after each; the suite importssrc, so nodistleg; measured atda3bc28e, whosedeclared-webhooks.ts/.test.tsare byte-identical to this head):listed[0]listed.length >= 1serveslisted[0]expected [ { webhook: … } ] to deeply equal []The dogfood pin's other side: the same two assertions read
com.qa.bare.alphaonmain(they were green there), so onmain's reader the new refusal assertion isundefinedand red.Verification
Head 7d39171 (on
main1a2361c + f2abd47, after #31725 and #31730):@objectstack/plugin-webhookstest exit 0 (25 files, 262 tests), typecheck exit 0, underos-verify-lock(one&&command, VERDICT command-exit 0). Rooteslint --no-inline-configoverpackages/plugins/plugin-webhooksand the edited dogfood file: exit 0 (the package has nolintscript).turbo run build --filter='@objectstack/dogfood^...'(70/70): 19 files (the four phase-1 bare-name families including the moved pin, email template boot/materialization/locale files, connector install/restart/package-ref files, declarative MCP, webhook materialization), 96 tests passed, VERDICT command-exit 0.dispatch-gates --commandsderived 86 families at 7d39171; 85 exit 0 on the first pass;check:dual-build-cjs-loadsexited 3 (its own "Runpnpm buildfirst … nothing was measured": it ran before a package's dist existed) and exit 0 on the rerun ("122 published require entry point(s) across 72 package(s) load").--ran: "86 derived, 86 run, 0 NOT-MEASURED, 0 UNRUN" (a derived zero).check:registry-log-declaredis green with the vitest config change.test:reponot run locally (CI'sTest Core); this diff touches no repo-project test.🤖 Generated with Claude Code
https://claude.ai/code/session_01ADHkmR4astMzES5UkW47jW