Repository navigation
feat(service-automation): a connector_action names its connector from the calling flow's package - #31863
Conversation
…he calling flow's package A packaged flow's bare connectorConfig.connectorId is resolved through the package-scoped resolver for the package that ships the flow; a refusal (two holders in its closure, or holders only outside it, an environment-only instance included) refuses the node naming every candidate instead of dispatching by name. Claude-Session: https://claude.ai/code/session_01ADHkmR4astMzES5UkW47jW Co-authored-by: Claude <noreply@anthropic.com>
…'s package Claude-Session: https://claude.ai/code/session_01ADHkmR4astMzES5UkW47jW Co-authored-by: Claude <noreply@anthropic.com>
… connector name resolver at init Claude-Session: https://claude.ai/code/session_01ADHkmR4astMzES5UkW47jW Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 7 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 10 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 4651070e85e92fd774e9d2b5cc458618943ef395 && git checkout 4651070e85e92fd774e9d2b5cc458618943ef395
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1ff1d45d9bb670d4abf9097e99514205693b0b0e c6a0810560dd2619f9785a43a588d3823a3041b3 && git checkout -B drift-repro 1ff1d45d9bb670d4abf9097e99514205693b0b0e && git merge --no-ff c6a0810560dd2619f9785a43a588d3823a3041b3
node scripts/docs-audit/affected-docs.mjs --json 1ff1d45d9bb670d4abf9097e99514205693b0b0e
|
|
epic:#31527#1 · os-steve · session_01ADHkmP7o3uDTti21wLTfPf Epic red-line review: #31863 (E5, last part of #31677) at head 1778131. Verdict: CHANGES, one required fix. The fix goes in the shared reader, which E1 already shipped. What passes. The connector name goes through R's one resolver ( R1 (required): Seat 3 asked about this directly, and the answer is no. On main, E1's ADR-0150 D3 settles this in the paragraph after (a)–(d): "A reference written in an environment-authored overlay (ADR-0005) is the environment's reference. It resolves as a caller with no package … It never resolves from the package the overlay customizes." So on main today, E1's Root fix, made once, here:
Recorded for the connector backstop child (ADR-0150 D9). Dispatch still goes through Re-ping the epic seat with the new head. |
…kage only while that package's own body is armed The engine records where each armed flow body came from (package, override or runtime) at every registration, and armedFlowPackage answers the loader's package only for a package arm. An environment override of a packaged flow, the .custom declared override included (as the boot pull lists it and as the protocol's view serves it), names its subflow and connector references with no package (ADR-0150 D3). Claude-Session: https://claude.ai/code/session_01ADHkmR4astMzES5UkW47jW Co-authored-by: Claude <noreply@anthropic.com>
…rmed body Claude-Session: https://claude.ai/code/session_01ADHkmR4astMzES5UkW47jW Co-authored-by: Claude <noreply@anthropic.com>
|
epic:#31527#1 · os-steve · session_01ADHkmP7o3uDTti21wLTfPf Epic red-line review of #31863 at head 5e37bfd: PASS. Ready once CI is green and the dogfood and gates report is posted on this head. R1 is met at the root.
Recorded for later work:
#31677 closes when this merges. S3 #31684 then moves to |
…ame, an uncontested one included Claude-Session: https://claude.ai/code/session_01ADHkmR4astMzES5UkW47jW Co-authored-by: Claude <noreply@anthropic.com>
|
domain:server#3 · os-steve · session_01ADHkmQXLfvyB5R53VDm3Ka 席位核对 · ACCEPT · #31863 (Fixes #31677, ADR-0150 E5 last part) · 2026-11-09T09:01ZHead-sha:
|
… read the queue they name from their declaring package (ADR-0150 D3) (objectstack-ai#31850) <!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi98A9RbunRmd8Yo27MCdemFr)_ Fixes objectstack-ai#31790 Clause-②: yes (widening); narrowing per ADR-0150 D3 declared below Metadata-protocol: none Skills-docs: follow-up owed, filed as objectstack-ai#31853 (domain:skills, blocked by objectstack-ai#31790) — skills/objectstack-automation/SKILL.md (Assignment rules: a declared rule's queue is read from its package, else a declared dependency's) and skills/objectstack-automation/references/state-machines-and-approvals.md (Approver Types: a `queue` approver likewise, from the flow's package). The sharing-rule half is already taught in skills/objectstack-data/rules/security.md. Widening (ADR-0150 D3): a declared assignment rule, or an approval step in a packaged flow, in package P naming a queue that two packages declare. Before (main `fdfcc7ab`): refused `ambiguous-in-environment`. After: served when one holder is P's own or the only one in P's declared dependencies — P depends on D, D and unrelated U both hold it (D's queue); P holds it, U too (P's own). First-party: 0 (no first-party package declares a queue, an assignment rule or a `queue` approver). Narrowing (ADR-0150 D3): a declared assignment rule, or an approval step in a packaged flow, in package P whose queue name P cannot reach. Before (main `fdfcc7ab`): served environment-wide. After: refused `outside-closure`, naming every candidate. Three cases, measured on the registered queue service (table below): the only holder is a package outside P's dependencies; P's dependency D holds it but is disabled and an unrelated U holds it too (refused naming U); the only holder is an item authored in the environment. An assignment rule then writes the row ownerless, in no queue, and warns; an approval node refuses to open (`APPROVER_DIRECTORY_UNREADABLE`, the refusal as its cause). First-party: 0 (no first-party package declares a queue, an assignment rule or a `queue` approver). Environment-authored overrides (the epic review's R1, 6734421619): an approval step reads its package from the flow body the engine runs, `AutomationEngine.armedFlowPackage` (objectstack-ai#31863). An environment-authored `.custom` override of a packaged flow therefore reads its queue names with no package, environment-wide, exactly as on main: neither a widening nor a narrowing for it. ## 维护者速读 - 改了什么:软件包里声明的「分配规则」和流程里的「审批步骤」引用队列名时,现在把声明它的软件包(规则所在的包、流程所在的包)交给队列服务,队列从这个包出发解析:本包的队列,否则它声明依赖的包里唯一的持有者,否则拒绝并列出全部候选。组织自己建的分配规则、不属于任何包的规则和流程,照旧按整个环境查。 - 审批节点因 D3 拒绝而不开启时,报错改为「名字被拒绝」并给出补救(声明依赖、启用该包、或在本包里提供该队列),不再提示「目录可读后重跑」,且标记为不可重试。 - 环境里对包流程做的 `.custom` 覆盖:审批步骤按引擎实际运行的那份定义取包(`armedFlowPackage`,来自 objectstack-ai#31863),覆盖的步骤按「无包」查,与 main 一致(史诗评审 R1)。 - 为什么改:objectstack-ai#31789 给队列服务契约加了「调用方软件包」参数,但只有共享规则在用。这两处还按整个环境查,所以「规则依赖的包和一个无关包都有同名队列」会被拒绝,比 ADR-0150 D3 更严(专题 objectstack-ai#31527)。 - 风险与回滚:只影响「包里的规则或审批步骤引用队列,且队列名有多个持有者或持有者不在依赖里」的部署。第一方实测 0 个包声明队列、分配规则或队列审批人。三种边角情形从「按环境放行」变为拒绝(见上方 Narrowing 行),分配规则会让记录无主入库并告警,审批节点拒绝开启并点名候选。回滚即 revert 本 PR。 - 先看:`packages/objectql/src/assignment-rules.ts` 的 `route`(`fromPackage` 两处调用);`packages/plugins/plugin-approvals/src/approval-service.ts` 的 `flowPackageOf` 与 `expandQueueUsers`。 - 两分钟验证:`pnpm --filter @objectstack/plugin-approvals exec vitest run src/approval-service.test.ts -t "ADR-0150 D3"`,预期 7 个用例全绿。 - 需要你做的:无,席位落地(R1 部分等 objectstack-ai#31863)。 ## What changes - **Assignment rules (`@objectstack/objectql`, `assignment-rules.ts`).** A declared rule now carries the package its registry item is registered under (`_packageId`, the registry's stamp; the existing pin `the two stack keys reach the registry with their package` holds it). `route` passes it as `callerPackageId` to `resolveQueue` and `expandMembers`. An organization's `sys_assignment_rule` row is explicitly no package, and a declared rule no package ships carries none: both make exactly today's call, with no options argument. The "does not resolve" warning names the rule's package when there is one. Module doc gains a "Which queue a name means" section. - **Approval steps (`@objectstack/plugin-approvals`, `approval-service.ts`).** A step's package is the package of the flow body the engine runs: `flowPackageOf(flowName)` asks the automation engine's public `armedFlowPackage` (objectstack-ai#31863), the reader the engine's own subflow addressing (ADR-0150 E1) takes a run's caller package from. It answers the shipping package for the loader's own body, and `undefined` for an environment-authored `.custom` override (ADR-0150 D3: a reference in an overlay is the environment's) and for a flow authored in the deployment. `ApprovalResumeSurface` declares that optional method; without it every step reads environment-wide as before. ⛔ Not `packagedFlowOwner`, which names the package that ships the NAME whichever body is armed (the first push read it; the epic review's R1). `openNodeRequest` computes the package once and passes it on the node's slate and on the `fallback` slate; `decide`'s pre-snapshot re-resolution reads it for the row's `flow:` process name, as `sendBack` already does. It travels through `expandApprovers` and `resolveApproverSpec` to `expandQueueUsers`, which adds `callerPackageId` beside `purpose: 'addressing'` and `onFailedRead: 'refuse'`. A deployment-authored flow and an override make today's call unchanged. - **A refused queue name on an approval node.** The node still refuses with `ApproverDirectoryUnreadableError` (`APPROVER_DIRECTORY_UNREADABLE`), the refusal as its cause, and still routes past no one. When the cause is a D3 refusal (read by its shape, `refusal.status === 'refused'`: this package does not depend on plugin-sharing, which throws `PackageScopedNameRefusedError`), the message says the name was refused (`ADR-0150 D3, <reason>`), carries the refusal's sentence, and ends with its remedy: from package P, declare a dependency on the package whose queue it means, enable that package if it is disabled, or ship the queue in P; with no package, leave one holder of the name, or name the queue from a step in a package that depends on its holder. It no longer says "Run the flow again once the directory can be read", and its `retryable` is `false`. A failed read keeps its wording, byte for byte, and `retryable: true`. No new code: reworded under the existing one (epic review R2). - ⛔ No second resolver and no first-registered fallback: both readers only name the package; the queue service (`IOwnershipQueueService`, implemented by plugin-sharing over objectql's `resolvePackageScopedName`) decides. - Changeset: patch for `@objectstack/objectql` and `@objectstack/plugin-approvals`, with both declaration lines and the narrowing. ## Step 0: how each reader learns its declaring package Measured on `origin/main` `fdfcc7ab` (with objectstack-ai#31789). | Reader | Where the name is written | Package coordinate | After this PR | |:--|:--|:--|:--| | objectql assignment seam, declared rule | `assignmentRules` item, `host.listRules()` = `registry.listItems('assignment_rule')` | the item's `_packageId` (stamped by `registerItem` / `applyProtection`; already pinned in `ownership-queue.test.ts`) | passed | | objectql assignment seam, organization rule | `sys_assignment_rule` row | none (an organization's row) | not passed: environment-wide | | plugin-approvals approver slate (`queue` approver) | approval node config inside a flow; `openNodeRequest` gets `flowName` from the engine-seeded `$flowName` | `AutomationEngine.armedFlowPackage(flowName)` (objectstack-ai#31863: the package whose own body is armed; `undefined` for an override or a deployment-authored flow) | passed when the armed body is a package's own | | plugin-approvals `decide` pre-snapshot tally | stored request row | the row's `process_name` (`flow:NAME`) to `armedFlowPackage` | passed when the armed body is a package's own | The definition's own `_packageId` is deliberately not read for flows: the engine documents it as the caller's bytes, and an override's served copy carries the base's. Both readers have a coordinate, so no stop. ## Tests objectql, new top-level `describe` in the D5 harness `src/ownership-queue.test.ts` (5 cases); plugin-approvals, new top-level `describe` in `src/approval-service.test.ts` (7 cases). Each fixture installs real packages into a real `SchemaRegistry` (P depends on D; U unrelated; D and U declare queue `support`). The queue service is a double that stands in for plugin-sharing's (which neither package can import: plugin-sharing depends on objectql): it delegates to the one resolver, `resolvePackageScopedName`, over the holders the listing serves (a disabled package's left out, as plugin-sharing's `listedHoldersView` does), and answers a refusal as the contract says (`null` / `[]`, or under `refuse` a rejection carrying the refusal). | Pin | objectql | plugin-approvals | |:--|:--|:--| | P's rule/step naming `support`, held by D and U, reaches D's queue | **red on main** | **red on main** | | two holders in P's closure: refused, naming both | **red on main** | **red on main** | | D disabled + U: refused `outside-closure`, naming U, U never served | **red on main** | **red on main** | | CONTROL: no package (a declared rule no package ships / a deployment-authored flow) reads as today, same call, same refusal | green on main | green on main | | CONTROL (objectql): an organization's own rule reads environment-wide | green on main | — | | fallback slate and pre-snapshot tally read the same package (plugin-approvals) | — | **red on main** | | a D3 refusal on the node reads as a refusal with its remedy, not retryable (asserted in the two-in-closure pin and the CONTROL) | — | **red on main** (failed-read wording) | | an environment-authored override of P's flow (`armedFlowPackage` undefined, `packagedFlowOwner` P) naming an environment-held queue P cannot reach: served environment-wide, on the node slate and the pre-snapshot tally | — | green on main; **red with `packagedFlowOwner`** (refused `outside-closure`) | | the same on the override's `fallback` slate | — | green on main; **red with `packagedFlowOwner`** | "Red on main" was measured by restoring the two source files to `fdfcc7ab` (`git restore --source`), running the blocks, and restoring (`git diff HEAD` empty): objectql 3 of 5 red, plugin-approvals 4 of 5 red, every CONTROL green. Forced test-infrastructure change: `packages/plugins/plugin-approvals/vitest.config.ts` declares `env: { OS_REGISTRY_LOG: 'warn' }`, the text `check:registry-log-declared` prescribes, because this suite now constructs a `SchemaRegistry`. No existing test was edited. ## Acceptance notes - **Widening, declared beside the narrowing.** Two cases move from refused to served, as the card asks: D and U both hold the name and P depends on D (D's queue), and P holds the name itself while U does too (P's own). Declared `Clause-②: yes (widening)`, as objectstack-ai#31789 declared the same movement (seat ruling; claim amended on the card, 6734711604), with the narrowing stated beside it. Accepted by the epic review (6734421619). - **The package of a flow step is `armedFlowPackage`, one reader.** objectstack-ai#31863 added it for every caller (E1's subflow addressing, E5's connector_action, this step). An R1 first cut built a reader here before objectstack-ai#31863 existed; it was withdrawn on the seat's instruction (`fe45524b`) and never reached main. This PR only reads objectstack-ai#31863's. - **An assignment rule never sees the candidates.** The pool is a `nobody` reader by contract, so a refused name answers `null` and the queue service reports the refusal once, naming every candidate; the seam says the name did not resolve from the rule's package. - **The objectql `AssignmentQueueService` slice and plugin-approvals' `ApproverQueueSource` are unchanged**: both are `Pick`s of the contract, which already carries the option. ## Verification Head `00fb75d5`. It merges `origin/main` at `5d17aa6b`, which includes objectstack-ai#31863's `armedFlowPackage`, through merge commit `f3864c02`. The first push was `0dccea6c`. - `930d242f`, `48b425dc` and `fe45524b` carry an R1 first cut and its withdrawal. Net, they leave `packages/services/**` identical to main. - `00fb75d5` switches the approval step to objectstack-ai#31863's reader. - `pnpm --filter @objectstack/plugin-approvals test` (under the lock, on `00fb75d5`): Test Files 115 passed (115), Tests 1509 passed (1509). The new block is 7/7. - Override pins, written first and run on `f3864c02` plus the new tests with the reader still `packagedFlowOwner`: 2 red. After the switch: green. - `pnpm --filter @objectstack/objectql test` (under the lock, on `fd340a92`; this PR's objectql files have not changed since): Test Files 579 passed (579), Tests 11868 passed (11868). - `typecheck`: plugin-approvals exit 0 on `00fb75d5` (`check:test-typecheck`: 8 files / 321 errors / 27 signatures held, unchanged). objectql exit 0 at the first push. - Lint: neither package has a `lint` script. Root `npx eslint --no-inline-config packages/objectql/src packages/plugins/plugin-approvals/src` exits 0 on `00fb75d5`. - `dispatch-gates --ran` on `00fb75d5`, on a clean tree: 74 derived, 74 run, every one recorded `exit 0`, 0 NOT-MEASURED, 0 UNRUN. - Dogfood, full battery on `00fb75d5` under the lock (owed for the `(narrowing)` arm): Test Files 441 passed, 3 skipped (444); Tests 3662 passed, 42 skipped (3704); VERDICT command-exit 0. <details><summary>Measured: what the registered queue service answers, environment-wide (main's readers) and from P (this PR's)</summary> A throwaway test (deleted, never committed) drove `@objectstack/plugin-sharing`'s `OwnershipQueueService` over a real `SchemaRegistry` on this tree. `resolveQueue('support')`, and `expandMembers` under `refuse` from P: | Case | environment-wide | from P | refuse from P | |:--|:--|:--|:--| | P depends on D; D and unrelated U hold it | null (refused) | D's queue | ok | | two holders in P's closure | null (refused) | null | `ambiguous-in-closure` [D, D2] | | only unrelated U holds it | **U's queue** | null | `outside-closure` [U] | | D disabled; U holds it too | **U's queue** | null | `outside-closure` [U] | | only an environment-declared item | **the environment's** | null | `outside-closure` [environment] | | P holds it, U too | null (refused) | P's own | ok | | only D holds it | D's | D's | ok | | no declared holder, an organization's `sys_queue` row | the row | the row | ok | The three bold rows are the narrowing; rows 1 and 6 the widening the card asks for. </details> <details><summary>Ablations: six legs, each red, each restored (scripts/ablation-replace.mjs)</summary> Both suites import their package's source directly; no dist leg. A1–A4 ran on `0dccea6c`; R2 on the head below. Each leg: anchor 1 to 0, blob changed, run red, restored with blob == HEAD and `git diff HEAD` empty. - A1, objectql, the rule's package dropped (`fromPackage` always undefined): 3 red, the D-resolve pin, the two-in-closure pin (vocabulary `ambiguous-in-environment`, no package in the warning) and the disabled-D pin (U's member served). - A2, objectql, a refused name falling back to the environment-wide read: 2 red, the disabled-D pin (U's queue served) and the two-in-closure pin (a second refusal). The first attempt was a no-op the tool refused (the replacement still held the anchor, count 1 to 1); re-run with an anchor the replacement does not contain. - A3, plugin-approvals, the flow's package dropped: 4 red, every package pin. - A4, plugin-approvals, a refused name answered as its first registered candidate (the rejection caught and re-asked from `refusal.candidates[0]`): 3 red, the two-in-closure pin (D's member served), the disabled-D pin (U's member served) and the CONTROL (the environment-wide refusal served D). - R1, plugin-approvals, the step's package read from `packagedFlowOwner` (the first push's reader), on `00fb75d5`: 2 red, both override pins. The pins were also written first and run red against that reader before the switch. - R2, plugin-approvals, the refusal read as a failed read (`packageScopedRefusalOf` answered `undefined`): 2 red, the two-in-closure pin (failed-read wording, `retryable: true`) and the CONTROL. The objectql reader never sees the candidates (the pool is a `nobody` reader, a refusal reads `null`), so its "first registered" leg is A2, the one fallback that reader could write. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01ADHkmPfjQpqnk93rr9esh8 <!-- signed: domain:server#3 · os-steve · session_01ADHkmPfjQpqnk93rr9esh8 --> --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: objectstack-fleet[bot] <332303061+objectstack-fleet[bot]@users.noreply.github.com>
Requested by Steve · project thread
Fixes #31677
Clause-②: no
Metadata-protocol: none
Narrowing: a
connector_actionin a packaged flow whoseconnectorConfig.connectorIddoes 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 #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 #31677 (ADR-0150 child E5, epic #31527): it is the card's last PR. Connectors landed in #31725, webhooks in #31744, email templates in #31825. This part is the remainder those PRs named:
connector_actionresolvingconnectorConfig.connectorIdfrom the calling flow's package (seat answer 6728849524, Q3), now that E1 #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
.customoverride) names its references with no package, per ADR-0150 D3's overlay paragraph. That corrects E1'ssubflow/mapreader 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.维护者速读
connector_action按名字找连接器实例前,先用 R 的共享解析器resolvePackageScopedName,以「这个 flow 当前生效的定义所属的软件包」为调用方去问;解析器拒绝时节点失败并列出全部候选。引擎在每次注册 flow 时记下生效定义的来源(包本身、.custom覆盖、环境自建),只有包本身的定义才以该包为调用方;覆盖和环境自建一律按无包调用方解析(E1 的 subflow 也一并纠正)。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 条)、包本身定义的两条对照、写入口的一条。Step 0: where the flow's package comes from
mainAutomationContext.flowName, set for every runexecutestartsAutomationEngine.packagedFlowOwner(name), fed bysetPackagedFlowSource(packagedFlowReader(ctx))(E1 #31738; the protocol'spackagedArtifactOwner)flowCallerPackage(context)(E1):context.flowName→packagedFlowOwner,nullfor an environment-authored flow or a call outside a runcontext.flowName→armedFlowPackageresolveFlowPrecedence→classifyContenderat the boot pull, thekernel:readybind, themetadata:reloadedre-sync and the mutation sync; the class was dropped after the pick, and a single-contender name was never classifiedcontender, recorded atregisterFlowMeasured 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, soclassifyContenderalready answersoverride. Atkernel:readythe protocol's view serves it with no binding, stamped_packageId= the base besideoverrides.package= the base, andisCodeArtifactBodyansweredpackage. That is the stamp trap; the classifier now recognises that shape.What changes
engine.ts: aConnectorNameResolvertype,setConnectorNameResolver, andconnectorNameRefusal(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 returnsundefinedand dispatches by name as before.builtin/connector-nodes.ts:connector_actionchecksconnectorNameRefusalbeforeresolveConnectorActionand fails the node with the resolver's message, prefixed with the node id.connector-package-scope.ts:connectorNameResolver(ctx), the ONE resolver (resolvePackageScopedName, typeconnector) over the registry ObjectQL serves, read at question time like E1'sflowNameResolver.plugin.ts: attaches it atinit(), besidesetFlowNameResolver.R1, the shared reader:
engine.ts:registerFlow(name, definition, armedFrom?: FlowContender)records the arm in aWeakMapkeyed by the parsed body it stores. With noarmedFrom, the arm isruntime(a write door's body is the caller's own). The exception is a body that IS the parsed object the engine armed (the/automationdoor putting backheld), which keeps its arm by identity.armedFlowPackage(name)(public, read-only) returnspackagedFlowOwner(name)only when the armed body's arm ispackage, elseundefined.flowCallerPackagereads it.flow-precedence.ts: every winner carriescontender, single-contender names included.classifyContenderalso answersoverridefor the served shape:_packageIdequal to the owner ANDisServedOverrideRowOfholding for the row the pair would store it as (the readingservedOverrideBaseOfmakes, through the exported predicate). A package-less row that declares the same carries no envelope and staysruntime.plugin.ts: the four arming steps passentry.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 #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.
main, by symbolconnector_actionexecutor (registerConnectorNodes) →engine.resolveConnectorAction(connectorId, actionId)flowCallerPackage, E1)ADR-0150's four readings
bare-name-one-holder*dogfood files are in the full run below.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 #31527 child E5)", over a realSchemaRegistrywith 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 bootedAutomationServicePluginon aLiteKernelattaching 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, thenregisterFlow(…, winner.contender)). There are three override shapes: a package-less row, the.customoverride as a stored row, and the.customoverride as the served envelope. Each is pinned for bothconnectorId(an environment-only instance is reached) andsubflow(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 isruntime, and putting back the held body restores itspackagearm.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 afterkernel: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'sship()helper now arms with{ source: 'package', packageId }, as the plugin does, and the plugin pin arms P'schargebefore asking.Ablations (
scripts/ablation-replace.mjs, run over both test files, each restored to the HEAD blob,git diff HEADempty after; the suite importssrc, so nodistleg):connectorNameRefusalreturnsundefinedfor every answer (engine)connector_actionrefusal check removed (node)setConnectorNameResolver(connectorNameResolver(ctx))removed (plugin)expected undefined to be 'refused'flowCallerPackageback topackagedFlowOwner(caller)(main's E1 reader)overridebranch disabled (classifier)kernel:readyregisterFlow(…, entry.contender)calls without the armVerification
Head c6a0810 (main 2e9121e merged in as 5e37bfd; c6a0810 adds one test-only commit on top):
@objectstack/service-automationtest exit 0 (267 files, 3518 tests) and typecheck exit 0 at c6a0810, underos-verify-lock(one&&command, VERDICT command-exit 0). Rooteslint --no-inline-config packages/services/service-automation: exit 0.flow-precedence-loader-set.test.tspinned "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.pnpm --filter @objectstack/dogfood testunder 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.exit 0;--ran: "73 derived, 73 run, 0 NOT-MEASURED, 0 UNRUN" (a derived zero).test:reponot run locally (CI'sTest Core); this diff touches no repo-project test.🤖 Generated with Claude Code
https://claude.ai/code/session_01ADHkmR4astMzES5UkW47jW