Skip to content

feat(plugin-email): email template names resolve by (package, name) through the shared resolver - #31825

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

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-bwr6nn

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 9, 2026 •

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 (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's connectorConfig.connectorId resolving 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).

维护者速读

  • 改了什么:发邮件和渲染模板时,按名字取哪个模板,先问 R 的共享解析器 resolvePackageScopedName(无包调用方):先环境自建项,再全环境唯一的包持有者(内置模板排在应用模板之后,依赖包追加的语言不算第二个持有者),否则发送失败并列出全部候选;解析通过后按语言选条目的逻辑不变。
  • 为什么改:ADR-0150 把邮件模板改为按(软件包, 名称)寻址;以前两个包同名时静默用列表里第一个包的模板。
  • 风险与回滚:只影响阶段一之前就已经有两个包同名的部署(启动时已有「Bare-name conflict at boot」警告)。按维护者裁决 A,这类发送改为 409,日志里点名两个包;第一方计数 0。其余名字的发送结果不变。回滚即还原本 PR。
  • 先看: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 并点名两个包。
  • 需要你做的:无,席位落地。本卡还剩 connector_action 的 connectorId 按调用 flow 的软件包解析,作为最后一个 PR。

What changes

createRegistryEmailTemplateLoader's readBundle (behind EmailService.sendTemplate / renderTemplate) now asks the ONE package-scoped resolver (resolvePackageScopedName, @objectstack/objectql) which template a name means before it picks a locale. SendTemplateInput names 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 baseline com.objectstack.service.email ranks below a package holder, D3 (b); a dependent's bundle addition is no holder, R2), else a refusal naming every candidate.

Reader on main, by symbol Pick today Caller package After this PR
createRegistryEmailTemplateLoader readBundle first non-built-in item per (name, locale) slot none (SendTemplateInput carries no package) the name is resolved first; a refusal fails the send with EmailTemplateAmbiguousError (409 RESOURCE_CONFLICT, candidates); any other answer leaves the layered list's pick unchanged

When 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 ObjectQL SchemaRegistry, which carries the slice.

@objectstack/objectql moves from devDependencies to dependencies (claim amendment 6728498905). No cycle: objectql's workspace closure (deps, devDeps and peers) does not contain plugin-email.

ADR-0150's four readings

  • (a) phase-1 one-holder tests stay green. Untouched by this PR; the four bare-name-one-holder* dogfood files run in the targeted subset and the full run below.
  • (b) bare-name behaviour unchanged for every name with one holder. template-loader.test.ts pins the app-beside-built-in case on a real SchemaRegistry; email-template-dependent-unshipped-locale-read.dogfood.test.ts (a dependent's added fr-FR under the base's name) is green on this head, where at da3bc28e, before R2, it was 3 red. The one change is the ruled narrowing above. No URL, MCP or SDK surface moves.
  • (c) no migration. Nothing stored is read or written differently.
  • (d) Clause-②: no, Metadata-protocol: none; no ADR-0087 entry.

Tests

