Skip to content

feat(objectql,plugin-sharing): an organization authors its own assignment rules as sys_assignment_rule records - #30760

Draft
objectstack-fleet[bot] wants to merge 7 commits into
mainfrom
claude/project-thread-kl2al6
Draft

objectstack-fleet[bot] wants to merge 7 commits into
mainfrom
claude/project-thread-kl2al6

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Requested by Elon · project thread

Fixes #30066

Clause-②: yes (widening)
Metadata-protocol: added
Skills-docs: follow-up owed — content/docs/automation/queues-and-assignment-rules.mdx (an organization's own rules, their place before the declared ones, and the active-member rule for a user target), skills/objectstack-automation/SKILL.md (the assignTo: { type: 'user' } line: a user target owns a row only while the user is an active member)

维护者速读

  • 改了什么:组织可以在 Setup 里自己建分派规则(新对象 sys_assignment_rule,一行一条规则);引擎先评本组织的规则,再用应用包声明的规则兜底,第一条命中的说了算。指定给某个人的规则,只有那人仍是本组织的活跃成员时才生效。
  • 为什么改:以前只有应用包能声明分派规则,客户想换个路由就得等发版;这是裁决 A 的服务端一半,Setup 页面另起 domain:ui 卡。
  • 风险与回滚:每次插入一个带队列列的对象,多一次按组织读 sys_assignment_rule(表不存在时不读);包规则里写死用户 id 的,若该用户不是记录所在组织的成员,从此不再分派(记一次 warn,插入照常)。回滚即 revert 本 PR,表里已存的行不会被引擎读到。
  • 先看:packages/objectql/src/assignment-rules.ts(apply() 里组织规则在前、读失败时不走兜底;routeToUser 的成员校验);packages/plugins/plugin-sharing/src/assignment-rule-hooks.ts(保存时的全部拒收);packages/plugins/plugin-sharing/src/objects/sys-assignment-rule.object.ts。
  • 两分钟验证:pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2 src/ownership-queue.test.ts,预期全绿,其中「an organization's own rules go first」一组 9 条都通过。
  • 需要你做的:无,席位落地。

What changes

@objectstack/plugin-sharing: a new object, sys_assignment_rule (src/objects/sys-assignment-rule.object.ts), registered in the plugin manifest, with a Setup nav entry nav_assignment_rules beside Queues that opens the generic record list (the dedicated Setup page is the domain:ui card). One row is one rule with one entry: object_name, criteria (CEL over record, empty = every row), sort_order, recipient_type (queue or user) + recipient_id (the existing recipient-picker widget, which reads a sibling literally named recipient_type), an optional strategy (round_robin / least_loaded) and load, and active. organization_id is required; the name is unique per organization.

The save guard (src/assignment-rule-hooks.ts, bound on kernel:ready beside the queue guards). beforeInsert / beforeUpdate judge the merged row and refuse with one VALIDATION_FAILED naming every field at fault:

  • a name that is not snake_case, or that a declared rule holds (ruling item 4: one namespace, ADR-0131 D4);
  • an object that does not exist, or whose records carry no ownership queue column;
  • whatever AssignmentRuleSchema refuses for a declared entry of the same shape (a strategy on a user target, least_loaded without load, load without a strategy, a malformed queue name, an unknown recipient type);
  • a criteria or load the engine cannot compile, judged by the same CEL paths the seam runs (ExpressionEngine.compile; compileCelToFilter) — ruling item 5;
  • a user target who is not an active member of the rule's organization (sys_member row and no ban in force) — ruling item 2;
  • a row with no organization.

An update that touches none of the judged columns (a label, active, sort_order) is not re-judged. A membership read that fails refuses the save rather than storing an unjudged target. A boot report logs, at error, any stored row whose name a later-installed package now declares.

@objectstack/objectql: the seam reads both sources (src/assignment-rules.ts). For an insert of an object that carries the queue column, the evaluator reads the organization's sys_assignment_rule rows once per organization in the batch, orders them by sort_order then name, and puts them before the declared rules (ruling item 1: first match; the activation ledger has no organization column, so the other order would let a package rule always win). Then:

  • a row under a declared rule's name is not evaluated (the declared rule answers for the name) and warns once;
  • an inactive row is skipped; a row that does not read as a rule warns once and the next rule decides;
  • when the organization's rules cannot be read, its rows are written as they stood — never routed by the fallback its own rules would have overtaken;
  • a user target, from either source, owns the row only while organizationMemberStanding answers member; otherwise the row is written ownerless and the lapse warns once per rule, user and organization (ruling item 2). With no sys_member table registered, no user can be confirmed and none is assigned;
  • a lapsed queue keeps its ADR-0142 D5 behaviour unchanged (ruling item 3);
  • the round-robin cursor is keyed by (rule, its one entry named after it, organization), so an organization rule's rotation is its own.

The engine's call site (src/engine.ts) asks evaluator.mayRoute(object) instead of rulesFor(object).length > 0, so an object with no declared rule is still routed by an organization's own. New exports for the guard: ASSIGNMENT_RULE_OBJECT, assignmentExpressionRefusals, organizationMemberStanding (+ type OrganizationMemberStanding).

@objectstack/spec (domain:spec#3 consented, mailbox comment 6672529419): PLATFORM_PROVIDED_OBJECT_NAMES gains sys_assignment_rule under plugin-sharing, between sys_assignment_cursor and sys_queue, in the same commit as the census and inventory rows; the [ADR-0142 D5] comment on the assignment_rule registry entry drops "declared only: organization-authored routing is deferred" (ruling item 6), with the entry's description and flags untouched; the AssignmentRuleSchema docblock states how the two sources combine, in the order the engine evaluates them. No key, shape or .describe() text changes, and gen:schema / gen:docs regenerate with no diff (the docblock is not rendered into a generated file).

Ledgers. The federated-injected-column-readers ledger records the two new organization_id keyings (both platform tables, not reads of a federated column); the cli organization-ownership inventory lists sys_assignment_rule (fate attribution); scripts/platform-object-tenancy-census.json is regenerated.

Tests

  • packages/objectql/src/ownership-queue.test.ts, three new top-level describes: "a user target owns the row only while the user is an active member" (5), "an organization's own rules go first, the declared rules are the fallback" (9), "assignmentExpressionRefusals" (3). The D5 boot now registers sys_member with a membership for the existing user-target case.
  • packages/plugins/plugin-sharing/src/ownership-queue.test.ts, new describe "the sys_assignment_rule save guard": every refusal above by field, merged-row judging on update, a failed membership read refusing the save, re-binding not stacking hooks, and the boot report.
  • packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.by-object.test.ts: the by-object roster gains sys_assignment_rule.

Ablations (each through scripts/ablation-replace.mjs, mutation proven on disk by anchor count and blob hash, restore proven by blob == HEAD and an empty git diff HEAD; the suites import the subject from src, so no rebuild sits between):

Mutation Suite Result
apply(): [...own, ...fallback] → [...fallback, ...own] objectql src/ownership-queue.test.ts 3 failed / 38 passed: "an organization rule wins…", "several organization rules are tried by sort_order…", "a user target of an organization rule…"
routeToUser: answer === 'member' → answer !== 'unconfirmable' objectql src/ownership-queue.test.ts 3 failed / 38 passed: "a user who is not a member…", "a banned member owns nothing…", "a user target of an organization rule…"
guard: the declared-name branch short-circuited (&& false) plugin-sharing src/ownership-queue.test.ts 2 failed / 65 passed: "refuses a declared rule's name…", "names every field at fault in one refusal"

All three restored, then green.

Acceptance notes

  • A fourth packages/spec hunk, consented by domain:spec#3 (6674042568, same terms as 6672529419). pnpm --filter @objectstack/spec run check:empty-state was red on ab98c3b8: it reports sys_assignment_rule.criteria as an unclassified permissive empty state, because its description tells the admin that an empty condition takes every new record no earlier rule took. That is the real behaviour, so the description stays and the empty state is declared instead: one scope entry in packages/spec/scripts/liveness/empty-state-registry.mts, right after the declared rule's criteria entry, which stays byte-identical. With it the gate prints "all classified". No changeset line is owed for it (packages/spec/scripts/** does not publish). The entry, as committed:
{ file: 'packages/plugins/plugin-sharing/src/objects/sys-assignment-rule.object.ts', property: 'criteria', semantics: 'scope', rationale: "[ADR-0142 D5, ruling A on #30066] An organization's own assignment rule (`sys_assignment_rule`): which inserted rows it routes; empty = every row no earlier rule took, the catch-all at the end of the organization's ordered rules. The declared entry's contract above, row for row: it selects work to route and grants nothing by itself — the author names the target, assignment runs only for a row that reaches the engine with no owner and no queue, a user target must be an active member of the organization, and a parked row is readable only by members of the queue named. A missing condition mis-routes among the organization's own targets, never widens a grant.", evidence: 'packages/objectql/src/assignment-rules.ts (authoredRules + assignOne: the organization rules go first, the first matching rule decides); packages/plugins/plugin-sharing/src/assignment-rule-hooks.ts (the save guard)' }
  • The roster line is load-bearing. Without it, packages/spec/src/system/constants/platform-object-names.test.ts > registers exactly the objects each package declares fails on sys_assignment_rule (measured: 1 failed / 13 passed without the line, 14/14 with it).
  • Skills-docs: no skills/** line calls organization rules deferred or assignment rules declared-only (grep for deferred / declared only over skills/**: no assignment hit). The owed follow-up is teaching the new behaviour: the docs page's "Declare an assignment rule" section and the skill's assignTo: { type: 'user' } line.
  • Translations are held. Labels for the new object and nav entry in all 11 platform languages follow once i18n phase 3 (platform metadata and built-in emails): extraction and bundle checks read PLATFORM_LOCALES; backfill the seven missing locales #30512's sharing PR merges, by a rebase onto it.
  • Two checks beyond the ruling, added as mechanism, for the seat's review: the save guard also refuses an object that does not exist or has no queue column (the seam could never route it), and the boot report logs at error a stored row a later package's rule now shadows.
  • Cost: an insert of an object with the queue column now reads sys_assignment_rule once per organization in the batch while the table is registered. Without the table, or for an object without the column, nothing is read (pinned: "the cheap path").
  • Behaviour change for declared rules: a package rule whose entry names a user id now assigns only where that user is a member of the row's organization (the assignment-rule-user-target lint already warns against it). The changeset's Upgrading paragraph says so.
  • Overlap with open PRs: feat(service-settings,plugin-audit,plugin-sharing): config_change rows name their settings row; record pages for audit rows and queues #30728 also edits sharing-plugin.ts (+4) and the plugin-sharing translations; fix(scripts,objectql): codemod-split-engine --members reads hand-written facet headers and inverts its import edits; move registerApp, registerDriver and registerHook #30716 moves code out of engine.ts (registerApp / registerDriver / registerHook), not the insert body this PR's one-line call-site change sits in.
  • Commit split, accepted by the seat: spec#3 asked for the roster line to land in the same commit as the object, so the line can never land ahead of it. The object is in 4b6296e8, already pushed, so the line sits in a later commit of this PR (d86cdc3e, with the census and inventory rows) rather than rewriting published history. Both merge together in this PR, which is what the condition protects.
  • Placement, accepted by the seat: the object lives in plugin-sharing (beside sys_queue and sys_sharing_rule, whose model it follows) rather than platform-objects, and the call site changed is engine.ts; engine-insert.ts is untouched.
  • The ADR-0142 D5 rewording is Tier H and comes as its own docs/adr PR.

Verification

Readings at head ab98c3b8 unless a line names another. ab98c3b8 differs from 796614dc only by moving one sentence of sys_assignment_rule.criteria's description onto the description: line (so the empty-state gate attributes it to criteria, not required).

Package tests and typecheck
  • pnpm --filter @objectstack/objectql test — Test Files 556 passed (556), Tests 11086 passed (11086); lock VERDICT command-exit 0.
  • pnpm --filter @objectstack/plugin-sharing test — Test Files 66 passed (66), Tests 1421 passed (1421); lock VERDICT command-exit 0.
  • pnpm --filter @objectstack/cli exec vitest run --project unit src/utils/organization-ownership-inventory.test.ts — 26 passed (26). The cli integration tier is CI's; this diff touches no spawn entry.
  • typecheck on objectql, plugin-sharing and cli (each tsc --noEmit plus check:test-typecheck) — lock VERDICT command-exit 0.
  • pnpm --filter @objectstack/spec test — Test Files 817 passed (817), Tests 25792 passed, 1 todo; lock VERDICT command-exit 0 (at 796614dc).
  • pnpm --filter @objectstack/spec exec vitest run --project repo src/system/constants/platform-object-names.test.ts — 14 passed (14); without the roster line, 1 failed / 13 passed.
  • pnpm --filter @objectstack/plugin-sharing exec vitest run src/objects src/ownership-queue.test.ts at ab98c3b8 — 88 passed (88).
  • pnpm --filter @objectstack/spec gen:schema && gen:docs — no file changed.
Derived gates
  • At 796614dc, dispatch-gates --commands derived 99 families (the same 99 at ab98c3b8): 98 exit 0, and pnpm --filter @objectstack/spec run check:empty-state exit 1 (see Acceptance notes). --ran: "99 derived famil(ies) accounted for — 99 run, 0 NOT-MEASURED (a DERIVED zero)".
  • Before the spec edits (the tree of d86cdc3e without its roster line), it derived 77 families; each was run with its exit code recorded before any pipe. Five first answered exit 3 (prerequisite not met: a pinned fixture commit outside the shallow clone, and unbuilt dist/ for the i18n and dual-build gates); after fetching that commit and building the closures each gate names, all five re-ran at exit 0.
    --ran on that run: "77 derived famil(ies) accounted for — 77 run, 0 NOT-MEASURED".
  • node scripts/check-commit-card-trailers.mjs --range origin/main..HEAD — 5 commit messages carry no card relation and no model identifier. check-nul-bytes exit 0.
  • pnpm lint (repo-wide eslint . --no-inline-config) — exit 0 on that pre-spec tree and again at 796614dc; eslint --no-inline-config on the one file ab98c3b8 changes — exit 0.

Generated by Claude Code

…ment rules as sys_assignment_rule records

An organization's own rules are evaluated first, ascending by sort_order and
then by name, with the rules packages declare as the fallback. A user target
owns the row only while the user is an active member of the row's
organization; a lapsed one leaves the row ownerless and warns once, never
refusing the insert. The sys_assignment_rule save guard refuses a row whose
name a package rule holds, whose object cannot be routed, whose entry shape
the stack schema refuses, whose criteria or load the engine's CEL paths
cannot compile, whose user target is not an active member, or that lands in
no organization.

Claude-Session: https://claude.ai/code/session_016WV4d14C8K4gMehVb6jBvA
Co-authored-by: Claude <[email protected]>
…er, the organization-ownership inventory and the tenancy census, and add the changeset

PLATFORM_PROVIDED_OBJECT_NAMES gains sys_assignment_rule under
plugin-sharing, between sys_assignment_cursor and sys_queue. The
organization-ownership inventory lists it as an attribution-fate table
(its organization column is required, so no NULL row has a producer),
and the platform-object tenancy census picks up the new object. The
changeset declares Clause-② yes (widening), Metadata-protocol added and
the Skills-docs follow-up.

Claude-Session: https://claude.ai/code/session_016WV4d14C8K4gMehVb6jBvA
Co-authored-by: Claude <[email protected]>
…es combine

The assignment_rule registry entry's comment no longer calls
organization-authored routing deferred, and the AssignmentRuleSchema
docblock states the order the engine evaluates in: the organization's
active sys_assignment_rule rows first (sort_order, then name), the
declared rules after them in registry order, the first matching entry
deciding across both. No key, shape or describe() text changes; the
schema and reference generators produce no diff.

Claude-Session: https://claude.ai/code/session_016WV4d14C8K4gMehVb6jBvA
Co-authored-by: Claude <[email protected]>
…nt_rule.criteria's description line

The empty-state gate ties a permissive-empty statement to the nearest
shallower key; on a continuation line it resolved to the field's
`required` key rather than `criteria`. The sentence now opens the
description, so the statement is attributed to `criteria`. Wording is
unchanged in substance.

Claude-Session: https://claude.ai/code/session_016WV4d14C8K4gMehVb6jBvA
Co-authored-by: Claude <[email protected]>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation protocol:system tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 6, 2026
@github-actions

github-actions Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 5 package(s): @objectstack/cli, @objectstack/objectql, @objectstack/platform-objects, @objectstack/plugin-sharing, @objectstack/spec, touching 65 documentable anchor(s). ⚠️ 3 changed file(s) yielded no anchor (packages/objectql/src/index.ts, packages/plugins/plugin-sharing/src/index.ts, packages/plugins/plugin-sharing/src/objects/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

90 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 adf6ee0948d8dd8e747678e6d5107e0c4a86bb31.

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

What this run could not see
  • 3 changed file(s) yielded no anchor (packages/objectql/src/index.ts, packages/plugins/plugin-sharing/src/index.ts, packages/plugins/plugin-sharing/src/objects/index.ts) — pages documenting those are invisible to this run
  • 1 cross-cutting symbol(s) contributed no route anchor: isSystem (5 routes)
  • 3 anchor(s) matched too much of the corpus to be a work list: VALIDATION_FAILED (literal, 42 pages), organization_id (literal, 39 pages), sys_user (literal, 49 pages)
  • 15 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 63 of 221 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 103 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 — 179 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 adf6ee0948d8dd8e747678e6d5107e0c4a86bb31 → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ 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 adf6ee0948d8dd8e747678e6d5107e0c4a86bb31 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…cope in the empty-state registry

An organization's assignment rule with an empty condition takes every new
record no earlier rule took. The entry records that this selects work to
route and grants nothing, mirroring the declared rule's criteria entry,
which stays byte-identical.

Claude-Session: https://claude.ai/code/session_016WV4d14C8K4gMehVb6jBvA
Co-authored-by: Claude <[email protected]>
…ry in en, zh-CN, ja-JP and es-ES

plugin-sharing contributes nav_assignment_rules to the Setup app at
runtime; check:app-nav-i18n requires a label for every runtime-merged nav
id in every locale file of the app's translations.

Claude-Session: https://claude.ai/code/session_016WV4d14C8K4gMehVb6jBvA
Co-authored-by: Claude <[email protected]>

This branch has not been deployed

No deployments
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 protocol:system size/xl 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.

[Decision] Can an organization author its own assignment rules, or do they stay package-declared only?

1 participant