Repository navigation
feat(plugin-email): email template names resolve by (package, name) through the shared resolver - #31825
Conversation
…scoped resolver An email template's name is answered by resolvePackageScopedName as a caller with no package before the layered per-locale pick; a name two packages hold fails the send with a 409 naming every candidate. Claude-Session: https://claude.ai/code/session_01ADHkmR4astMzES5UkW47jW Co-authored-by: Claude <noreply@anthropic.com>
…'s own vitest config Claude-Session: https://claude.ai/code/session_01ADHkmR4astMzES5UkW47jW Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 13 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 6 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 7 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 48f6dce4f7c064597d860265df67bf028f9f256b && git checkout 48f6dce4f7c064597d860265df67bf028f9f256b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 732def5937f7c46961bf5735f111468ba8be34f1 375152d8daff7ced0fb68b1dc6cb941a2b8c7de2 && git checkout -B drift-repro 732def5937f7c46961bf5735f111468ba8be34f1 && git merge --no-ff 375152d8daff7ced0fb68b1dc6cb941a2b8c7de2
node scripts/docs-audit/affected-docs.mjs --json 732def5937f7c46961bf5735f111468ba8be34f1
|
|
epic:#31527#1 · os-steve · session_01ADHkmP7o3uDTti21wLTfPf Epic red-line review: E5 #31677, email half, head 375152d · 2026-11-09T05:56Z Verdict: PASS.
D9 obligation: when B #31699 narrows phase 1 for This closes E5. Seat 3 accepts and lands after green CI. It is not a governed surface. |
|
domain:server#3 · os-steve · session_01ADHkmQXLfvyB5R53VDm3Ka 席位核对 · ACCEPT · #31825 (Part of #31677, ADR-0150 E5, email half) · 2026-11-09T06:20ZHead-sha:
|
… the calling flow's package (objectstack-ai#31863) <!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9B9qYj7GTyDGfaMgXwXx7rZ)_ Fixes objectstack-ai#31677 Clause-②: no Metadata-protocol: none Narrowing: a `connector_action` in a packaged flow whose `connectorConfig.connectorId` does not resolve for the flow's package is refused, naming every candidate, instead of dispatching to whatever instance the engine holds under the name. Three sub-cases: the only holder is outside the flow package's declared closure (the closure-outside sub-case of objectstack-ai#31527 comment 6728487836, a sibling package in the same artifact included); the only instance is environment-authored (ADR-0150 D3 (a)); two packages in the closure hold the name. First-party count: 0 (every first-party packaged flow names its own package's instance or a connector a plugin registers in code; HotCRM 6961ab1, ADR-0150's census). This closes card objectstack-ai#31677 (ADR-0150 child E5, epic objectstack-ai#31527): it is the card's last PR. Connectors landed in objectstack-ai#31725, webhooks in objectstack-ai#31744, email templates in objectstack-ai#31825. This part is the remainder those PRs named: `connector_action` resolving `connectorConfig.connectorId` from the calling flow's package (seat answer 6728849524, Q3), now that E1 objectstack-ai#31738 gives a run its flow's package. **Epic review R1 (6735080780), fixed here:** the caller package now follows the body the engine ARMS under the flow's name, not the loader's set by bare name. An environment override of P's flow (a package-less stored row, or P's declared `.custom` override) names its references with no package, per ADR-0150 D3's overlay paragraph. That corrects E1's `subflow`/`map` reader too, back to main's answer before E1 for overrides. It is a loosening, not a new narrowing. Names were accepted by seat 3 (6735158321). Card thread `domain:server#3` · `os-steve` · `session_01ADHkmR4astMzES5UkW47jW`; claim 6728454873, file-surface amendments 6728498905 and 6728849524. ## 维护者速读 - 改了什么:flow 里的 `connector_action` 按名字找连接器实例前,先用 R 的共享解析器 `resolvePackageScopedName`,以「这个 flow 当前生效的定义所属的软件包」为调用方去问;解析器拒绝时节点失败并列出全部候选。引擎在每次注册 flow 时记下生效定义的来源(包本身、`.custom` 覆盖、环境自建),只有包本身的定义才以该包为调用方;覆盖和环境自建一律按无包调用方解析(E1 的 subflow 也一并纠正)。 - 为什么改:ADR-0150 把连接器改为按(软件包, 名称)寻址;以前包里的 flow 会派发到引擎里同名的任何实例,哪怕它属于闭包外的包或只是环境自建。 - 风险与回滚:只影响「包内 flow 指向自己够不着的已声明连接器」的部署:只有闭包外的包持有、只有环境自建、或闭包内两个包同名。第一方计数 0。环境自建的 flow、代码注册的连接器(slack、rest 等)照常派发。回滚即还原本 PR。 - 先看:`engine.ts` 的 `armedFlowPackage` 和 `registerFlow` 的 `armedFrom`,`flow-precedence.ts` 里识别协议视图下发的覆盖文档那一段,再看 `connectorNameRefusal`。 - 两分钟验证:`pnpm --filter @objectstack/service-automation exec vitest run src/flow-package-scoped-resolution.test.ts -t "connector_action|environment override"`,预期 17 条通过:连接器的 8 条,加上三种覆盖形态各自的 connectorId 与 subflow 都按无包解析(6 条)、包本身定义的两条对照、写入口的一条。 - 需要你做的:无,席位落地。本 PR 合并后 objectstack-ai#31677 关闭。 ## Step 0: where the flow's package comes from | What | Where on `main` | Used here as | |:--|:--|:--| | The run's flow name | `AutomationContext.flowName`, set for every run `execute` starts | the key | | Which package ships that flow | `AutomationEngine.packagedFlowOwner(name)`, fed by `setPackagedFlowSource(packagedFlowReader(ctx))` (E1 objectstack-ai#31738; the protocol's `packagedArtifactOwner`) | the caller package | | Both together | private `flowCallerPackage(context)` (E1): `context.flowName` → `packagedFlowOwner`, `null` for an environment-authored flow or a call outside a run | **corrected (R1)**: `context.flowName` → `armedFlowPackage` | | Which body is armed under the name | `resolveFlowPrecedence` → `classifyContender` at the boot pull, the `kernel:ready` bind, the `metadata:reloaded` re-sync and the mutation sync; the class was dropped after the pick, and a single-contender name was never classified | each winner carries its `contender`, recorded at `registerFlow` | Measured on the real composition (`flow-declared-override-first-boot-pull.integration.test.ts`: ObjectKernel, ObjectQLPlugin over driver-sql/better-sqlite3, two cold boots): at the boot pull the declared override arrives as a stored row carrying its binding, so `classifyContender` already answers `override`. At `kernel:ready` the protocol's view serves it with no binding, stamped `_packageId` = the base beside `overrides.package` = the base, and `isCodeArtifactBody` answered **`package`**. That is the stamp trap; the classifier now recognises that shape. ## What changes - `engine.ts`: a `ConnectorNameResolver` type, `setConnectorNameResolver`, and `connectorNameRefusal(connectorId, context)`. It asks the resolver for the flow's package and returns the refusal when there is one. A caller with no package, a name the registry holds nothing under (a connector a plugin registers in code), or a host with no resolver returns `undefined` and dispatches by name as before. - `builtin/connector-nodes.ts`: `connector_action` checks `connectorNameRefusal` before `resolveConnectorAction` and fails the node with the resolver's message, prefixed with the node id. - `connector-package-scope.ts`: `connectorNameResolver(ctx)`, the ONE resolver (`resolvePackageScopedName`, type `connector`) over the registry ObjectQL serves, read at question time like E1's `flowNameResolver`. - `plugin.ts`: attaches it at `init()`, beside `setFlowNameResolver`. R1, the shared reader: - `engine.ts`: `registerFlow(name, definition, armedFrom?: FlowContender)` records the arm in a `WeakMap` keyed by the parsed body it stores. With no `armedFrom`, the arm is `runtime` (a write door's body is the caller's own). The exception is a body that IS the parsed object the engine armed (the `/automation` door putting back `held`), which keeps its arm by identity. `armedFlowPackage(name)` (public, read-only) returns `packagedFlowOwner(name)` only when the armed body's arm is `package`, else `undefined`. `flowCallerPackage` reads it. - `flow-precedence.ts`: every winner carries `contender`, single-contender names included. `classifyContender` also answers `override` for the served shape: `_packageId` equal to the owner AND `isServedOverrideRowOf` holding for the row the pair would store it as (the reading `servedOverrideBaseOf` makes, through the exported predicate). A package-less row that declares the same carries no envelope and stays `runtime`. - `plugin.ts`: the four arming steps pass `entry.contender`. Kept for the connector backstop child (ADR-0150 D9), as the review recorded: dispatch is still `resolveConnectorAction(connectorId)` by bare name, which is right only while phase 1's one-holder refusal keeps one instance per name. Why a refusal is the only answer that needs acting on: the engine holds one instance per name, materialized for the no-package answer (the declared-connector reconcile from objectstack-ai#31725 already refuses a name two packages hold). In the registry an environment item exists only for a name no package holds (a bare registration under a package-held name keeps the package holder), so a package caller whose name resolves at all resolves to the instance the engine serves. No second resolver, no first-registered fallback. | Reader on `main`, by symbol | Pick today | Caller package | After this PR | |:--|:--|:--|:--| | `connector_action` executor (`registerConnectorNodes`) → `engine.resolveConnectorAction(connectorId, actionId)` | the engine's one instance under the name | the flow's package (`flowCallerPackage`, E1) | a refusal for that package fails the node naming every candidate; any other answer dispatches as before | ## ADR-0150's four readings - **(a) phase-1 one-holder tests stay green.** The four `bare-name-one-holder*` dogfood files are in the full run below. - **(b) bare-name behaviour unchanged for every name with one holder the caller can reach.** Pinned: own package, the one closure holder, an environment-authored flow, a code-registered connector. The change is the declared narrowing above, plus R1's loosening for overrides back to main's pre-E1 answer. No URL, MCP or SDK surface moves. - **(c) no migration.** Nothing stored is read or written differently. - **(d)** `Clause-②: no`, `Metadata-protocol: none`; no ADR-0087 entry. ## Tests `service-automation/src/flow-package-scoped-resolution.test.ts`, new describe "ADR-0150 D3 — a connector_action names its connector from the calling flow's package (epic objectstack-ai#31527 child E5)", over a real `SchemaRegistry` with E1's install/ship/author helpers. 8 tests: four dispatch as before (own package, closure holder, environment-authored flow, code-registered connector); red line 1 (holder only outside the closure, refused naming it, nothing dispatched); D3 (a) (environment-only, refused); red line 2 (two closure holders, refused naming both); and a booted `AutomationServicePlugin` on a `LiteKernel` attaching the resolver at init. R1, same file, describe "ADR-0150 D3 — an environment override of a packaged flow names its references with no package". Each body is armed as the plugin arms it (`resolveFlowPrecedence`, then `registerFlow(…, winner.contender)`). There are three override shapes: a package-less row, the `.custom` override as a stored row, and the `.custom` override as the served envelope. Each is pinned for both `connectorId` (an environment-only instance is reached) and `subflow` (an environment-only flow is reached), 6 tests. Two CONTROLs: P's own body (a package arm) still answers P, so the same references are refused. One more pin: a write door's body is `runtime`, and putting back the held body restores its `package` arm. `flow-declared-override-first-boot-pull.integration.test.ts`, new describe on the real composition, 4 cases: the loader's body alone and beside a package-less row answer P; the declared override, alone or beside a row, answers no package. Each is checked at the boot pull and after `kernel:ready`. Changed existing test code: `flow-precedence-loader-set.test.ts` "asks the set once per contested name, and never for an uncontested one" becomes "once per name, contested or not" (see Verification). E1's `ship()` helper now arms with `{ source: 'package', packageId }`, as the plugin does, and the plugin pin arms P's `charge` before asking. Ablations (`scripts/ablation-replace.mjs`, run over both test files, each restored to the HEAD blob, `git diff HEAD` empty after; the suite imports `src`, so no `dist` leg): | Mutation | Result | |:--|:--| | `connectorNameRefusal` returns `undefined` for every answer (engine) | 3 failed: red line 1, D3 (a), red line 2 | | the `connector_action` refusal check removed (node) | 3 failed: the same three | | `setConnectorNameResolver(connectorNameResolver(ctx))` removed (plugin) | 1 failed: the plugin pin, `expected undefined to be 'refused'` | | R1: `flowCallerPackage` back to `packagedFlowOwner(caller)` (main's E1 reader) | 6 failed: every override shape × connectorId and subflow; both CONTROLs stay green | | R1: the served-envelope `override` branch disabled (classifier) | 4 failed: the served-shape connectorId and subflow pins, and the two real-kernel override cases at `kernel:ready` | | R1: the plugin's four `registerFlow(…, entry.contender)` calls without the arm | 2 failed: the real-kernel cases whose loader body must answer P | ## Verification Head c6a0810 (main 2e9121e merged in as 5e37bfd; c6a0810 adds one test-only commit on top): - `@objectstack/service-automation` test exit 0 (267 files, 3518 tests) and typecheck exit 0 at c6a0810, under `os-verify-lock` (one `&&` command, VERDICT command-exit 0). Root `eslint --no-inline-config packages/services/service-automation`: exit 0. - The first local run, at 5e37bfd, had 1 red: `flow-precedence-loader-set.test.ts` pinned "never asks the loader's set for an uncontested name". R1 classifies every name for its arm, so c6a0810 restates that pin as "once per name, an uncontested one included", and adds that the uncontested body's claimed id buys nothing. CI's Test Core 5/8 showed the same 1 red at 5e37bfd. - `turbo run build --filter='@objectstack/dogfood^...'`: 70/70. - Full dogfood run (owed for the declared narrowing), `pnpm --filter @objectstack/dogfood test` under the lock at 5e37bfd: VERDICT command-exit 0, Test Files 441 passed / 3 skipped (444), Tests 3662 passed / 42 skipped (3704). The only change since is that test file. - Derived gates at c6a0810 (the same 73 as at 5e37bfd): 73 derived, all 73 `exit 0`; `--ran`: "73 derived, 73 run, 0 NOT-MEASURED, 0 UNRUN" (a derived zero). - `test:repo` not run locally (CI's `Test Core`); this diff touches no repo-project test. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01ADHkmR4astMzES5UkW47jW <!-- signed: domain:server#3 · os-steve · session_01ADHkmR4astMzES5UkW47jW --> --------- Co-authored-by: Claude <noreply@anthropic.com>
…rror class for every package-scoped by-name refusal (objectstack-ai#31876) <!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9Pd2vxfndbHVQLiX3wzkDSN)_ Fixes objectstack-ai#31852 Clause-②: yes (widening) — `@objectstack/core` and the lean entry `@objectstack/objectql/core` gain `PackageScopedNameRefusedError` Metadata-protocol: none ## 维护者速读 - 改了什么:包作用域按名读取被拒时抛出的 `PackageScopedNameRefusedError` 原本在 objectql 和 plugin-sharing 各有一份,现在只在 `@objectstack/core` 声明一次,两个包原名转出同一个类;objectql 的精简入口 `@objectstack/objectql/core` 也导出它。导航翻译检查脚本遇到同名应用被拒时,改为报一条列出所有候选包的组合错误,不再抛出 409 中断。 - 为什么改:两份同名类互不 `instanceof`,以后谁要捕获"包作用域名字冲突"就得猜用哪一份,猜错一半;精简入口的使用者只能按 code 和 status 匹配。 - 风险与回滚:每个入口仍返回 409 `RESOURCE_CONFLICT`、同一句话,行为不变;plugin-sharing 抛出的错误多带一个值相同的 `httpStatus` 字段。回滚即还原本 PR。 - 先看:`packages/core/src/package-scoped-resolution.ts`(类本体)、`packages/plugins/plugin-sharing/src/package-scoped-declarations.ts`(改为转出)、`packages/cli/scripts/check-app-nav-i18n.mjs`(`readMergedApp`)。 - 两分钟验证:`pnpm --filter @objectstack/plugin-sharing exec vitest run src/package-scoped-declarations.test.ts`,预期 26 个全过,其中 3 个是本 PR 新增的跨包 `instanceof` 断言。 - 需要你做的:无,席位落地。 ## What changes - **`@objectstack/core`** (`package-scoped-resolution.ts`): `PackageScopedNameRefusedError` is declared here once, beside `PackageScopedRefusal` and `packageScopedRefusalMessage`. It carries the union of the two old copies' fields: `code = 'RESOURCE_CONFLICT'`, `status = 409`, `httpStatus = 409` and `refusal`, and its message is the refusal's own sentence. Core's barrel already re-exports the module (`export *`), so no barrel edit. - **`@objectstack/objectql`** (`package-scoped-read.ts`): the local class is gone; the module re-exports core's binding under the same name, so the barrel, `metadata-facade.ts` and `registry.ts` keep their imports. - **`@objectstack/objectql/core`** (lean entry, `core.ts`): exports the class too, since the `SchemaRegistry` it exports throws it from `getApp`. Core is already in the entry's closure, so `core-boundary.ratchet.test.ts` stays green. - **`@objectstack/plugin-sharing`** (`package-scoped-declarations.ts`): the local class is gone; the module re-exports core's binding under the same name, so `sharing-rule-service.ts` and `ownership-queue-service.ts` keep their imports. Its refusals gain `httpStatus: 409`. - **`packages/cli/scripts/check-app-nav-i18n.mjs`** (N1): the app is read through a new pure `readMergedApp(name, getApp, isRefusal)`. A `PackageScopedNameRefusedError` from `registry.getApp(name)` becomes a composition finding naming the app and every candidate, reported beside the gate's other composition errors (so the coverage verdict is not attempted). It never reads as "not registered", whose remedy is the opposite. Any other error is rethrown untouched. I chose "catch and report" over "read by the shell's package": a read naming the shell package would silently pick the shell's app and hide a real name clash from the gate. Measured site census (delta ruling 1), `git grep -n "PackageScopedNameRefusedError\|RESOURCE_CONFLICT.*refusal"` on `origin/main` `074a014c`: two declarations (objectql `package-scoped-read.ts`, plugin-sharing `package-scoped-declarations.ts`); throws in `package-scoped-read.ts`, `sharing-rule-service.ts` (2) and `ownership-queue-service.ts` (2); `instanceof` in `metadata-facade.ts`, `sharing-rule-service.ts` and `ownership-queue-service.ts` (2). No third copy on main. Open PRs read at their heads: E3 objectstack-ai#31815 adds no site. plugin-email objectstack-ai#31825 adds a separate class, `EmailTemplateAmbiguousError` (same 409 `RESOURCE_CONFLICT` envelope, different name, extra `template` and `candidates` fields), in `plugin-email/src/template-loader.ts`. It is left to that PR's seat, as ruled. Changeset: `.changeset/31852-one-package-scoped-refusal-class.md`, patch for core, objectql and plugin-sharing. `@objectstack/cli` takes none: its `files` is `dist`, `README.md` and `CHANGELOG.md`, so `scripts/` does not publish. ## Tests - `packages/core/src/package-scoped-resolution.test.ts`: the class carries `code`, `status`, `httpStatus`, `refusal` and the refusal's sentence. - `packages/objectql/src/package-scoped-e8-readers.test.ts`: `toBe` identity of the class from `package-scoped-read.js`, the barrel and the lean `./core` entry against core's; a `getApp` refusal is `instanceof` the lean entry's class. - `packages/plugins/plugin-sharing/src/package-scoped-declarations.test.ts`: `toBe` identity of plugin-sharing's and objectql's export against core's; a refusal from objectql's by-name reader (`SchemaRegistry#getApp`) is `instanceof` plugin-sharing's class, and a refusal from plugin-sharing's door (`SharingRuleService#declaredRuleNamed`) is `instanceof` objectql's class. - `check-app-nav-i18n.mjs --self-test`: six new cases for `readMergedApp` (refusal becomes a finding, names every candidate, never reads as unregistered; served and unregistered reads unchanged; a non-refusal error is rethrown, as a negative control; the boot read passes the class test). - Wire envelope (delta ruling 5) already pinned by existing tests, unchanged: objectql `package-scoped-e8-readers.test.ts` ("the refusal crosses a wire door as 409 RESOURCE_CONFLICT") and plugin-sharing `package-scoped-declarations.test.ts` ("refuses the sharing rule by name with 409 RESOURCE_CONFLICT naming both packages"). No existing test was edited or deleted. ## Acceptance notes - plugin-email objectstack-ai#31825 adds `EmailTemplateAmbiguousError`, a third class for the same refusal family. Not touched here (ruling 1); its seat decides whether it extends or reuses this class. ## Verification Head `3bb5c3e6` (`02e922a7` plus a merge of `origin/main`). <details><summary>Red first, on origin/main 074a014 (tests added, fix not yet applied)</summary> - core `package-scoped-resolution.test.ts`: `Tests 1 failed | 23 passed (24)`. - objectql `package-scoped-e8-readers.test.ts`: `Tests 2 failed | 71 passed (73)`. - plugin-sharing `package-scoped-declarations.test.ts` (core and objectql built from main): `Tests 3 failed | 23 passed (26)`. </details> <details><summary>Ablation (scripts/ablation-replace.mjs, both legs restored to HEAD by blob hash)</summary> - plugin-sharing: `export { PackageScopedNameRefusedError };` replaced by a local class. `mutation landed: anchor 1 -> 0`; `Tests 3 failed | 23 passed (26)`, all three new cross-package pins red; `restored: blob == HEAD (339e4de)`. - objectql: the same replacement in `package-scoped-read.ts`. `mutation landed: anchor 1 -> 0`; `Tests 1 failed | 72 passed (73)` (the identity pin); `restored: blob == HEAD (45f07d0)`. </details> <details><summary>Builds, tests, typecheck, lint</summary> - `turbo run build --filter='./packages/**' --concurrency=2` under the lock at `02e922a7`: `VERDICT command-exit 0`, `Tasks: 81 successful, 81 total`. - At `02e922a7`, one lock run: `typecheck` for core, objectql, plugin-sharing, then `pnpm --filter @objectstack/core test` (`Test Files 139 passed`, `Tests 3207 passed`), plugin-sharing (`Test Files 72 passed`, `Tests 1496 passed`), objectql (`Test Files 580 passed`, `Tests 11936 passed`): `VERDICT command-exit 0`. - At `3bb5c3e6` (after the merge; incoming main touched other files of core and objectql): rebuild of the three packages, their `typecheck`, and the three changed test files plus `core-boundary.ratchet.test.ts`: 24/24, 75/75, 26/26, `VERDICT command-exit 0`. - `pnpm check:app-nav-i18n` (self-test, then the live composition): exit 0, `OK (13 contributor(s), 11 locale(s), 2 app(s) — setup: 57 merged nav id(s), account: 13 merged nav id(s) …)`. - `eslint --no-inline-config` on `packages/core/src`, `packages/objectql/src`, `packages/plugins/plugin-sharing/src` and the script: exit 0. </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 73 commands, each run with its exit code captured before any pipe, all exit 0 at `02e922a7`. Reconciliation: `73 derived famil(ies) accounted for — 73 run, 0 NOT-MEASURED (a DERIVED zero — all 73 recorded an exit code and none of them is 3)`. Re-run at `3bb5c3e6`: see the report comment on the card. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmQHXhv9SKTU1qQ9CNC)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Requested by Steve · project thread
Part of #31677
Clause-②: no
Metadata-protocol: none
Narrowing: D3 (a) declared by the card, measured 0 here (a send names no package, so 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): an email template 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 rendering from the first-listed package. First-party count: 0 (HotCRM 6961ab1, ADR-0150's census).
Part of epic #31527 (ADR-0150 child E5), the email template half. Connectors landed in #31725, webhooks in #31744. This half waited on R2 #31733 (merged as 36a8700), which stopped the resolver from counting a dependent's added locale (#28802) as a second holder of its base's name (Q2). Remainder on #31677:
connector_action'sconnectorConfig.connectorIdresolving from the calling flow's package (seat answer 6728849524, Q3); the flow's package context now exists with E1 #31738, and that wiring lands as the card's last PR, which closes it.Card thread
domain:server#3·os-steve·session_01ADHkmR4astMzES5UkW47jW; claim 6728454873, file-surface amendments 6728498905 and 6728849524 (Q4:plugin-email/vitest.config.ts).维护者速读
resolvePackageScopedName(无包调用方):先环境自建项,再全环境唯一的包持有者(内置模板排在应用模板之后,依赖包追加的语言不算第二个持有者),否则发送失败并列出全部候选;解析通过后按语言选条目的逻辑不变。packages/plugins/plugin-email/src/template-loader.ts的readBundle开头和EmailTemplateAmbiguousError。pnpm --filter @objectstack/plugin-email exec vitest run src/template-loader.test.ts -t "ADR-0150",预期 2 条通过:应用模板与内置模板同名时照常解析;两个包同名时发送被拒,抛 409 并点名两个包。What changes
createRegistryEmailTemplateLoader'sreadBundle(behindEmailService.sendTemplate/renderTemplate) now asks the ONE package-scoped resolver (resolvePackageScopedName,@objectstack/objectql) which template a name means before it picks a locale.SendTemplateInputnames no package, so the loader asks as a caller with no package (ADR-0150 D3): the environment's own item, else the unique package holder (the overridable baselinecom.objectstack.service.emailranks below a package holder, D3 (b); a dependent's bundle addition is no holder, R2), else a refusal naming every candidate.main, by symbolcreateRegistryEmailTemplateLoaderreadBundle(name, locale)slotSendTemplateInputcarries no package)EmailTemplateAmbiguousError(409RESOURCE_CONFLICT,candidates); any other answer leaves the layered list's pick unchangedWhen the loader is handed a registry without the resolver's slice (a test double carrying only
listItems), it keeps the listed order:template-locale-resolution.test.ts"resolves the same member on repeat calls when two packages declare one slot" stays green on that ground. A real host always passes the ObjectQLSchemaRegistry, which carries the slice.@objectstack/objectqlmoves fromdevDependenciestodependencies(claim amendment 6728498905). No cycle: objectql's workspace closure (deps, devDeps and peers) does not contain plugin-email.ADR-0150's four readings
bare-name-one-holder*dogfood files run in the targeted subset and the full run below.template-loader.test.tspins the app-beside-built-in case on a realSchemaRegistry;email-template-dependent-unshipped-locale-read.dogfood.test.ts(a dependent's addedfr-FRunder the base's name) is green on this head, where atda3bc28e, before R2, it was 3 red. The one change is the ruled narrowing above. No URL, MCP or SDK surface moves.Clause-②: no,Metadata-protocol: none; no ADR-0087 entry.Tests
plugin-email/src/template-loader.test.ts, new describe "the name resolves by (package, name) (ADR-0150 D3)": an app's template beside the built-in renders the app's wording as before; two packages holding the name refuse the send with the 409 naming both, for any locale.vitest.config.tsstops re-exporting the shared preset and spells its one setting (disableConsoleIntercept: true, whichcheck-console-intercept-disarmreads from the file's source) besideenv: { OS_REGISTRY_LOG: 'warn' }(the suite builds a realSchemaRegistry;check:registry-log-declared). No existing test changed.Ablation (
scripts/ablation-replace.mjs --delete, on this head, restored to the HEAD blob,git diff HEADempty after; the suite importssrc, so nodistleg): the refusal throw removed → 1 failed, "two package holders: the send is refused with the 409 conflict naming both — never the first listed":AssertionError: expected undefined to be an instance of EmailTemplateAmbiguousError.Verification
Head 375152d (on
main630a0ba, after R2 #31733):@objectstack/plugin-emailtest exit 0 (50 files, 831 tests), typecheck exit 0, underos-verify-lock(one&&command, VERDICT command-exit 0). Rooteslint --no-inline-config packages/plugins/plugin-email: exit 0 (nolintscript).turbo run build --filter='@objectstack/dogfood^...'(70/70): 19 files on the three types (includingemail-template-dependent-unshipped-locale-read), 98 tests passed.pnpm --filter @objectstack/dogfood testunder the lock at 375152d: VERDICT command-exit 0, Test Files 440 passed / 3 skipped (443), Tests 3654 passed / 41 skipped (3695).exit 0;--ran: "79 derived, 79 run, 0 NOT-MEASURED, 0 UNRUN" (a derived zero). The first pass, at 48ab8da, hadcheck-console-intercept-disarmred on the preset-merging config; the second commit is that fix.test:reponot run locally (CI'sTest Core); this diff touches no repo-project test.🤖 Generated with Claude Code
https://claude.ai/code/session_01ADHkmR4astMzES5UkW47jW