plugin-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.ts stops re-exporting the shared preset and spells its one setting (disableConsoleIntercept: true, which check-console-intercept-disarm reads from the file's source) beside env: { OS_REGISTRY_LOG: 'warn' } (the suite builds a real SchemaRegistry; check:registry-log-declared). No existing test changed.

Ablation (scripts/ablation-replace.mjs --delete, on this head, restored to the HEAD blob, git diff HEAD empty after; the suite imports src, so no dist leg): 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 main 630a0ba, after R2 #31733):

  • @objectstack/plugin-email test exit 0 (50 files, 831 tests), typecheck exit 0, under os-verify-lock (one && command, VERDICT command-exit 0). Root eslint --no-inline-config packages/plugins/plugin-email: exit 0 (no lint script).
  • Targeted dogfood after turbo run build --filter='@objectstack/dogfood^...' (70/70): 19 files on the three types (including email-template-dependent-unshipped-locale-read), 98 tests passed.
  • Full dogfood run (owed for the declared narrowing), pnpm --filter @objectstack/dogfood test under the lock at 375152d: VERDICT command-exit 0, Test Files 440 passed / 3 skipped (443), Tests 3654 passed / 41 skipped (3695).
  • Derived gates at 375152d: 79 derived, all 79 exit 0; --ran: "79 derived, 79 run, 0 NOT-MEASURED, 0 UNRUN" (a derived zero). The first pass, at 48ab8da, had check-console-intercept-disarm red on the preset-merging config; the second commit is that fix.
  • 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

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

github-actions Bot commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/api/error-catalog.mdx (via RESOURCE_CONFLICT (literal, a string literal in EmailTemplateAmbiguousError))
  • content/docs/api/error-handling-server.mdx (via RESOURCE_CONFLICT (literal, a string literal in EmailTemplateAmbiguousError))
  • content/docs/api/metadata-api.mdx (via RESOURCE_CONFLICT (literal, a string literal in EmailTemplateAmbiguousError))
  • content/docs/automation/email-templates.mdx (via email_template (literal, a string literal in EMAIL_TEMPLATE_TYPE))
  • content/docs/concepts/metadata-lifecycle.mdx (via email_template (literal, a string literal in EMAIL_TEMPLATE_TYPE))
  • content/docs/deployment/cloud-connection.mdx (via RESOURCE_CONFLICT (literal, a string literal in EmailTemplateAmbiguousError))
  • content/docs/deployment/environment-variables.mdx (via email_template (literal, a string literal in EMAIL_TEMPLATE_TYPE))
  • content/docs/deployment/install-modes.mdx (via RESOURCE_CONFLICT (literal, a string literal in EmailTemplateAmbiguousError))
  • content/docs/deployment/validating-metadata.mdx (via email_template (literal, a string literal in EMAIL_TEMPLATE_TYPE))
  • content/docs/permissions/attachments-access.mdx (via RESOURCE_CONFLICT (literal, a string literal in EmailTemplateAmbiguousError))
  • content/docs/permissions/authentication.mdx (via RESOURCE_CONFLICT (literal, a string literal in EmailTemplateAmbiguousError))
  • content/docs/permissions/permission-sets.mdx (via RESOURCE_CONFLICT (literal, a string literal in EmailTemplateAmbiguousError))
  • content/docs/permissions/sharing-rules.mdx (via RESOURCE_CONFLICT (literal, a string literal in EmailTemplateAmbiguousError))

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

  • content/docs/releases/v17/17-6.mdx (via RESOURCE_CONFLICT (literal, a string literal in EmailTemplateAmbiguousError))
  • content/docs/releases/v17/17-7.mdx (via RESOURCE_CONFLICT (literal, a string literal in EmailTemplateAmbiguousError))
  • content/docs/releases/v18/18-0.mdx (via RESOURCE_CONFLICT (literal, a string literal in EmailTemplateAmbiguousError))
  • content/docs/releases/v18/18-1.mdx (via RESOURCE_CONFLICT (literal, a string literal in EmailTemplateAmbiguousError))
  • content/docs/releases/v18/18-2.mdx (via email_template (literal, a string literal in EMAIL_TEMPLATE_TYPE))
  • content/docs/releases/v18/18-3.mdx (via RESOURCE_CONFLICT (literal, a string literal in EmailTemplateAmbiguousError), email_template (literal, a string literal in EMAIL_TEMPLATE_TYPE))

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

Coarse fallback — 7 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 732def5937f7c46961bf5735f111468ba8be34f1 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 48f6dce4f7c064597d860265df67bf028f9f256b — the merge of head 375152d8daff7ced0fb68b1dc6cb941a2b8c7de2 into base 732def5937f7c46961bf5735f111468ba8be34f1, 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 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

⚠️ 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 732def5937f7c46961bf5735f111468ba8be34f1 → 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, email half, head 375152d · 2026-11-09T05:56Z

Verdict: PASS.

Red line Reading
1. Never loosens readBundle asks resolvePackageScopedName as a caller with no package, because SendTemplateInput names none, and it asks before the per-locale pick. A refusal fails the send. Any other answer leaves the layered pick exactly as it was. A dependent's added locale is no longer a second holder (R2's bundle-addition rule, E5 Q2), so the dependent-locale dogfood that was red before R2 is green, and a read naming the dependent still reaches it.
2. Ambiguity reported, never guessed One resolver, no first-registered fallback. A refusal throws EmailTemplateAmbiguousError (409 RESOURCE_CONFLICT, with candidates). Ablation on the throw is red.
3. Independently releasable Narrowing: declares D3 (a) and the Q1 sealed clash (Ruled A, 6729831159), first-party 0. Full dogfood 3654 / 0 failed, gates 79/79, plugin-email 831. The vitest config spells the preset's one setting locally, because check-console-intercept-disarm.mjs reads this file's source. Accepted.

D9 obligation: when B #31699 narrows phase 1 for email_template, a composition with no resolver slice must fail closed. This is the same obligation as connectors and webhooks (6729233545).

This closes E5. Seat 3 accepts and lands after green CI. It is not a governed surface.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-steve · session_01ADHkmQXLfvyB5R53VDm3Ka

席位核对 · ACCEPT · #31825 (Part of #31677, ADR-0150 E5, email half) · 2026-11-09T06:20Z

Head-sha: 375152d8daff7ced0fb68b1dc6cb941a2b8c7de2

  • Form: draft to main. The first content line is Part of #31677, and the body names the remainder: connector_action's connectorId, per seat answer Q3 A (6728849524), built after E1 feat(service-automation)!: the flow index and the activation ledger consult on (package, name) (ADR-0150 E1) #31738 as E5's last PR. Clause-②: no, Metadata-protocol: none. The narrowing is D3 (a) plus the sealed clash ruled under Q1 (6729831159), with first-party count 0.
  • Premise measured: built on main after R2 feat(core, metadata-core, objectql): the package-scoped resolution rule as a lean core, engine functions by package #31733 (36a87008), which fixed the bundle-addition holder (E5 Q2), the reason this half waited. Email template names resolve through R's resolver only; there is no second resolver and no first-registered fallback.
  • Pins and ablations: the throw ablation goes red and is restored. plugin-email: 831/831, typecheck 0, eslint 0. The dependent-locale dogfood file, 3 red before R2, is green.
  • Gates (check-runs on this head): 70 runs, 59 success, 11 skipped, 0 other. check-governed-merges --pr 31825: NOT governed, 166 changed lines. K = 25 first-parent commits on main since the PR base, and a local merge with main is clean. dispatch-gates: 79/79 exit 0. Full dogfood: 3654 passed, 0 failed.
  • Accepted deviations: plugin-email's vitest config spells disableConsoleIntercept next to OS_REGISTRY_LOG: 'warn' (Q4), because check-console-intercept-disarm reads the source text.
  • Epic red-line review: PASS 6733368595 at this head.
  • Out of scope: none filed.
  • Landing: ready and auto-merge in this stroke. On merge: a landing note on webhooks, connectors, email templates on (package, name) (ADR-0150 child E5) #31677. pm:dispatched stays, and the claim continues on claude/project-thread-bwr6nn for the connectorId part.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 9, 2026 06:21
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 9, 2026 06:21
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 9, 2026
Merged via the queue into main with commit 785d432 Nov 9, 2026
71 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-bwr6nn branch November 9, 2026 06:57
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>
rajeev-kumar-se pushed a commit to rajeev-kumar-se/objectstack that referenced this pull request Nov 10, 2026
…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>
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