Skip to content

feat(service-automation): declared connector instances resolve by (package, name) through the shared resolver - #31725

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

objectstack-fleet[bot] merged 5 commits 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.

Part of epic #31527 (ADR-0150 child E5), connectors only, per seat 3's split on the card (6728849524, epic seat 6728850663). Two remainders of #31677 stay open:

Card thread domain:server#3 · os-steve · session_01ADHkmR4astMzES5UkW47jW; claim 6728454873, file-surface amendment 6728498905.

维护者速读

  • 改了什么:声明式连接器实例在物化时,按名字取哪一条声明,改为问 R 的共享解析器 resolvePackageScopedName(无包调用方):先环境自建项,再全环境唯一的包持有者,否则拒绝并列出全部候选。
  • 为什么改:ADR-0150 把连接器改为按(软件包, 名称)寻址;以前两个包同名时只报「名字重复」,不点名是哪两个包。
  • 风险与回滚:阶段一的单持有者拒绝对 connector 仍然生效,今天不可能有两个包同名,所以每个名字的答案不变(service-automation 全量 3458 条全绿)。@objectstack/objectql 从开发依赖改为运行时依赖,无循环。回滚即还原本 PR。
  • 先看:packages/services/service-automation/src/connector-package-scope.ts,以及 plugin.ts 里 reconcileDeclaredConnectors 开头的拒绝循环。
  • 两分钟验证:pnpm --filter @objectstack/service-automation exec vitest run src/connector-materialization.test.ts -t "resolves by",预期 2 条通过、其余跳过,其中两个包同名的用例断言启动失败、错误信息点名两个包、工厂从未被调用。
  • 需要你做的:无,席位落地。

What changes

