Skip to content

feat(service-automation)!: the flow index and the activation ledger consult on (package, name) (ADR-0150 E1) - #31738

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

objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/project-thread-ack2ot

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Requested by Steve · project thread
Fixes #31672

Clause-②: no (narrowing)
Metadata-protocol: none
Narrowing: (1) D3 (a), as the card declares: a package flow's bare subflow / map reference whose only holder is an environment-authored flow is refused (outside-closure, naming the environment). (2) D3 step 3, ruled inside ADR-0150 by the epic seat (#31527 comment 6728487836): a package flow's bare reference whose only holder is a package outside its declared dependency closure is refused (outside-closure, naming that package). Measured first-party for (1) and (2): 0, including the sibling-in-one-artifact sub-case (ADR-0130 co-ownership): 0. (3) A flow name two packages hold, which only a deployment sealed at its cold boot with a clash that predates phase 1 reaches (#31677 Q1, ruled A, comment 6729831159): arming it is refused and a run of it is refused, naming both, so neither flow is armed (it used to arm the lexicographically first package's flow). Measured first-party: 0. Methods under Verification.
Skills-docs: follow-up #31741

epic:#31527 child E1 · domain:server#3 · dev session session_01ADHkmQjmHnuYAuaRd9k252

维护者速读

  • 改了什么:流程引擎按(软件包, 名称)解析裸流程名,全部经 R 的共享解析器 resolvePackageScopedName。软件包内流程的子流程/映射节点先找本包,再找它声明依赖闭包里唯一的持有者,否则拒绝并列出全部候选。两个软件包同名的流程(包括冷启动封存的 phase 1 之前的旧冲突)在注册和运行时都拒绝,不再按包名字典序挑一个。启停台账按(软件包, 名称)生效:过期行仍然关闭当前持有者,并警告一次。
  • 为什么改:专题 design(metadata): address behavioural metadata by package — key flows, actions, jobs and other non-object types by (package, name); re-open ADR-0028 (replaces #30806 phase 3 renames) #31527 子卡 E1(ADR-0150 D3/D4),以及 webhooks, connectors, email templates on (package, name) (ADR-0150 child E5) #31677 Q1 裁决 A。
  • 风险与回滚:收窄三处,第一方实测都是 0 处。单持有者的裸名、REST 门、触发器行为不变。phase 1 的 2 条 dogfood 用例按裁决改为读拒绝。回滚即还原本 PR。
  • 先看:engine.ts 的 registerFlow 与 execute 两处 flowNameRefusal 闸门;plugin.ts 的 flowNameResolver;flow-activation-projection.ts。
  • 两分钟验证:pnpm --filter @objectstack/service-automation exec vitest run src/flow-package-scoped-resolution.test.ts,预期 20 条全绿。
  • 需要你做的:无。席位在史诗席复核通过且 CI 绿后落地。

What changes

Step 0 — every read point that looks a flow up by bare name today (on origin/main b126c13):

# Read point File / symbol Caller This PR
1 arming a name (boot pull, kernel:ready bind, reload re-sync, mutation sync all end here) engine.ts registerFlow (via plugin.ts resolveFlowContenders → flow-precedence.ts resolveFlowPrecedence, which arms the lexicographically-first package on two package holders) no package gated: the resolver is asked for a caller with no package; a name two packages hold is refused, naming both, and nothing is armed
2 running a flow by name engine.ts execute (this.flows.get(flowName)) doors, triggers (no package); subflow / map children (the calling flow's package) gated: the resolver is asked for the caller's package
3 subflow / map child name builtin/subflow-node.ts, builtin/map-node.ts → engine.execute(flowName, childContext) the parent flow's package (childContext.flowName, stamped by resolveRunContext) resolved through #2
4 activation ledger projection flow-definitions.ts hydrateFlowActivations, toggleFlow; read by isFlowEnabled, describeDisabledFlow, the §7.3 subflow guards (subflow-guards.ts) — keyed by (package, name) (FlowActivationProjection)
5 which package ships a name engine.ts packagedFlowOwner (the loader's set, plugin.ts packagedFlowReader) — unchanged; it is now also the caller package of #2 and the holder #4 reads
6 context-free by-name reads getFlow, getFlowRegistration, toggleFlow, getFlowVersionHistory, rollbackFlow, unregisterFlow, flowCredentialHoldings no package unchanged: they read the engine's one armed slot per name, which #1 now fills only for a name with one holder
7 stored references by flow name run store (flowName), credential channel, dispatch keys, approvals flow:NAME, history by checksum — not here: S3 #31684 (R7–R10, R14, R20) adds the package columns
8 screen node getFlow(flowName) builtin/screen-nodes.ts the run's own flow unchanged: reads the running flow's own definition

Step 1. Both gates call one engine seam, flowNameRefusal, which asks the reader the host attaches (setFlowNameResolver, new). The automation plugin attaches flowNameResolver(ctx): resolvePackageScopedName from @objectstack/objectql over the ObjectQL registry, read at question time (the startup-registry rule). ⛔ No second resolver and no first-registered fallback: the engine reads only the resolver's refused answer; resolved and not-found leave the existing path (a name the registry knows nothing of has one holder, the engine's own map).

  • Caller package. execute reads context.flowName, which the engine stamps on every run context (resolveRunContext), so a child call carries its parent's name; the caller package is that flow's packagedFlowOwner, or none for a door, a trigger, or an environment-authored flow (ADR-0150 D3: an environment item resolves as a caller with no package).
  • Arming. registerFlow asks for a caller with no package, so a name two packages hold (ambiguous-in-environment) throws the resolver's sentence before anything is armed. Each of the four call sites already catches a registration throw and logs it as a warning carrying the flow and the sentence naming both packages (the boot pull's is [Automation] failed to register flow).
  • Activation. flowLedgerDisabled becomes a FlowActivationProjection that keeps each row's package and state. The row of the package that ships the name decides for it, in either state. A row of another package that still holds the name keys only that package's flow (D4). A stale row, whose package no longer holds the name, still switches the shipping package's flow off, as before rows were keyed, and is warned once naming both packages and the remedy: switch the flow again, which writes the shipping package's own row (epic seat ruling B on Q3). "Still holds" is the resolver reaching that package's own flow; with no resolver it reads as stale, so the row keeps the flow off. A row naming no package binds to the one holder (D8's backfill reading). The two writers stay hydrateFlowActivations and toggleFlow (the existing pin in flow-activation-ledger.test.ts holds).
  • Dependency. @objectstack/objectql is a runtime dependency of service-automation. E5 made that move on main first, so after the merge it is no longer in this diff. No cycle: objectql's runtime workspace closure (11 packages) does not contain service-automation; pnpm check:workspace-manifest-cycles green. The flow reader asks for the registry slice through E5's packageScopedRegistryOf, so the slice is checked in one place.

ADR-0150's four readings.

  • (a) Phase 1's one-holder tests stay green: flow stays in BARE_NAME_ONE_HOLDER_TYPES and this PR touches nothing in objectql. Of phase 1's dogfood pins, exactly the two that read the sealed clash's armed flow move to the refusal, as webhooks, connectors, email templates on (package, name) (ADR-0150 child E5) #31677 Q1 rules; every other pin is unchanged.
  • (b) Bare-name behaviour is unchanged for every name with one holder, except the declared narrowing: a door, a trigger, MCP and the SDK reach execute with no run context, so they resolve environment-wide, and a name with one holder resolves (pinned: reading (b) block, plus the door control in the red-line-1 block).
  • (c) No migration in this PR.
  • (d) Clause-②: no (narrowing); no protocol change, so no ADR-0087 migration entry (not-required (no-migration-prescription) in the changeset).

Tests

New src/flow-package-scoped-resolution.test.ts (20 cases) over a real SchemaRegistry (packages through installPackage, so the declared closure is the save door's) and the plugin's own flowNameResolver:

  • own flow, closure holder, environment caller to a package holder, engine-only flow: all run;
  • red line 1: D3 (a) refused naming the environment; D3 step 3 refused naming the stranger package; the door control runs the same environment flow;
  • red line 2: a name two packages hold is refused at registration naming both, nothing armed; a door run of a sealed clash is refused naming both; a package caller whose closure holds the name twice is refused naming both; control: one holder arms;
  • reading (b): a door runs a packaged flow by its bare name; with no resolver attached a subflow reaches an environment flow as before;
  • D4: the shipping package's row switches its flow off, a row of another package that still holds the name does not; a stale row still switches the holder off, is warned once naming both packages and the remedy, and the remedy works; the toggle door writes and reads under the shipping package; four FlowActivationProjection unit cases.

Forced test changes: packages/qa/dogfood/test/bare-name-one-holder-flow-family.dogfood.test.ts, the two pins that read the sealed clash's flow as armed (armedFlowLabel('qa_shared_flow') === 'alpha flow', in the cold-boot case and the third-package install case) now read it unarmed and find the refusal naming both packages in the boot log. The seat directed this after #31677 Q1 was ruled A. The file's header now states the refusal. No other existing test changed.

Acceptance notes

  • D4's "all same-named flows arm" is not done here. The engine keeps one armed slot per bare name and refuses a name two packages hold. Per the epic seat, arming both is a prerequisite of B objectql: narrow the phase-1 one-holder refusal from environment to namespace for flow (ADR-0150 child B, flow family) #31696 (which narrows phase 1 for flows), not of S3: it becomes reachable only when phase 1 stops refusing a second flow holder.
  • A missing resolver slice refuses nothing. An engine with no ObjectQL registry answers as before; accepted while phase 1 refuses for flow. B objectql: narrow the phase-1 one-holder refusal from environment to namespace for flow (ADR-0150 child B, flow family) #31696 must fail closed on a missing slice or prove every composition carries it (the epic seat's recorded obligation, same as E5's).
  • The precedence's collision line still says "arming … by package id". For two package holders resolveFlowPrecedence still logs Flow name collision: … arming <first> by package id, and the refusal line follows it naming both. The wording is pinned by packages/cli/src/utils/format.boot-warning-classes.test.ts, outside this card's file surface, so it is reported as a follow-up and not changed here.
  • The §7.3 subflow guards still list callers by bare reference, so a caller whose reference is now refused still counts as reaching the child (a stricter guard, unchanged from today).
  • A refused child call answers success: false with the resolver's sentence and no code: no new error code is minted. The doors' 409 RESOURCE_CONFLICT for an ambiguous bare address is G1's (D5). E2 (actions) and E6 (api endpoints) callers execute with no caller package until their cards land (seat: A).

Verification

Ablations (each refusal and ledger pin; mutation through scripts/ablation-replace.mjs on a committed head, restored, blob == HEAD, git diff HEAD empty)
  • The registerFlow gate deleted (head c2f2031): Tests 1 failed | 19 passed (20): the two-holder registration pin goes red.
  • The execute gate deleted (head c2f2031): 4 failed: D3 (a), D3 step 3, the sealed-clash door run and the ambiguous-in-closure caller all go red (the child runs: this is also the base reading, the behaviour on main).
  • The projection's "still holds" check replaced by continue (head 4cf79d48): Tests 2 failed | 15 passed (17): the stale-row engine pin and unit pin go red.
  • The stale-row report deleted (head 4cf79d48): 2 failed | 15 passed (17): the same two pins go red on the warning.
  • The holder's own row no longer deciding (head 4cf79d48): 3 failed | 14 passed (17): the foreign-holder row, stale-row remedy and toggle pins go red.
First-party narrowing measures
  • (1), (2): every type: 'subflow' / type: 'map' node in examples/** and packages/** outside tests and the spec (git grep on b126c13): 5 nodes, all in examples/app-showcase, naming showcase_notify_owner, showcase_closure_signoff, showcase_one_task_signoff, showcase_recalc_task_estimate, all flows of the same package. Refused by D3 (a): 0; by D3 step 3: 0; sibling-in-one-artifact sub-case: 0.
  • (3): across every boot of the full dogfood battery (bcc5ba6), the cold boot's Bare-name conflict at boot warning appears 4 times and names one flow, qa_shared_flow, the QA fixture that builds the clash on purpose. First-party: 0.
Gates, suite and dogfood (head d7a38bb, merged with main at c28d8e7)
  • Derived gates: dispatch-gates.mjs --ran: 76 derived, 76 run, 0 NOT-MEASURED, 0 UNRUN; every recorded exit is 0 on d7a38bb. An earlier pass (bcc5ba6) caught one real finding, fixed here: check-tenant-audit-census could not read the receiver of the projection's delete(), so the local is annotated (no UNTYPED_RECEIVERS row added).
  • Package: service-automation vitest run 267 files / 3478 tests passed; tsc --noEmit 0; eslint on the package and the dogfood file 0.
  • Dogfood (turbo run build --filter='@objectstack/dogfood^...' then pnpm --filter @objectstack/dogfood test, under the verify lock): Test Files 440 passed | 3 skipped (443), Tests 3652 passed | 41 skipped (3693), VERDICT command-exit 0. That includes the two moved phase-1 pins.

Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_01ADHkmQjmHnuYAuaRd9k252

…through the package-scoped resolver

The flow index and the activation ledger projection consult the package
(ADR-0150 D3/D4). A subflow or map child named inside a package flow resolves
from that package: its own flow, else the one holder in its declared
dependency closure, else a refusal naming every candidate. A name two
packages hold is refused at registration instead of arming one of them.
The ledger projection switches off the (package, name) a row names.

Claude-Session: https://claude.ai/code/session_01ADHkmQjmHnuYAuaRd9k252
Co-authored-by: Claude <noreply@anthropic.com>
…the projection's receiver

A flow name two packages hold reaches the engine only from a deployment
that held the clash before phase 1, sealed at its cold boot with a
warning. Refusing it is a maintainer ruling still open, and phase 1's
cold-boot pins read it as armed, so the engine now acts only on the
resolver's outside-closure refusal (D3 (a), D3 step 3) and the arming
gate in registerFlow is left out. The activation projection's Set
receiver is annotated so the tenant-audit census can place its delete().

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADHkmQjmHnuYAuaRd9k252
@github-actions github-actions Bot added size/l dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 9, 2026
@github-actions

github-actions Bot commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-automation, touching 20 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/services/service-automation/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/api/declarative-endpoints.mdx (via /api/v1/automation/:name/trigger (route, bridged from symbol toggleFlow — its route source's handler names it))
  • content/docs/automation/flows.mdx (via AutomationServicePlugin (symbol, a top-level class), registerFlow (symbol, a method of class AutomationEngine), /api/v1/automation/:name/trigger (route, bridged from symbol toggleFlow — its route source's handler names it))
  • content/docs/data-modeling/formulas.mdx (via registerFlow (symbol, a method of class AutomationEngine))
  • content/docs/deployment/cli.mdx (via registerFlow (symbol, a method of class AutomationEngine))
  • content/docs/kernel/architecture.mdx (via getService (literal, a string literal in flowNameResolver))
  • content/docs/kernel/cluster.mdx (via AutomationServicePlugin (symbol, a top-level class), getService (literal, a string literal in flowNameResolver))
  • content/docs/kernel/runtime-services/audit-service.mdx (via getService (literal, a string literal in flowNameResolver))
  • content/docs/kernel/services-checklist.mdx (via getService (literal, a string literal in flowNameResolver))
  • content/docs/plugins/anatomy.mdx (via getService (literal, a string literal in flowNameResolver))
  • content/docs/plugins/development.mdx (via getService (literal, a string literal in flowNameResolver))
  • content/docs/protocol/kernel/http-protocol.mdx (via /api/v1/automation/:name/trigger (route, bridged from symbol toggleFlow — its route source's handler names it))
  • content/docs/protocol/kernel/index.mdx (via getService (literal, a string literal in flowNameResolver))

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

  • content/docs/releases/v16.mdx (via AutomationEngine (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via AutomationEngine (symbol, a top-level class), registerFlow (symbol, a method of class AutomationEngine))
  • content/docs/releases/v17/17-4.mdx (via registerFlow (symbol, a method of class AutomationEngine))
  • content/docs/releases/v17/17-5.mdx (via registerFlow (symbol, a method of class AutomationEngine))
  • content/docs/releases/v17/17-6.mdx (via AutomationEngine (symbol, a top-level class), AutomationServicePlugin (symbol, a top-level class))
  • content/docs/releases/v17/17-7.mdx (via registerFlow (symbol, a method of class AutomationEngine))
  • content/docs/releases/v18/18-3.mdx (via registerFlow (symbol, a method of class AutomationEngine))

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

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/services/service-automation/src/index.ts) — pages documenting those are invisible to this run
  • 5 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 — 10 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json f65c36fcca8c33d38c62104c0888c950a315089a → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 38fd92b9d1739005a3b3804c80e4e53cdb587141 — the merge of head 1081c7770bfa860b55635ae45c9348bd4845312e into base f65c36fcca8c33d38c62104c0888c950a315089a, 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 38fd92b9d1739005a3b3804c80e4e53cdb587141 && git checkout 38fd92b9d1739005a3b3804c80e4e53cdb587141
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f65c36fcca8c33d38c62104c0888c950a315089a 1081c7770bfa860b55635ae45c9348bd4845312e && git checkout -B drift-repro f65c36fcca8c33d38c62104c0888c950a315089a && git merge --no-ff 1081c7770bfa860b55635ae45c9348bd4845312e

node scripts/docs-audit/affected-docs.mjs --json f65c36fcca8c33d38c62104c0888c950a315089a

⚠️ 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 f65c36fcca8c33d38c62104c0888c950a315089a → 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: E1 #31672, head e94f303 · 2026-11-09T01:06Z

Verdict: PASS once one small change is in. It needs no re-review; send the head.

Red line Reading
1. Never loosens execute() asks resolvePackageScopedName with the calling flow's package (packagedFlowOwner(context.flowName)). The environment-only holder (D3 (a)) and the outside-closure holder (D3 step 3) are refused, naming every candidate. There is no fallback and no second resolver. Door, trigger and no-context callers resolve environment-wide, with one-holder names unchanged and pinned. While phase 1 refuses two flow holders, the engine's one flow per name is the resolver's only holder, so a resolved answer and the executed flow agree.
2. Ambiguity reported, never guessed Outside-closure: refused with candidates. The ambiguous-* arm is held back, because it is reachable only through the pre-phase-1 sealed clash, which waits on the maintainer's Q1 Ruled: on #31677 (6728850663). The ready patch in the PR body is the right carrier.
3. Independently releasable (a) green: 3470 package tests and the dogfood VERDICT. (b) pinned. (c) no migration. (d) Clause-②: no (narrowing), covering D3 (a) and step 3, 0 first-party, sibling-in-artifact 0. objectql moves to dependencies with no cycle.

The change: a held-back refusal is still reported.

  • Today flowNameRefusal drops an ambiguous-* refusal without a word, and the precedence pick runs.
  • Holding back the act is right until Q1. Holding back the report is not.
  • Required: when the resolver refuses with an ambiguous-* reason that this child does not act on, warn once per (name, caller), naming every candidate and saying that today's precedence pick still runs until webhooks, connectors, email templates on (package, name) (ADR-0150 child E5) #31677 Q1.
  • Add a test that is red without it.

Recorded obligations:

The D4 activation projection keyed by (package, name) is accepted: an environment-wide row ('') disarms every holder, and a package row disarms only its holder.

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

…r name and caller

Holding back the act on a name two packages hold waits for the
maintainer's ruling; the report does not. The engine now warns once per
(name, caller) with the resolver's sentence naming every candidate, and
says the flow precedence's pick still runs until that ruling.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADHkmQjmHnuYAuaRd9k252
…d is reported

A ledger row whose package no longer holds the flow name is stale: it
keeps switching the name's current holder off, as before rows were
keyed, and is warned once naming both packages and the remedy (switch
the flow again, which writes the holder's own row). A row of a package
that still holds the name keys only that package's flow, and the
holder's own row, in either state, decides for it.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADHkmQjmHnuYAuaRd9k252
…led clash included

The maintainer ruled that a name two packages held before phase 1 is
refused like any other two-holder name. The engine now refuses arming
such a name and refuses running it, naming both holders, in place of
the interim warning; the phase-1 dogfood pins on the sealed clash now
read neither flow armed and the refusal in the boot log.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADHkmQjmHnuYAuaRd9k252
…ne reader

The merge brought the connector reconcile's own registry slice reader
and a second import of the same type. The flow-name reader now asks that
one reader too, so the slice the package-scoped resolver needs is
checked in one place.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADHkmQjmHnuYAuaRd9k252
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

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

Epic red-line review, final: E1 #31672, head d7a38bb · 2026-11-09T02:34Z

Verdict: PASS.

  • Q1 Ruled A (6729831159) replaces my warn-once item. registerFlow asks for a caller with no package and refuses a name two packages hold, naming both, with nothing armed. execute refuses every resolver refusal (outside-closure, ambiguous-in-closure, ambiguous-in-environment). The two phase-1 dogfood pins on the sealed clash move to "neither armed, plus the refusal logged". The boot carries on and is not crashed by the refusal.
  • Q3 as ruled. The holder's own row decides in either state. A row of a package that still holds the name keys only itself. A stale row keeps the holder off and warns once, naming both packages and the remedy. All three are pinned.
  • B objectql: narrow the phase-1 one-holder refusal from environment to namespace for flow (ADR-0150 child B, flow family) #31696 prerequisites recorded: arm-both and missing-slice fail-closed (6729815913).
  • Checks: ablations red (registerFlow 1, execute 4); dogfood VERDICT 0 (3652 tests); package 3478/3478; 76/76 gates, 0 NOT-MEASURED.

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

…-ack2ot

# Conflicts:
#	packages/qa/dogfood/test/bare-name-one-holder-flow-family.dogfood.test.ts
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-steve · session_01ADHkmQXLfvyB5R53VDm3Ka

席位核对 · ACCEPT · #31738 (Fixes #31672, ADR-0150 E1) · 2026-11-09T03:04Z

Head-sha: 1081c7770bfa860b55635ae45c9348bd4845312e

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 9, 2026 03:04
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 9, 2026 03:04
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 9, 2026
Merged via the queue into main with commit 55c4e95 Nov 9, 2026
52 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-ack2ot branch November 9, 2026 03:42
rajeev-kumar-se pushed a commit to rajeev-kumar-se/objectstack that referenced this pull request Nov 10, 2026
… the calling flow's package (objectstack-ai#31863)

<!-- claude-projects-attribution: {"github_login":"os-steve"} -->
_Requested by **Steve** · [project
thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9B9qYj7GTyDGfaMgXwXx7rZ)_

Fixes objectstack-ai#31677

Clause-②: no
Metadata-protocol: none
Narrowing: a `connector_action` in a packaged flow whose
`connectorConfig.connectorId` does not resolve for the flow's package is
refused, naming every candidate, instead of dispatching to whatever
instance the engine holds under the name. Three sub-cases: the only
holder is outside the flow package's declared closure (the
closure-outside sub-case of objectstack-ai#31527 comment 6728487836, a sibling package
in the same artifact included); the only instance is
environment-authored (ADR-0150 D3 (a)); two packages in the closure hold
the name. First-party count: 0 (every first-party packaged flow names
its own package's instance or a connector a plugin registers in code;
HotCRM 6961ab1, ADR-0150's census).

This closes card objectstack-ai#31677 (ADR-0150 child E5, epic objectstack-ai#31527): it is the
card's last PR. Connectors landed in objectstack-ai#31725, webhooks in objectstack-ai#31744, email
templates in objectstack-ai#31825. This part is the remainder those PRs named:
`connector_action` resolving `connectorConfig.connectorId` from the
calling flow's package (seat answer 6728849524, Q3), now that E1 objectstack-ai#31738
gives a run its flow's package.

**Epic review R1 (6735080780), fixed here:** the caller package now
follows the body the engine ARMS under the flow's name, not the loader's
set by bare name. An environment override of P's flow (a package-less
stored row, or P's declared `.custom` override) names its references
with no package, per ADR-0150 D3's overlay paragraph. That corrects E1's
`subflow`/`map` reader too, back to main's answer before E1 for
overrides. It is a loosening, not a new narrowing. Names were accepted
by seat 3 (6735158321).

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

## 维护者速读

- 改了什么:flow 里的 `connector_action` 按名字找连接器实例前,先用 R 的共享解析器
`resolvePackageScopedName`,以「这个 flow
当前生效的定义所属的软件包」为调用方去问;解析器拒绝时节点失败并列出全部候选。引擎在每次注册 flow
时记下生效定义的来源(包本身、`.custom` 覆盖、环境自建),只有包本身的定义才以该包为调用方;覆盖和环境自建一律按无包调用方解析(E1
的 subflow 也一并纠正)。
- 为什么改:ADR-0150 把连接器改为按(软件包, 名称)寻址;以前包里的 flow
会派发到引擎里同名的任何实例,哪怕它属于闭包外的包或只是环境自建。
- 风险与回滚:只影响「包内 flow 指向自己够不着的已声明连接器」的部署:只有闭包外的包持有、只有环境自建、或闭包内两个包同名。第一方计数
0。环境自建的 flow、代码注册的连接器(slack、rest 等)照常派发。回滚即还原本 PR。
- 先看:`engine.ts` 的 `armedFlowPackage` 和 `registerFlow` 的
`armedFrom`,`flow-precedence.ts` 里识别协议视图下发的覆盖文档那一段,再看
`connectorNameRefusal`。
- 两分钟验证:`pnpm --filter @objectstack/service-automation exec vitest run
src/flow-package-scoped-resolution.test.ts -t
"connector_action|environment override"`,预期 17 条通过:连接器的 8 条,加上三种覆盖形态各自的
connectorId 与 subflow 都按无包解析(6 条)、包本身定义的两条对照、写入口的一条。
- 需要你做的:无,席位落地。本 PR 合并后 objectstack-ai#31677 关闭。

## Step 0: where the flow's package comes from

| What | Where on `main` | Used here as |
|:--|:--|:--|
| The run's flow name | `AutomationContext.flowName`, set for every run
`execute` starts | the key |
| Which package ships that flow |
`AutomationEngine.packagedFlowOwner(name)`, fed by
`setPackagedFlowSource(packagedFlowReader(ctx))` (E1 objectstack-ai#31738; the
protocol's `packagedArtifactOwner`) | the caller package |
| Both together | private `flowCallerPackage(context)` (E1):
`context.flowName` → `packagedFlowOwner`, `null` for an
environment-authored flow or a call outside a run | **corrected (R1)**:
`context.flowName` → `armedFlowPackage` |
| Which body is armed under the name | `resolveFlowPrecedence` →
`classifyContender` at the boot pull, the `kernel:ready` bind, the
`metadata:reloaded` re-sync and the mutation sync; the class was dropped
after the pick, and a single-contender name was never classified | each
winner carries its `contender`, recorded at `registerFlow` |

Measured on the real composition
(`flow-declared-override-first-boot-pull.integration.test.ts`:
ObjectKernel, ObjectQLPlugin over driver-sql/better-sqlite3, two cold
boots): at the boot pull the declared override arrives as a stored row
carrying its binding, so `classifyContender` already answers `override`.
At `kernel:ready` the protocol's view serves it with no binding, stamped
`_packageId` = the base beside `overrides.package` = the base, and
`isCodeArtifactBody` answered **`package`**. That is the stamp trap; the
classifier now recognises that shape.

## What changes

- `engine.ts`: a `ConnectorNameResolver` type,
`setConnectorNameResolver`, and `connectorNameRefusal(connectorId,
context)`. It asks the resolver for the flow's package and returns the
refusal when there is one. A caller with no package, a name the registry
holds nothing under (a connector a plugin registers in code), or a host
with no resolver returns `undefined` and dispatches by name as before.
- `builtin/connector-nodes.ts`: `connector_action` checks
`connectorNameRefusal` before `resolveConnectorAction` and fails the
node with the resolver's message, prefixed with the node id.
- `connector-package-scope.ts`: `connectorNameResolver(ctx)`, the ONE
resolver (`resolvePackageScopedName`, type `connector`) over the
registry ObjectQL serves, read at question time like E1's
`flowNameResolver`.
- `plugin.ts`: attaches it at `init()`, beside `setFlowNameResolver`.

R1, the shared reader:

- `engine.ts`: `registerFlow(name, definition, armedFrom?:
FlowContender)` records the arm in a `WeakMap` keyed by the parsed body
it stores. With no `armedFrom`, the arm is `runtime` (a write door's
body is the caller's own). The exception is a body that IS the parsed
object the engine armed (the `/automation` door putting back `held`),
which keeps its arm by identity. `armedFlowPackage(name)` (public,
read-only) returns `packagedFlowOwner(name)` only when the armed body's
arm is `package`, else `undefined`. `flowCallerPackage` reads it.
- `flow-precedence.ts`: every winner carries `contender`,
single-contender names included. `classifyContender` also answers
`override` for the served shape: `_packageId` equal to the owner AND
`isServedOverrideRowOf` holding for the row the pair would store it as
(the reading `servedOverrideBaseOf` makes, through the exported
predicate). A package-less row that declares the same carries no
envelope and stays `runtime`.
- `plugin.ts`: the four arming steps pass `entry.contender`.

Kept for the connector backstop child (ADR-0150 D9), as the review
recorded: dispatch is still `resolveConnectorAction(connectorId)` by
bare name, which is right only while phase 1's one-holder refusal keeps
one instance per name.

Why a refusal is the only answer that needs acting on: the engine holds
one instance per name, materialized for the no-package answer (the
declared-connector reconcile from objectstack-ai#31725 already refuses a name two
packages hold). In the registry an environment item exists only for a
name no package holds (a bare registration under a package-held name
keeps the package holder), so a package caller whose name resolves at
all resolves to the instance the engine serves. No second resolver, no
first-registered fallback.

| Reader on `main`, by symbol | Pick today | Caller package | After this
PR |
|:--|:--|:--|:--|
| `connector_action` executor (`registerConnectorNodes`) →
`engine.resolveConnectorAction(connectorId, actionId)` | the engine's
one instance under the name | the flow's package (`flowCallerPackage`,
E1) | a refusal for that package fails the node naming every candidate;
any other answer dispatches as before |

## ADR-0150's four readings

- **(a) phase-1 one-holder tests stay green.** The four
`bare-name-one-holder*` dogfood files are in the full run below.
- **(b) bare-name behaviour unchanged for every name with one holder the
caller can reach.** Pinned: own package, the one closure holder, an
environment-authored flow, a code-registered connector. The change is
the declared narrowing above, plus R1's loosening for overrides back to
main's pre-E1 answer. No URL, MCP or SDK surface moves.
- **(c) no migration.** Nothing stored is read or written differently.
- **(d)** `Clause-②: no`, `Metadata-protocol: none`; no ADR-0087 entry.

## Tests

`service-automation/src/flow-package-scoped-resolution.test.ts`, new
describe "ADR-0150 D3 — a connector_action names its connector from the
calling flow's package (epic objectstack-ai#31527 child E5)", over a real
`SchemaRegistry` with E1's install/ship/author helpers. 8 tests: four
dispatch as before (own package, closure holder, environment-authored
flow, code-registered connector); red line 1 (holder only outside the
closure, refused naming it, nothing dispatched); D3 (a)
(environment-only, refused); red line 2 (two closure holders, refused
naming both); and a booted `AutomationServicePlugin` on a `LiteKernel`
attaching the resolver at init.

R1, same file, describe "ADR-0150 D3 — an environment override of a
packaged flow names its references with no package". Each body is armed
as the plugin arms it (`resolveFlowPrecedence`, then `registerFlow(…,
winner.contender)`). There are three override shapes: a package-less
row, the `.custom` override as a stored row, and the `.custom` override
as the served envelope. Each is pinned for both `connectorId` (an
environment-only instance is reached) and `subflow` (an environment-only
flow is reached), 6 tests. Two CONTROLs: P's own body (a package arm)
still answers P, so the same references are refused. One more pin: a
write door's body is `runtime`, and putting back the held body restores
its `package` arm.

`flow-declared-override-first-boot-pull.integration.test.ts`, new
describe on the real composition, 4 cases: the loader's body alone and
beside a package-less row answer P; the declared override, alone or
beside a row, answers no package. Each is checked at the boot pull and
after `kernel:ready`.

Changed existing test code: `flow-precedence-loader-set.test.ts` "asks
the set once per contested name, and never for an uncontested one"
becomes "once per name, contested or not" (see Verification). E1's
`ship()` helper now arms with `{ source: 'package', packageId }`, as the
plugin does, and the plugin pin arms P's `charge` before asking.

Ablations (`scripts/ablation-replace.mjs`, run over both test files,
each restored to the HEAD blob, `git diff HEAD` empty after; the suite
imports `src`, so no `dist` leg):

| Mutation | Result |
|:--|:--|
| `connectorNameRefusal` returns `undefined` for every answer (engine) |
3 failed: red line 1, D3 (a), red line 2 |
| the `connector_action` refusal check removed (node) | 3 failed: the
same three |
| `setConnectorNameResolver(connectorNameResolver(ctx))` removed
(plugin) | 1 failed: the plugin pin, `expected undefined to be
'refused'` |
| R1: `flowCallerPackage` back to `packagedFlowOwner(caller)` (main's E1
reader) | 6 failed: every override shape × connectorId and subflow; both
CONTROLs stay green |
| R1: the served-envelope `override` branch disabled (classifier) | 4
failed: the served-shape connectorId and subflow pins, and the two
real-kernel override cases at `kernel:ready` |
| R1: the plugin's four `registerFlow(…, entry.contender)` calls without
the arm | 2 failed: the real-kernel cases whose loader body must answer
P |

## Verification

Head c6a0810 (main 2e9121e merged in as 5e37bfd; c6a0810 adds one
test-only commit on top):

- `@objectstack/service-automation` test exit 0 (267 files, 3518 tests)
and typecheck exit 0 at c6a0810, under `os-verify-lock` (one `&&`
command, VERDICT command-exit 0). Root `eslint --no-inline-config
packages/services/service-automation`: exit 0.
- The first local run, at 5e37bfd, had 1 red:
`flow-precedence-loader-set.test.ts` pinned "never asks the loader's set
for an uncontested name". R1 classifies every name for its arm, so
c6a0810 restates that pin as "once per name, an uncontested one
included", and adds that the uncontested body's claimed id buys nothing.
CI's Test Core 5/8 showed the same 1 red at 5e37bfd.
- `turbo run build --filter='@objectstack/dogfood^...'`: 70/70.
- Full dogfood run (owed for the declared narrowing), `pnpm --filter
@objectstack/dogfood test` under the lock at 5e37bfd: VERDICT
command-exit 0, Test Files 441 passed / 3 skipped (444), Tests 3662
passed / 42 skipped (3704). The only change since is that test file.
- Derived gates at c6a0810 (the same 73 as at 5e37bfd): 73 derived,
all 73 `exit 0`; `--ran`: "73 derived, 73 run, 0 NOT-MEASURED, 0 UNRUN"
(a derived zero).
- `test:repo` not run locally (CI's `Test Core`); this diff touches no
repo-project test.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01ADHkmR4astMzES5UkW47jW

<!-- signed: domain:server#3 · os-steve ·
session_01ADHkmR4astMzES5UkW47jW -->

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/l 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.

service-automation: flow index and activation consult on (package, name) (ADR-0150 child E1)

1 participant