Skip to content

feat(plugin-webhooks): declared webhook names resolve by (package, name) through the shared resolver - #31744

Merged
objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/project-thread-bwr6nn
Nov 9, 2026
Merged

objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/project-thread-bwr6nn

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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).

维护者速读

  • 改了什么:声明式 Webhook 的派发和按名读取(激活入口、重投否决、名字守卫)不再按列表顺序取第一个,改为问 R 的共享解析器 resolvePackageScopedName(无包调用方):先环境自建项,再全环境唯一的包持有者,否则拒绝并列出全部候选。
  • 为什么改:ADR-0150 把 Webhook 改为按(软件包, 名称)寻址;以前两个包同名时静默派发先注册的那个。
  • 风险与回滚:只影响阶段一之前就已经有两个包同名的部署(启动时已有「Bare-name conflict at boot」警告),按维护者裁决 A,该 Webhook 不再派发,日志点名两个包,按名读取答 409;第一方计数 0。其余名字答案不变。回滚即还原本 PR。
  • 先看:packages/plugins/plugin-webhooks/src/declared-webhooks.ts 的 chooseDeclaration、parseDeclared 与 declaredWebhookNamed;dogfood bare-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.

Reader on main, by symbol Pick today Caller package After this PR
DeclaredWebhookSource.parseDeclared → list() (the AutoEnqueuer dispatcher) first listed item per name none resolver item; a refused name is not dispatched and is logged once naming every candidate
declaredWebhookNamed (redelivery veto judgeDeclared, runtime catalog-activation.ts webhook lookup, bindDeclaredWebhookNameGuard) first listed match none same answer; a refused name throws DeclaredWebhookAmbiguousError (409 RESOURCE_CONFLICT, candidates), so no door reads "not declared" for it
metadata-service reader (a host whose registry holds no webhook) first listed item none one item per name serves; a name listed twice is refused the same way

Not 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/objectql moves from devDependencies to dependencies (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.ts pinned declaredWebhookPackage(HOOK) === 'com.qa.bare.alpha' (phase 1's first-registered answer for the clash two AppPlugin packages hold at cold boot) in two tests. Both now pin the refusal instead: the by-name read throws 409 RESOURCE_CONFLICT with candidates com.qa.bare.alpha and com.qa.bare.beta, and the message names both. Nothing else in that file changed.

ADR-0150's four readings

  • (a) phase-1 one-holder tests stay green. objectql's four registry-bare-name-one-holder* suites plus package-scoped-resolver.test.ts: 187 passed (untouched by this PR). The four bare-name-one-holder* dogfood files run in the targeted subset below, with the moved pin.
  • (b) bare-name behaviour unchanged for every name with one holder. declared-webhooks.test.ts pins the one-holder case on a real SchemaRegistry (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.
  • (c) no migration. Nothing stored is read or written differently.
  • (d) 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.ts gains env: { OS_REGISTRY_LOG: 'warn' } (the suite now builds a real SchemaRegistry; check:registry-log-declared).

Ablations (scripts/ablation-replace.mjs, mutation proved on disk, restored to the HEAD blob, git diff HEAD empty after each; the suite imports src, so no dist leg; measured at da3bc28e, whose declared-webhooks.ts / .test.ts are byte-identical to this head):

Ablation Result
a refused name answers listed[0] 2 failed: "neither is dispatched…", "the by-name read throws the 409…"
registry-less: listed.length >= 1 serves listed[0] 1 failed: expected [ { webhook: … } ] to deeply equal []

The dogfood pin's other side: the same two assertions read com.qa.bare.alpha on main (they were green there), so on main's reader the new refusal assertion is undefined and red.

Verification

Head 7d39171 (on main 1a2361c + f2abd47, after #31725 and #31730):

  • @objectstack/plugin-webhooks test exit 0 (25 files, 262 tests), typecheck exit 0, under os-verify-lock (one && command, VERDICT command-exit 0). Root eslint --no-inline-config over packages/plugins/plugin-webhooks and the edited dogfood file: exit 0 (the package has no lint script).
  • Targeted dogfood after 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.
  • Derived gates: dispatch-gates --commands derived 86 families at 7d39171; 85 exit 0 on the first pass; check:dual-build-cjs-loads exited 3 (its own "Run pnpm build first … 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-declared is green with the vitest config change.
  • test:repo not run locally (CI's Test Core); this diff touches no repo-project test.

🤖 Generated with Claude Code

https://claude.ai/code/session_01ADHkmR4astMzES5UkW47jW

…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>
@github-actions github-actions Bot added size/m dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 9, 2026
@github-actions

github-actions Bot commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-webhooks, touching 13 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/plugins/plugin-webhooks/package.json, packages/plugins/plugin-webhooks/vitest.config.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

27 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 90cea476dedee4bb2586f60b4f469bba67904cf3.

⛔ 11 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

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

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

Which tree this was computed on

This run read content/docs from 5052e40329ff0dea8bde5b26abf34a6fa77ad9b3 — the merge of head 7d39171db1c4e92ac8f7e877c7ac0cb2eb315d47 into base 90cea476dedee4bb2586f60b4f469bba67904cf3, which is what actions/checkout gives a pull_request run. Not the PR head.

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

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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

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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

epic:#31527#1 · os-steve · session_01ADHkmP7o3uDTti21wLTfPf

Epic red-line review: E5 #31677, webhooks half, head 7d39171 · 2026-11-09T01:51Z

Verdict: PASS.

Red line Reading
1. Never loosens The dispatcher and the by-name reads ask resolvePackageScopedName as a caller with no package. A refused name is not dispatched. When the registry is absent, a name listed under two holders is refused and one listed item answers as before. The old "first registration wins" skip is gone.
2. Ambiguity reported, never guessed The single resolver answers. A refusal is warned once naming every candidate, and the by-name read throws DeclaredWebhookAmbiguousError (409 RESOURCE_CONFLICT, candidates). There is no first-registered fallback.
3. Independently releasable Per #31677 Q1 Ruled A (6729831159), phase 1's dogfood pin declaredWebhookPackage(HOOK) === alpha moves to the refusal, and the Narrowing: line declares the sealed clash beside D3 (a), first-party 0. The diff against current main touches only plugin-webhooks, the dogfood pin and the changeset; #31725 and #31730 are already on main. Gates 86/86.

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.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-steve · session_01ADHkmQXLfvyB5R53VDm3Ka

席位核对 · ACCEPT · #31744 (Part of #31677, ADR-0150 E5 webhooks) · 2026-11-09T02:16Z

Head-sha: 7d39171db1c4e92ac8f7e877c7ac0cb2eb315d47

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 9, 2026 02:17
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 9, 2026 02:17
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 9, 2026
Merged via the queue into main with commit f65c36f Nov 9, 2026
68 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-bwr6nn branch November 9, 2026 02:34
rajeev-kumar-se pushed a commit to rajeev-kumar-se/objectstack that referenced this pull request Nov 10, 2026
… 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/m tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants