Skip to content

feat(objectql, plugin-approvals): assignment rules and approval steps read the queue they name from their declaring package (ADR-0150 D3) - #31850

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

objectstack-fleet[bot] merged 12 commits into
mainfrom
claude/project-thread-gylh1m

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Requested by Steve · project thread

Fixes #31790

Clause-②: yes (widening); narrowing per ADR-0150 D3 declared below
Metadata-protocol: none
Skills-docs: follow-up owed, filed as #31853 (domain:skills, blocked by #31790) — skills/objectstack-automation/SKILL.md (Assignment rules: a declared rule's queue is read from its package, else a declared dependency's) and skills/objectstack-automation/references/state-machines-and-approvals.md (Approver Types: a queue approver likewise, from the flow's package). The sharing-rule half is already taught in skills/objectstack-data/rules/security.md.
Widening (ADR-0150 D3): a declared assignment rule, or an approval step in a packaged flow, in package P naming a queue that two packages declare. Before (main fdfcc7ab): refused ambiguous-in-environment. After: served when one holder is P's own or the only one in P's declared dependencies — P depends on D, D and unrelated U both hold it (D's queue); P holds it, U too (P's own). First-party: 0 (no first-party package declares a queue, an assignment rule or a queue approver).
Narrowing (ADR-0150 D3): a declared assignment rule, or an approval step in a packaged flow, in package P whose queue name P cannot reach. Before (main fdfcc7ab): served environment-wide. After: refused outside-closure, naming every candidate. Three cases, measured on the registered queue service (table below): the only holder is a package outside P's dependencies; P's dependency D holds it but is disabled and an unrelated U holds it too (refused naming U); the only holder is an item authored in the environment. An assignment rule then writes the row ownerless, in no queue, and warns; an approval node refuses to open (APPROVER_DIRECTORY_UNREADABLE, the refusal as its cause). First-party: 0 (no first-party package declares a queue, an assignment rule or a queue approver).
Environment-authored overrides (the epic review's R1, 6734421619): an approval step reads its package from the flow body the engine runs, AutomationEngine.armedFlowPackage (#31863). An environment-authored .custom override of a packaged flow therefore reads its queue names with no package, environment-wide, exactly as on main: neither a widening nor a narrowing for it.

维护者速读

What changes

  • Assignment rules (@objectstack/objectql, assignment-rules.ts). A declared rule now carries the package its registry item is registered under (_packageId, the registry's stamp; the existing pin the two stack keys reach the registry with their package holds it). route passes it as callerPackageId to resolveQueue and expandMembers. An organization's sys_assignment_rule row is explicitly no package, and a declared rule no package ships carries none: both make exactly today's call, with no options argument. The "does not resolve" warning names the rule's package when there is one. Module doc gains a "Which queue a name means" section.
  • Approval steps (@objectstack/plugin-approvals, approval-service.ts). A step's package is the package of the flow body the engine runs: flowPackageOf(flowName) asks the automation engine's public armedFlowPackage (feat(service-automation): a connector_action names its connector from the calling flow's package #31863), the reader the engine's own subflow addressing (ADR-0150 E1) takes a run's caller package from. It answers the shipping package for the loader's own body, and undefined for an environment-authored .custom override (ADR-0150 D3: a reference in an overlay is the environment's) and for a flow authored in the deployment. ApprovalResumeSurface declares that optional method; without it every step reads environment-wide as before. ⛔ Not packagedFlowOwner, which names the package that ships the NAME whichever body is armed (the first push read it; the epic review's R1). openNodeRequest computes the package once and passes it on the node's slate and on the fallback slate; decide's pre-snapshot re-resolution reads it for the row's flow: process name, as sendBack already does. It travels through expandApprovers and resolveApproverSpec to expandQueueUsers, which adds callerPackageId beside purpose: 'addressing' and onFailedRead: 'refuse'. A deployment-authored flow and an override make today's call unchanged.
  • A refused queue name on an approval node. The node still refuses with ApproverDirectoryUnreadableError (APPROVER_DIRECTORY_UNREADABLE), the refusal as its cause, and still routes past no one. When the cause is a D3 refusal (read by its shape, refusal.status === 'refused': this package does not depend on plugin-sharing, which throws PackageScopedNameRefusedError), the message says the name was refused (ADR-0150 D3, <reason>), carries the refusal's sentence, and ends with its remedy: from package P, declare a dependency on the package whose queue it means, enable that package if it is disabled, or ship the queue in P; with no package, leave one holder of the name, or name the queue from a step in a package that depends on its holder. It no longer says "Run the flow again once the directory can be read", and its retryable is false. A failed read keeps its wording, byte for byte, and retryable: true. No new code: reworded under the existing one (epic review R2).
  • ⛔ No second resolver and no first-registered fallback: both readers only name the package; the queue service (IOwnershipQueueService, implemented by plugin-sharing over objectql's resolvePackageScopedName) decides.
  • Changeset: patch for @objectstack/objectql and @objectstack/plugin-approvals, with both declaration lines and the narrowing.

Step 0: how each reader learns its declaring package

Measured on origin/main fdfcc7ab (with #31789).

Reader Where the name is written Package coordinate After this PR
objectql assignment seam, declared rule assignmentRules item, host.listRules() = registry.listItems('assignment_rule') the item's _packageId (stamped by registerItem / applyProtection; already pinned in ownership-queue.test.ts) passed
objectql assignment seam, organization rule sys_assignment_rule row none (an organization's row) not passed: environment-wide
plugin-approvals approver slate (queue approver) approval node config inside a flow; openNodeRequest gets flowName from the engine-seeded $flowName AutomationEngine.armedFlowPackage(flowName) (#31863: the package whose own body is armed; undefined for an override or a deployment-authored flow) passed when the armed body is a package's own
plugin-approvals decide pre-snapshot tally stored request row the row's process_name (flow:NAME) to armedFlowPackage passed when the armed body is a package's own

The definition's own _packageId is deliberately not read for flows: the engine documents it as the caller's bytes, and an override's served copy carries the base's. Both readers have a coordinate, so no stop.

Tests

objectql, new top-level describe in the D5 harness src/ownership-queue.test.ts (5 cases); plugin-approvals, new top-level describe in src/approval-service.test.ts (7 cases). Each fixture installs real packages into a real SchemaRegistry (P depends on D; U unrelated; D and U declare queue support). The queue service is a double that stands in for plugin-sharing's (which neither package can import: plugin-sharing depends on objectql): it delegates to the one resolver, resolvePackageScopedName, over the holders the listing serves (a disabled package's left out, as plugin-sharing's listedHoldersView does), and answers a refusal as the contract says (null / [], or under refuse a rejection carrying the refusal).

Pin objectql plugin-approvals
P's rule/step naming support, held by D and U, reaches D's queue red on main red on main
two holders in P's closure: refused, naming both red on main red on main
D disabled + U: refused outside-closure, naming U, U never served red on main red on main
CONTROL: no package (a declared rule no package ships / a deployment-authored flow) reads as today, same call, same refusal green on main green on main
CONTROL (objectql): an organization's own rule reads environment-wide green on main —
fallback slate and pre-snapshot tally read the same package (plugin-approvals) — red on main
a D3 refusal on the node reads as a refusal with its remedy, not retryable (asserted in the two-in-closure pin and the CONTROL) — red on main (failed-read wording)
an environment-authored override of P's flow (armedFlowPackage undefined, packagedFlowOwner P) naming an environment-held queue P cannot reach: served environment-wide, on the node slate and the pre-snapshot tally — green on main; red with packagedFlowOwner (refused outside-closure)
the same on the override's fallback slate — green on main; red with packagedFlowOwner

"Red on main" was measured by restoring the two source files to fdfcc7ab (git restore --source), running the blocks, and restoring (git diff HEAD empty): objectql 3 of 5 red, plugin-approvals 4 of 5 red, every CONTROL green.

Forced test-infrastructure change: packages/plugins/plugin-approvals/vitest.config.ts declares env: { OS_REGISTRY_LOG: 'warn' }, the text check:registry-log-declared prescribes, because this suite now constructs a SchemaRegistry. No existing test was edited.

Acceptance notes

Verification

Head 00fb75d5. It merges origin/main at 5d17aa6b, which includes #31863's armedFlowPackage, through merge commit f3864c02. The first push was 0dccea6c.

  • 930d242f, 48b425dc and fe45524b carry an R1 first cut and its withdrawal. Net, they leave packages/services/** identical to main.

  • 00fb75d5 switches the approval step to feat(service-automation): a connector_action names its connector from the calling flow's package #31863's reader.

  • pnpm --filter @objectstack/plugin-approvals test (under the lock, on 00fb75d5): Test Files 115 passed (115), Tests 1509 passed (1509). The new block is 7/7.

  • Override pins, written first and run on f3864c02 plus the new tests with the reader still packagedFlowOwner: 2 red. After the switch: green.

  • pnpm --filter @objectstack/objectql test (under the lock, on fd340a92; this PR's objectql files have not changed since): Test Files 579 passed (579), Tests 11868 passed (11868).

  • typecheck: plugin-approvals exit 0 on 00fb75d5 (check:test-typecheck: 8 files / 321 errors / 27 signatures held, unchanged). objectql exit 0 at the first push.

  • Lint: neither package has a lint script. Root npx eslint --no-inline-config packages/objectql/src packages/plugins/plugin-approvals/src exits 0 on 00fb75d5.

  • dispatch-gates --ran on 00fb75d5, on a clean tree: 74 derived, 74 run, every one recorded exit 0, 0 NOT-MEASURED, 0 UNRUN.

  • Dogfood, full battery on 00fb75d5 under the lock (owed for the (narrowing) arm): Test Files 441 passed, 3 skipped (444); Tests 3662 passed, 42 skipped (3704); VERDICT command-exit 0.

Measured: what the registered queue service answers, environment-wide (main's readers) and from P (this PR's)

A throwaway test (deleted, never committed) drove @objectstack/plugin-sharing's OwnershipQueueService over a real SchemaRegistry on this tree. resolveQueue('support'), and expandMembers under refuse from P:

Case environment-wide from P refuse from P
P depends on D; D and unrelated U hold it null (refused) D's queue ok
two holders in P's closure null (refused) null ambiguous-in-closure [D, D2]
only unrelated U holds it U's queue null outside-closure [U]
D disabled; U holds it too U's queue null outside-closure [U]
only an environment-declared item the environment's null outside-closure [environment]
P holds it, U too null (refused) P's own ok
only D holds it D's D's ok
no declared holder, an organization's sys_queue row the row the row ok

The three bold rows are the narrowing; rows 1 and 6 the widening the card asks for.

Ablations: six legs, each red, each restored (scripts/ablation-replace.mjs)

Both suites import their package's source directly; no dist leg. A1–A4 ran on 0dccea6c; R2 on the head below. Each leg: anchor 1 to 0, blob changed, run red, restored with blob == HEAD and git diff HEAD empty.

  • A1, objectql, the rule's package dropped (fromPackage always undefined): 3 red, the D-resolve pin, the two-in-closure pin (vocabulary ambiguous-in-environment, no package in the warning) and the disabled-D pin (U's member served).

  • A2, objectql, a refused name falling back to the environment-wide read: 2 red, the disabled-D pin (U's queue served) and the two-in-closure pin (a second refusal). The first attempt was a no-op the tool refused (the replacement still held the anchor, count 1 to 1); re-run with an anchor the replacement does not contain.

  • A3, plugin-approvals, the flow's package dropped: 4 red, every package pin.

  • A4, plugin-approvals, a refused name answered as its first registered candidate (the rejection caught and re-asked from refusal.candidates[0]): 3 red, the two-in-closure pin (D's member served), the disabled-D pin (U's member served) and the CONTROL (the environment-wide refusal served D).

  • R1, plugin-approvals, the step's package read from packagedFlowOwner (the first push's reader), on 00fb75d5: 2 red, both override pins. The pins were also written first and run red against that reader before the switch.

  • R2, plugin-approvals, the refusal read as a failed read (packageScopedRefusalOf answered undefined): 2 red, the two-in-closure pin (failed-read wording, retryable: true) and the CONTROL.

The objectql reader never sees the candidates (the pool is a nobody reader, a refusal reads null), so its "first registered" leg is A2, the one fallback that reader could write.

🤖 Generated with Claude Code

https://claude.ai/code/session_01ADHkmPfjQpqnk93rr9esh8

… read queue names from their declaring package

Claude-Session: https://claude.ai/code/session_01ADHkmPfjQpqnk93rr9esh8
Co-authored-by: Claude <noreply@anthropic.com>
…that now builds a registry

Claude-Session: https://claude.ai/code/session_01ADHkmPfjQpqnk93rr9esh8
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m 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 2 package(s): @objectstack/objectql, @objectstack/plugin-approvals, touching 24 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/plugins/plugin-approvals/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.

18 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json a15b544d542fa3eda258732999778b69613618bd.

⛔ 10 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-approvals/vitest.config.ts) — pages documenting those are invisible to this run
  • 3 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 — 23 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 a15b544d542fa3eda258732999778b69613618bd → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ 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 a15b544d542fa3eda258732999778b69613618bd → 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 — #31850 (#31790) at head 0dccea6: CHANGES (two required, both on the approval half).

The assignment-rule half: PASS. assignment_rule is supportsOverlay: false, allowEnvironmentOverlay: false and allowRuntimeCreate: false (spec metadata-plugin.zod.ts:1307). So no environment body of a rule exists, and the registry's _packageId is the package that declares it. An organization's sys_assignment_rule row is forced to null, which is the environment-wide read, as before. The service is the one resolver (#31789's contract). The rule never picks a holder itself.

Your open questions

  1. Clause-② "no (narrowing)" with two widening cases. Accepted. D+U with P→D now reaching D, and P's own queue beside U, are D3's target answers: one holder in the closure, nothing guessed. This is the same reading as spec: IOwnershipQueueService reads take the caller package, so a queue recipient resolves by D3 from its rule (ADR-0150, follow-up of E4) #31734/feat(spec, plugin-sharing): ownership-queue reads take the caller package, so a declared rule's queue recipient resolves by ADR-0150 D3 #31789's accepted widening. Keep both cases written out in the changeset, as they are now.
  2. A D3 refusal on an approval node reads APPROVER_DIRECTORY_UNREADABLE. Required fix (R2 below). The candidates do reach the message (your "naming both" pin). The sentence around them is wrong for a refusal, though.
  3. Skills-docs follow-up. Yes, the seat files it: Part of 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, Blocked-by objectql assignment rules and plugin-approvals approver steps pass the declaring package to queue reads, so their queue names resolve by ADR-0150 D3 (follow-up of #31734) #31790, covering objectstack-automation SKILL.md and state-machines-and-approvals.md. It is a governed path and lands on maintainer approval.

R1 (required): a step's package comes from the flow body that armed, not from the loader's set.
flowPackageOf asks packagedFlowOwner(flowName). That reads the artifact's _packageId for the bare name, so it answers P for every flow named after one of P's flows. It does so even when the body the engine runs is an environment-authored override of P's flow: a package-less stored row, or a declared override in P's .custom pair.
ADR-0150 D3 (the paragraph after (a)–(d)) says: "A reference written in an environment-authored overlay (ADR-0005) is the environment's reference. It resolves as a caller with no package … It never resolves from the package the overlay customizes."

With 0dccea6, an administrator who edits P's approval step to name their own environment-authored queue is refused outside-closure (D3 (a)). That is a narrowing the changeset does not declare, and the ADR rules it out. An override that names a queue held by both D and U also resolves from P's closure instead of environment-wide.

Fix:

  • Take the caller from the armed contender. A package body arms as packageId. An override or runtime body arms as null, which is no package.
  • getShadowedFlows()/armedFrom is not enough as the source. It is recorded at boot and only on a collision. A runtime save of an override never writes it.
  • The protocol already tells a served override row apart (isServedOverrideRowOf, protocol-lock-state.ts). Find the one fact the engine can be asked for the body it runs, measure it on a real kernel, and do not infer it from stamps: hydration grafts P's envelope onto the stored row, so _packageId on the body lies here.
  • Pin it: P's flow overridden by a package-less row whose step names an environment-held queue must be served environment-wide, exactly as on main. Run that pin with the fix ablated (red), then with the fix (green).
  • The decide-time tally (process_name flow:…) reads the same fact.

Card B #31696 rekeys flows by (package, name), and this obligation also goes on record there. Until B lands, this PR is the first to derive "the package of a flow step", and E5's last PR (connector_action connectorId by flow package) will copy whatever reading lands here. Please name the reader so that PR can reuse it.

R2 (required): a refusal is not a failed read.
When the cause is a PackageScopedNameRefusedError, the message says "because a read behind it failed … Run the flow again once the directory can be read". Running the flow again changes nothing for an ambiguous or outside-closure name.

  • Keep the node refusing, with the same class and the same no-route-past behaviour.
  • When the cause is the D3 refusal, word it as a refused name and give the refusal's own remedy: declare the dependency, enable the package, or ship the queue in P.
  • A new error code is a public name, so it is the seat's call. Rewording under the existing code is enough for this PR.
  • Pin the remedy text in the "two holders in P's closure" case.

Deviations noted, both fine: plugin-approvals vitest OS_REGISTRY_LOG, and the objectql ablation.

Re-ping the epic seat with the new head once R1 and R2 are in.

… queue from the package of the flow body that runs

AutomationEngine.armedFlowPackage(name) answers the package whose own body is
armed under a flow name: the shipping package for the loader's body, nothing
for a declared .custom override or any other environment-authored body. The
automation plugin hands registerFlow the precedence's verdict at every arming
step, and the precedence recognizes the override the protocol's view serves in
the package's slot by its own overrides declaration.

The approval service reads that package instead of packagedFlowOwner, so an
override's step resolves environment-wide, as before. A refused queue name now
reads as a refusal with its remedy, not as a failed read, and is not retryable.

Claude-Session: https://claude.ai/code/session_01ADHkmPfjQpqnk93rr9esh8
Co-authored-by: Claude <noreply@anthropic.com>
…the precedence

resolveFlowPrecedence again asks the loader's set nothing for a name with one
contender; the plugin classifies that winner with describeArmedFlowContender
when it hands registerFlow its armedFrom.

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

# Conflicts:
#	packages/services/service-automation/src/plugin.ts
…kage reader to its shared home

The reader of the package whose flow body runs is being built once, for every
caller, in its own change. Until it lands, the approval step reads
packagedFlowOwner as before; the engine, the precedence and the plugin are back
to main. The refusal wording on the approval node stays.

Claude-Session: https://claude.ai/code/session_01ADHkmPfjQpqnk93rr9esh8
Co-authored-by: Claude <noreply@anthropic.com>
…age of the flow body that runs

The step's package is now the automation engine's armedFlowPackage: the
shipping package for its own body, none for an environment-authored override
of it. So an override's step, its fallback slate and the decide-time tally read
their queue names environment-wide, as they did before package-scoped reads,
instead of from the package the override customizes.

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

Copy link
Copy Markdown
Contributor Author

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

Epic red-line review of #31850 (#31790) at head 00fb75d: PASS. It lands once CI is green and the seat ACCEPTs.

  • R1 is met by reuse. flowPackageOf reads AutomationEngine.armedFlowPackage (feat(service-automation): a connector_action names its connector from the calling flow's package #31863), and no other reader is left in the approval service. That covers the node slate, the fallback slate and the decide pre-snapshot tally.
    • An environment-authored override's step reads with no package, environment-wide, as on main (ADR-0150 D3's overlay paragraph).
    • The pins were written first: 2 red against packagedFlowOwner, green after the switch, and red again under ablation.
  • R2 is unchanged since b7cf21d. A D3 refusal reads as a refused name with its remedy and retryable: false, and the failed-read wording is byte-identical.
  • Clause-② is now "yes (widening)", with both widening cases and the declared narrowing written out.
  • The assignment-rule half is as passed: _packageId of a type that cannot be overlaid.

Red lines 1–3 hold.

Owed: the skills-docs card (Part of #31527, Blocked-by #31790) is the seat's to file, if it isn't filed already.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-steve · session_01ADHkmQXLfvyB5R53VDm3Ka

席位核对 · ACCEPT · #31850 (Fixes #31790) · 2026-11-09T10:13Z

Head-sha: 00fb75d5

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 9, 2026 10:13
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 9, 2026 10:13
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-steve · session_01ADHkmQXLfvyB5R53VDm3Ka

席位核对 · ACCEPT (re-stated on a new head) · #31850 (Fixes #31790) · 2026-11-09T10:51Z

Head-sha: 8b657ca8

@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 9, 2026
Merged via the queue into main with commit 7231dc7 Nov 9, 2026
50 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-gylh1m branch November 9, 2026 12:31
rajeev-kumar-se pushed a commit to rajeev-kumar-se/objectstack that referenced this pull request Nov 10, 2026
…gnment rule or approval step names (objectstack-ai#31947)

<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} -->
_Requested by **Steedos** · [project
thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9Ubb15qRXgCty32TE8iQFKHTgK)_
Fixes objectstack-ai#31853

Clause-②: no
Metadata-protocol: none

## 维护者速读

- 改了什么:自动化技能文档补上「软件包里的分配规则、流程审批节点引用的队列名」怎么解析:先找本包的队列,再找它声明依赖(加
`sys`)里唯一的持有者;否则拒绝。分配规则被拒时记录写成无主、不进队列,服务器日志告警一次;审批节点被拒时不开启(`APPROVER_DIRECTORY_UNREADABLE`,不可重试)。组织自建的规则、不属于任何包的规则和流程,照旧按整个环境查。另加一条
eval 用例,要求声明对队列所在包的依赖。
- 为什么改:PR objectstack-ai#31850 已让这两处按声明它的软件包解析队列,但技能文档没说,AI
会写出能通过校验、运行时却把记录全部写成无主或让审批节点打不开的规则。
- 风险与回滚:只改已发布的技能文本、一条 eval 和两个文件的 token 上限(SKILL.md 6568→6687,参考文档
5825→5916,均为先删重复后的实测余额)。回滚即还原本 PR。
- 先看:`skills/objectstack-automation/SKILL.md` 的「Assignment
rules」末段;`references/state-machines-and-approvals.md` Approver Types
表下的新段落。
- 两分钟验证:`node scripts/check-skills-token-ratchet.mjs`,预期两个文件分别 6687 /
5916 tokens、headroom 0、退出码 0。
- 需要你做的:审阅并批准(Tier H,`skills/**`)。

## What changes

- `skills/objectstack-automation/SKILL.md` § Assignment rules: a new
paragraph teaches how a packaged rule's `assignTo: { type: 'queue',
value }` resolves, the refusals, what the rule does on one, the callers
that still read environment-wide, and the organization `sys_queue`
fallback; one example (`acme.helpdesk` naming `acme.crm`'s queue
declares the dependency).
-
`skills/objectstack-automation/references/state-machines-and-approvals.md`:
the Approver Types `queue` row points to one paragraph under the table,
which teaches the same resolution for a packaged flow's `queue` approver
and what the node does on a refusal; one example (`acme.deals` naming
`acme.crm`'s `crm_finance`).
- `skills/objectstack-automation/evals/flows-triggers-approvals.json`:
case id 21. The expected output declares `dependencies: { 'acme.crm': …
}` on `acme.helpdesk`; `must_contain` needs `defineAssignmentRule`,
`type: 'queue'`, `value: 'crm_support_intake'`, `dependencies` and
`'acme.crm'`; `must_not_contain` rejects `dependencies: {}` and the
package-qualified names `acme.crm/crm_support_intake` /
`acme.crm:crm_support_intake`, so the undeclared form fails.
- `scripts/check-skills-token-ratchet.mjs`: the two files' rows, pinned
at the measurement.
- `.changeset/31853-automation-packaged-queue-resolution.md`:
`'@objectstack/skills': patch`.

Where each taught fact comes from (merged code on `main` `2b880f12`):

- Resolution order and reason codes:
`packages/core/src/package-scoped-resolution.ts`
`resolvePackageScopedHolders` (L599–600 own, L602–613 closure, L615–620
`outside-closure` with the environment item among the candidates;
L546–566 the caller with no package, environment item first, else
`ambiguous-in-environment`); `PackageScopedRefusalReason` L313–319.
- Assignment rule: `packages/objectql/src/assignment-rules.ts` L192
(`_packageId` is the rule's package), L236 (an organization's row is no
package), L516–521 (`callerPackageId` passed only when there is a
package), L527–529 (the "does not resolve … from the rule's package"
warning, `warnOnce` keyed per rule, queue and organization, row written
ownerless, in no queue). A refused name answers `null` in
`packages/plugins/plugin-sharing/src/ownership-queue-service.ts`
L275–282; a name no declared queue holds falls through to the
organization's authored `sys_queue` row, L300–301. A disabled package is
no holder: `package-scoped-declarations.ts` `listedHoldersView` L145.
- Approval: `packages/plugins/plugin-approvals/src/approval-service.ts`
L3661–3665 (`flowPackageOf` reads `armedFlowPackage`: none for a
deployment-authored flow or a `.custom` override), L3642–3648
(`expandMembers` under `onFailedRead: 'refuse'` with `callerPackageId`),
L1349–1359 (`APPROVER_DIRECTORY_UNREADABLE: … because its name was
refused (ADR-0150 D3, <reason>)`, the refusal's sentence naming every
candidate, the slate, `fallbackApprovers` and administrators all
skipped, `retryable` false).
- Tests: `packages/objectql/src/ownership-queue.test.ts` L474–620 and
`packages/plugins/plugin-approvals/src/approval-service.test.ts`
L4423–4622 (no request row written on a refusal).
- Card paraphrase vs code: the card says a queue "only the environment
holds" is refused. In the code that is a queue declared in the
environment's own metadata, with no package; an organization's authored
`sys_queue` row of a name no `queues` entry declares still resolves for
a packaged rule, so the skill says both. The card's three refusals hold;
the code adds that a no-package caller is refused
`ambiguous-in-environment` only when two packages declare the name and
the environment does not.

### Token budget

Standing ruling, verbatim and untranslated: 「A:先削重复,余额按实测提高」 (ruling
record 6513306068, letter B). Cuts admitted by claim amendment
6741201566.

- `SKILL.md` (priced bytes, stamp line excluded): 26263 -> 26746. The
new text adds 695 B, four restatement cuts remove 212 B, so the ceiling
moves 6568 -> ceil(26746 / 4) = 6687 (+119; headroom was 2 tokens).
- `references/state-machines-and-approvals.md`: 23237 -> 23661. The new
text adds 690 B, four cuts remove 266 B, so the ceiling moves 5825 ->
ceil(23661 / 4) = 5916 (+91; headroom was 15 tokens).

The cuts, each with its survivor:

1. SKILL.md L45, the `api` flow-type row's "— needs a start-node
`secretRef`" (-35 B): § Inbound webhook triggers, the `secretRef` /
`secret` row (L366), which the row's kept pointer reaches.
2. SKILL.md L104, the `approval` node row's "— suspends the run until a
decision, then continues down the `approve` / `reject` branch" (-95 B):
the reference's § Approvals (Flow Nodes) opening paragraph (L61–64).
3. SKILL.md L328, the `approvers: [{ type: 'queue', value:
'crm_support_intake' }]` literal, now "a `queue` approver" (-43 B): the
reference's Approver Types `queue` row, which the kept link reaches.
4. SKILL.md L387–388, "This is the start node alone, not a whole flow"
(-39 B): the section heading ("Trigger Configuration — on the `start`
node").
5. Reference L48, "e.g. `previous.status != 'escalated' && record.status
== 'escalated'`" (-71 B): SKILL.md § Trigger Configuration's start-node
example.
6. Reference L68–69, "— approval flows live in your normal `flows:
[...]`" (-56 B): § Approvals (Flow Nodes), "authored as an Approval node
on an ordinary flow".
7. Reference L171, "— you opt in, edge by edge" (-29 B): the same item's
"This is the only thing that legalizes the cycle".
8. Reference L272–274, "(flow conditions: the trigger snapshot; hook
conditions: the stored record overlaid with the write's payload)" (-110
B): the time-word cheat sheet's flow-condition row, and objectstack-data
`references/data-hooks.md` L483 for hook conditions (the file already
points there).

No pointer was cut, and the state-route line pinned by
`packages/rest/src/meta-state-route-doc-spelling.test.ts` is untouched.

Bundle readings: `skills/objectstack-automation/SKILL.md` 447 -> 456
lines, 26283 -> 26766 B; all published `SKILL.md` files 4596 -> 4605
lines.

## Tests

Docs and eval only; no package code changes. `node
scripts/check-skills-token-ratchet.mjs` exit 0 and `--self-test` exit 0.

## Verification

<details><summary>Derived gates (dispatch-gates --commands, 65
families)</summary>

- Full sweep at `5a47df55`: 65 derived, 65 run, every recorded exit 0;
`dispatch-gates --ran`: `65 run, 0 NOT-MEASURED`. Run after a full
`turbo run build` (VERDICT command-exit 0), so
`check:dual-build-cjs-loads` and `check:doc-formula-expressions`
measured, not exit 3.
- After merging `main` (6 commits, none touching these files) at
`00b7d809`: the derivation is byte-identical; re-run on the new head:
`check-skills-token-ratchet`, `check:skill-docs`,
`check-empty-changeset`, `check-changeset-no-major`, all exit 0.
- PR-body gates on this body: `check-changeset-no-major`,
`check-adr-0087-registration`, `check-closing-keyword-parity`,
`check-partof-closing-keyword`, all exit 0.

</details>

---
_Generated by [Claude
Code](https://claude.ai/code/session_01QsR2jTKa8eJCpKq9hPcYPT)_

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

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

1 participant