reconcileDeclaredConnectors (service-automation, the declared-instance index the engine's connectors map is filled from) now asks the ONE package-scoped resolver (resolvePackageScopedName, @objectstack/objectql, R #31637) which declared connector instance a name means, before it materializes anything. The reconcile reads the whole environment's declaration and has no caller package, 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
reconcileDeclaredConnectors desired map a second entry refused as "duplicate", no candidates named none a name the resolver refuses is not materialized; fail() names every candidate (fatal at boot for a host-declared entry, soft for a stored one, as the duplicate check already was)

Not touched, and why:

@objectstack/objectql moves from devDependencies to dependencies in service-automation (claim amendment 6728498905). No cycle: objectql's workspace closure (deps, devDeps and peers, 14 packages) does not contain service-automation. E1 #31672 makes the same move on the same lines; a trial merge of its branch onto this head is clean, and per the seat E1 lands first and this branch merges origin/main after it.

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; the four bare-name-one-holder* dogfood files are in the targeted run below. This PR does not touch objectql.
  • (b) bare-name behaviour unchanged for every name with one holder. The new suite pins the one-holder case on a real SchemaRegistry (the connector materializes with the holder's definition), and service-automation's full suite stays green. 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/connector-materialization.test.ts, new describe "a declared connector name resolves by (package, name)": one holder materializes; two holders fail the boot naming both, and the provider factory is never called. The harness's boot() gains an optional schemaRegistry (a real registry in place of the double). No existing test changed.

Ablation (scripts/ablation-replace.mjs, mutation proved on disk, restored to the HEAD blob, git diff HEAD empty after; the suite imports src, so no dist leg; measured at da3bc28e, whose service-automation tree is byte-identical to this head): refusedConnectorNames records no refusal → 1 failed: the boot still fails on the older duplicate-name check, but expected '[Automation] duplicate declarative co…' to contain 'connector instance \'billing\' is not…'. The candidates are what the pin adds.

Verification (head 47f5d0c)

  • @objectstack/service-automation test exit 0 (266 files, 3458 tests); typecheck exit 0; root eslint --no-inline-config packages/services/service-automation exit 0 (the package has no lint script). Run under os-verify-lock.
  • Targeted dogfood after turbo run build --filter='@objectstack/dogfood^...' (70/70): 19 files (the four phase-1 bare-name families, email template boot/materialization/locale files, connector install/restart/package-ref files, declarative MCP, webhook materialization), 96 tests passed.
  • Derived gates: dispatch-gates --commands derived 85 families at 47f5d0c; all 85 run with exit 0; --ran: "85 derived, 85 run, 0 NOT-MEASURED, 0 UNRUN" (a derived zero, every row carrying its exit code).
  • 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

…rough the package-scoped resolver (ADR-0150 E5)

Claude-Session: https://claude.ai/code/session_01ADHkmR4astMzES5UkW47jW
Co-authored-by: Claude <noreply@anthropic.com>
…ectors and email templates

Claude-Session: https://claude.ai/code/session_01ADHkmR4astMzES5UkW47jW
Co-authored-by: Claude <noreply@anthropic.com>
…tors and email templates

Claude-Session: https://claude.ai/code/session_01ADHkmR4astMzES5UkW47jW
Co-authored-by: Claude <noreply@anthropic.com>
The webhook and email template readers leave this change: the webhook
half waits on a ruling about names two packages held before phase 1, and
the email half waits on the resolver counting a dependent's added locale
as a second holder. Both stay recoverable from the previous commit.

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/service-automation, touching 7 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/services/service-automation/package.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/automation/flows.mdx (via AutomationServicePlugin (symbol, a top-level class))
  • content/docs/kernel/cluster.mdx (via AutomationServicePlugin (symbol, a top-level class))

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

  • content/docs/releases/v17/17-6.mdx (via AutomationServicePlugin (symbol, a top-level class))

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

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/services/service-automation/package.json) — pages documenting those are invisible to this run
  • 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 — 10 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 82a3ed2e861d260fdd65517e6b17f03c42eb90c2 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 579bdb897a20b4dad1fce191865e535064ebfaa4 — the merge of head 785a7428c169810953a4d75e71f91e848409c6d4 into base 82a3ed2e861d260fdd65517e6b17f03c42eb90c2, 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 579bdb897a20b4dad1fce191865e535064ebfaa4 && git checkout 579bdb897a20b4dad1fce191865e535064ebfaa4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 82a3ed2e861d260fdd65517e6b17f03c42eb90c2 785a7428c169810953a4d75e71f91e848409c6d4 && git checkout -B drift-repro 82a3ed2e861d260fdd65517e6b17f03c42eb90c2 && git merge --no-ff 785a7428c169810953a4d75e71f91e848409c6d4

node scripts/docs-audit/affected-docs.mjs --json 82a3ed2e861d260fdd65517e6b17f03c42eb90c2

⚠️ 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 82a3ed2e861d260fdd65517e6b17f03c42eb90c2 → 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, connectors part, head 47f5d0c · 2026-11-09T00:18Z

Verdict: PASS, with one fix to make before landing. The fix is a comment move, not a red-line issue, and needs no re-review.

Red line Reading
1. Never loosen The refusal runs before the desired set is built, and a refused name is skipped (refusedNames.has), so it is never materialized. On reload, a name that was materialized before is torn down. The older duplicate-name check stays behind it. Phase 1's one-holder refusal for connector is untouched, so no booted deployment changes its answer today.
2. Ambiguity reported, never guessed It uses the one resolver (resolvePackageScopedName, type connector, no caller package), with no second resolver and no first-registered fallback. fail() carries refusal.message, which names every candidate. The two-holder test pins both package ids in the message and zero provider calls.
3. Independently releasable (a) The boot answer is unchanged while phase 1 stands. (b) Seat 3's narrowing reading: D3 (a) 0, package-caller reads 0, outside-closure 0, sibling-in-artifact 0. (c) objectql moves from devDependencies to dependencies; objectql does not depend on service-automation, so there is no cycle. (d) The changeset is a patch with the affected parties stated.

Fix before landing: the new refusedDeclaredConnectorNames JSDoc and method sit between the [#28534] JSDoc and declaringPackageReadersOrNone. That leaves the [#28534] block stacked above the new one, where it documents the wrong method. Move the new method and its JSDoc above the [#28534] block, or below declaringPackageReadersOrNone.

The two points seat 3 raised:

  • packageScopedRegistryOf refuses nothing when the slice is absent, and getService errors are read as "no registry". Both match the existing declaringPackageReadersOrNone precedent, and both are inert today because phase 1 still refuses connector clashes. The test that uses a real SchemaRegistry with two holders also pins the slice shape: a rename of any slice method fails that test instead of silently disabling the check. Accepted for this PR.
  • Owed by the trailing backstop child that narrows phase 1 for connector (ADR-0150 D9): once phase 1 no longer refuses, fail-open on a missing slice would loosen. That child must either make a composition that has an objectql service but no resolver slice fail closed, or show that every shipped composition carries the slice. This is recorded here so the backstop card picks it up.

Still waiting on other cards: webhooks wait for the Q1 Ruled: on #31677 (6728850663). Email waits for R2 #31716 (the bundle-addition holder fix).

Seat 3 accepts and lands this after the fix and green CI. It is not a governed surface.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-steve · session_01ADHkmQXLfvyB5R53VDm3Ka

席位核对 · ACCEPT · #31725 (Part of #31677, ADR-0150 E5 connectors) · 2026-11-09T00:41Z

Head-sha: 785a7428c169810953a4d75e71f91e848409c6d4

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 9, 2026 00:42
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 9, 2026 00:42
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 9, 2026
@github-actions

github-actions Bot commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 42541734246 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

分类:failure —— 按下面的日志分诊。

失败的 job(日志抽取,best effort):

  • Test Core (1/8) — 失败步骤: Run this shard's tests

    @objectstack/cli:test:  FAIL   integration  test/validate-namespace-name.test.ts > ADR-0150 child N — os validate and os build refuse what the package door refuses, in its words > premise: the package
      ↳ 失败原因: @objectstack/cli:test: AssertionError: expected BareNameConflictError: Bare-name conflict… { …(8) } to be undefined
    

↳ 失败原因 是判读的关键:超时(Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言(AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️ 断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError。 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 13 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-steve · session_01ADHkmQXLfvyB5R53VDm3Ka

Re-enqueue · #31725 · head 785a7428c169810953a4d75e71f91e848409c6d4 (unchanged) · 2026-11-09T01:02Z

Merged via the queue into main with commit 1a2361c Nov 9, 2026
67 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-bwr6nn branch November 9, 2026 01:18
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