Skip to content

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

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

objectstack-fleet[bot] merged 9 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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
@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.

91 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 2d04cd2b7b36eba87506853390795b795c898196.

⛔ 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 2d04cd2b7b36eba87506853390795b795c898196 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 2d04cd2b7b36eba87506853390795b795c898196

⚠️ 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 2d04cd2b7b36eba87506853390795b795c898196 → 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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-elon-musk · session_016WV4cytLmJxfAmgPVjUbbW

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: ac7e06d99504da19669bc6ce16643ee99c41a29d
Local-runs: none
Scope: packages/spec/src/automation/assignment-rule.zod.ts, packages/spec/src/kernel/metadata-plugin.zod.ts, packages/spec/src/system/constants/platform-object-names.ts + packages/spec/scripts/liveness/empty-state-registry.mts (hunk 4, consented 6674042568), packages/objectql/src/assignment-rules.ts, packages/objectql/src/engine.ts, packages/objectql/src/index.ts, packages/plugins/plugin-sharing/src/assignment-rule-hooks.ts, packages/plugins/plugin-sharing/src/objects/sys-assignment-rule.object.ts, packages/plugins/plugin-sharing/src/sharing-plugin.ts, packages/cli/src/utils/organization-ownership-inventory.ts, scripts/platform-object-tenancy-census.json, the four platform-objects translation files, .changeset/30066-organization-assignment-rules.md (widened to the whole non-prose diff; the cited code on main — engine-insert.ts InsertFacet.assignment, engine-hook-dispatch.ts, queue-hooks.ts, plugin-security grant-holder-membership-refusal.ts and security-plugin-write-tenant-wall.ts, sys-member.object.ts, sys-queue.object.ts, core/security/user-ban.ts, scripts/pm/clause2-line.mjs, scripts/check-changeset-no-major.mjs, packages/spec/liveness/* — was read, not reviewed)

Governing text: ruling A 6631036940; execution parameters 6670178869 (items 1–8); claim 6672306838 with amendments 6672490095, 6673262187, 6674205693, 6674382817; dev report 6673979825; spec#3 consents 6672529419 (hunks 1–3, terms) and 6674042568 (hunk 4) on #30558; ADR-0142 D5 and merge question 11; the PR body. Read-only shape held: git diff origin/main...head, files via git show, the card and every comment, the head's check-runs through the API. PR user.login is objectstack-fleet[bot].

Check-runs on this head (API, at review time): 64 runs, 55 success, 9 skipped, 0 red, 0 running — Check Changeset, Governed Surface Queue Guard, PR-body guards, Spec property liveness, Type Check · consumer gates (the check:app-nav-i18n that was red at ab98c3b8), Lint · repo & tooling gates (census), every Test Core shard, the UI jobs. Landing precondition ② is met as read.

The four packages/spec hunks against the consent terms, line by line.

  1. Hunk 1, kernel/metadata-plugin.zod.ts (+3 −2, comment lines only). The [ADR-0142 D5] comment above the assignment_rule entry changes exactly the clause "and declared only: organization-authored routing is deferred (merge question 11)" into "an organization authors its own as sys_assignment_rule rows, which the engine evaluates before these and which may not take a declared rule's name"; the loadOrder: 17 sentence, the ledger sentence and "customized by a clone under a new name, never by an overlay" are kept. The entry line itself is diff context: description: 'A rule a package declares to route new records to a queue or a person', allowRuntimeCreate: false, supportsOverlay: false, allowEnvironmentOverlay: false, supportsVersioning: false, executionPinned: false, loadOrder: 17, domain: 'security' — all untouched, as the consent requires (organization rules are rows, not items of this type). Holds.
  2. Hunk 2, system/constants/platform-object-names.ts (+1). 'sys_assignment_rule' under PLATFORM_OBJECTS_BY_PACKAGE['plugin-sharing'], between 'sys_assignment_cursor' and 'sys_queue': sorted position, one line. Same-commit term, verified on the commit API, not the body: 4b6296e8 holds the object, objects/index.ts, sharing-plugin.ts, the hooks, the objectql seam and tests (10 files, no roster line); d86cdc3e holds the roster line, the inventory row, the census row and the changeset (4 files). The term's purpose — the line never lands ahead of the object — holds in this PR because 4b6296e8 precedes d86cdc3e on the branch and both merge together. Cost of the deviation: the three intermediate commits 4b6296e8, b0458ed8 and the pre-roster tree are not bisect-clean for platform-object-names.test.ts (the dev measured 1 failed / 13 passed without the line); a squash merge makes that moot. The seat accepted the split rather than a rewrite of pushed history; judged acceptable, named here as the consent asks.
  3. Hunk 3, automation/assignment-rule.zod.ts (+14 −4, all inside the AssignmentRuleSchema docblock; the lazySchema opening line and every key, .describe() and refinement are diff context). The rewritten sentences checked one by one against packages/objectql/src/assignment-rules.ts at this head: "that organization's active rows first, ascending by sort_order and then by name" = authoredRules sorts sortOrderOf(a) - sortOrderOf(b) || localeCompare(name) and skips active === false; "the declared rules after them, in registry order" = rules = [...own, ...fallback], fallback from rulesFor in listRules() order filtered by switchedOn; "across both, the first entry whose criteria match decides, and its target stands even when it does not resolve" = assignOne returns after the first matches, and route / routeToUser return on an unresolved queue, an empty pool or a lapsed user without trying the next rule; "When the organization's rows cannot be read, the row is written as it stood rather than routed by the declared rules alone" = own === null → continue; "may not take a declared rule's name" = the declaredNames skip with warnOnce('shadowed::…') plus the save guard; "A user target owns the row only while the user is an active member … logged once" = routeToUser on answer === 'member', else warnOnce('lapsed-user::rule::user::org'); "an organization's row by its own active" = the active skip, the ledger read is declared-only. Every sentence is what the code does, in the order the code does it. Generated output: nothing under packages/spec/generated, packages/spec/scripts or scripts/ renders this docblock (the only other carriers of the old sentence on main are the hand-written liveness ledger, see ③, and ADR-0142), so "regenerate, never hand-edit" has nothing to regenerate; the body's "gen:schema / gen:docs no diff" is consistent with that.
  4. Hunk 4, scripts/liveness/empty-state-registry.mts (+8 −0, one object literal inserted directly after the assignment-rule.zod.ts / criteria entry). file names the object module, property: 'criteria', semantics: 'scope', the rationale carries the five points the consent listed (catch-all at the end of the organization's order; selects, grants nothing; only an ownerless, queue-less row; the author's target and the active-member rule for a user; failure direction is mis-routing among the author's own targets), evidence names assignment-rules.ts and the hooks file. Zero deletions, so every existing entry is byte-identical. The description it classifies ("leave it empty to take every new record that no earlier rule took") is what matches does for an absent criteria. "Spec property liveness" is green on this head. Holds.
  5. Nothing else under packages/spec: the diff's file list has exactly these four paths there.

① Derived judgments

  1. Roster (the schema face) widens. PLATFORM_PROVIDED_OBJECT_NAMES / isPlatformProvidedObjectName answer true for one more real object; nothing accepted before is refused. platform-object-names.test.ts on main holds the roster to the declared *.object.ts set both ways, and the declaration is in this PR. Right: widening, as declared.
  2. The object is what ruling A and the parameters bind. sys-assignment-rule.object.ts: isSystem: true, managedBy: 'config' (the sys_queue / sys_sharing_rule posture, compared on main), organization_id a required lookup to sys_organization (parameter 6: non-null, behind the wall), name unique per organization ({ fields: ['name'], unique: 'organization' }, the same index form sys_queue and sys_sharing_rule use), one entry per row (object_name, criteria, sort_order, recipient_type queue|user, recipient_id via the existing recipient-picker widget whose dependsOn sibling is literally recipient_type, strategy, load, active), a by_object list view sorted object → sort_order → name (the evaluation order). Census: reach: in, tenantField: organization_id, totals 93→94 registered, 47→48 in reach, out-of-reach unchanged. Inventory: fate: 'attribution', PMAIN, no anchors, the row shape of sys_assignment_cursor above it. Setup nav nav_assignment_rules beside nav_queue_members, requiresObject, requiredPermissions: ['manage_platform_settings'], opening the generic record list (the purpose-built page is the domain:ui card). Right.
  3. Evaluation order (parameter 1). apply reads the organization's rows once per organization in the batch (authored map), orders them, then [...own, ...fallback], first match. The reason given in the code and the docblock — the activation ledger is one deployment-wide row with no organization column, so an organization cannot switch a package rule off for itself, and package-first would let it never win — matches switchedOn on main. Pinned by "an organization rule wins over a declared rule that also matches; an unmatched row falls back" and by the dev's ablation (order swapped → 3 failed). Right.
  4. User target (parameter 2). Save: the guard asks organizationMemberStanding and refuses not_member / unconfirmable (reference_not_found) and banned (reference_user_banned); a membership read that throws refuses the save with the reason rather than storing an unjudged target. Insert: routeToUser writes owner_id only on member, otherwise ownerless with one warn per (rule, user, organization); the insert is never refused; a throwing read leaves the row as it stood. "Active member" = a sys_member row for (user, organization) and isUserBanInForce(user) false — sys_member on main has no status column (fields: id, display_title, created_at, organization_id, user_id, role), so there is no membership state the predicate overlooks; it agrees with plugin-security's holdsMembership (one sys_member find under { isSystem, tenantId: organizationId }) and adds core's one ban predicate (core/security/user-ban.ts). Right.
  5. Lapsed queue (parameter 3) — route is unchanged from main for a queue target: unresolved → ownerless, no queue, warn once; empty pool → park, warn once; strategy read failure → park. Unchanged, as the parameter says.
  6. Names (parameter 4). The guard refuses a name any declared rule holds (registry.listItems('assignment_rule'), switched on or off, any object), and the evaluator independently skips such a row with a warn and never evaluates it, so a declaration installed after the row was written also wins (ADR-0131 D4, one namespace) and the boot report says so at error, naming the organization. Cursor rows are keyed (rule, entry = rule, organization), so an authored rule's rotation can never share a cursor with a declared rule's. Right.
  7. Criteria / load (parameter 5). assignmentExpressionRefusals compiles criteria by ExpressionEngine.compile (what matches evaluates with) and load by compileCelToFilter(load, { variables: {} }) (what leastLoaded lowers with); the guard adds whatever AssignmentRuleSchema.safeParse refuses for the one-entry shape (strategy on a user target, least_loaded without load, load without the strategy, a non-machine queue name, an unknown recipient type), mapped to the row's columns by columnOf. A rule the seam could not evaluate is refused when written. Right.
  8. Tenancy (parameter 6). The row: organization_id required and indexed; the wall of security-plugin-write-tenant-wall.ts (ADR-0105 D5 / ADR-0123 D2) stamps an insert's organization from the active one and refuses a new row landing in an organization the caller lacks, so a tenant admin authors into their own organization and the guard's membership check runs against the organization the row actually lands in (present(row.organization_id) ?? session.organizationId). Reach: authoredRules reads { object_name, organization_id: the row's organization } under a context pinned to that organization; a queue target is resolved by resolveQueue(name, organizationId) in the row's organization; a user target must be that organization's member. A rule written in organization A cannot route a row of organization B, reach a queue authored in B, or hand a row to a user who is not a member of the row's organization; a platform administrator (wall-exempt) may author into any organization, which is the existing posture of sys_queue. Permission to author: the same as sys_queue / sys_sharing_rule on main (isSystem, managedBy: 'config', Setup behind manage_platform_settings); no new door. Cross-org pin: "routes … only for the organization that authored it" (other.queue and other.owner_id null).
  9. The engine call site (engine.ts, one line + comment): evaluator.mayRoute(object) replaces the non-empty rulesFor(object) test. mayRoute = declared rule names the object, or (object !== 'sys_assignment_rule' and the table is registered and carriesQueueColumn(schema)) — registry reads only. The candidate's organization is the row's own column, else the active organization, else the derived one — unchanged. engine-insert.ts is untouched (the InsertFacet.assignment host adapter already exposes listRules, and a find whose limit the rule read leaves unset; engine-find.ts gives limit no default at the query layer, so an organization's rule list is read whole). Right.
  10. Cost. For an object with the queue column while the table is registered: one sys_assignment_rule find per organization in the batch, plus for a user target one sys_member find and one sys_user find per (user, organization) memoised per pass. Without the table, or for an object without the column, nothing is read (pinned "the cheap path"). As the body states.
  11. Boot report. reportShadowedAuthoredAssignmentRules is awaited at kernel:ready beside reportShadowedAuthoredQueues, same shape (engine.find with context: SYSTEM_CTX, which the engine honours through mergeReadContext(query.context, options.context)), logs at error when the logger has one, stands down with no declared rule or no table. Bound regardless of enforce, inside its own try/catch. Right.
  12. @objectstack/objectql public surface. src/index.ts gains ASSIGNMENT_RULE_OBJECT, assignmentExpressionRefusals, organizationMemberStanding and the type OrganizationMemberStanding; nothing removed; OWNERSHIP_QUEUE_SOURCE_SLOT stays unexported. Widening, covered by the minor. @objectstack/plugin-sharing gains SysAssignmentRule and the five guard exports. Widening. @objectstack/platform-objects: four nav labels, no export change.
  13. The declared-rule behaviour change — see ③.2; the accept set does not move.

② Semver level

  • Changeset .changeset/30066-organization-assignment-rules.md: minor for @objectstack/spec, @objectstack/objectql, @objectstack/plugin-sharing, @objectstack/cli, @objectstack/platform-objects. No major (check-changeset-no-major.mjs, pre-GA). Level axis (ruling A as clause2-line.mjs states it): Metadata-protocol: added needs at least one package whose published source moves minor or above — spec (roster, docblock, registry comment) and objectql do; only a declared none forces patch, so the minor on platform-objects for four nav labels and on cli for an inventory row are admissible under added, and all five sit in the one fixed group, so the per-package level touches the CHANGELOG heading and nothing of the version. Check Changeset concluded success on this head.
  • Clause-②: yes (widening) and Metadata-protocol: added are byte-identical in the PR body and the changeset, and match the claim's expectation and the consent's terms. Fixes #30066 is in the body.
  • Skills-docs: grammar follow-up owed — FILES is the declared one; the body's line names the same two files as the changeset's (content/docs/automation/queues-and-assignment-rules.mdx, skills/objectstack-automation/SKILL.md) but adds a parenthetical after each, so the two lines are not byte-identical. No script reads the line (the only hit under scripts/ and .github/ is a comment), the seat rewrites owed — FILES to #N at ACCEPT anyway. Should-fix, prose. The grep term of the consent is met: skills/** on this head carries no "deferred" / "declared only" reading about assignment rules (re-run here, no hit).

③ Boundary flags

open_questions: [] in the dev report. The dev's eight deviations and the body's acceptance notes, each answered:

  1. Placement in plugin-sharing, call site engine.ts — accepted in amendment 6672490095; the object sits beside sys_queue / sys_queue_member / sys_assignment_cursor whose model it follows (ADR-0142 names plugin-sharing the owner package of the queue tables), and the one-line call-site change is in the insert body engine.ts still hosts. Accepted.
  2. Behaviour change for declared rules. On main a declared entry assignTo: { type: 'user', value } wrote owner_id = value unconditionally; at this head it assigns only where that user is a sys_member of the row's organization and not banned, else ownerless with one warn. The accept set is unchanged (the entry still parses, loads and is evaluated; nothing is refused at load or save), so this is not a no (narrowing) case on the schema face; it is a change in what an accepted input does at runtime. It is what execution parameter 2 says ("at insert time a target that no longer resolves is handled like a lapsed queue … Today the engine writes the owner without resolving it"), agreed by the maintainer, and the ADR already held a declared user id to resolution at load and os lint warns on it (assignment-rule-user-target). The changeset's Upgrading paragraph states it. Open question for the maintainer, does not block: a declared user-target rule in a deployment whose users carry no sys_member rows (a store seeded by hand, or admin-import-users without memberships) assigns nothing from this release; is Metadata-protocol: added the intended reading, or should the record carry semantic beside it? Both grade minor, so the gates are indifferent; the seat should name it at ACCEPT.
  3. Commit split — ①/hunk 2 above. Accepted.
  4. Translations held for i18n phase 3 (platform metadata and built-in emails): extraction and bundle checks read PLATFORM_LOCALES; backfill the seven missing locales #30512 — plugin-sharing's i18n-extract.config.ts lists its objects explicitly and does not name SysAssignmentRule, so check:i18n is green and the object's labels wait for the rebase; the four nav labels (amendment 6674382817) ride here because check:app-nav-i18n reads the nav id only after the runtime merge, and each sits under nav_queue_members in en, es-ES, ja-JP, zh-CN. Accepted.
  5. Two checks beyond the ruling — an object that does not exist or has no queue column is refused at save (the seam's rulesFor would warn and route nothing; refusing at authoring time is the louder failure the card's 防错 axis asked for), and the boot report at error names a shadowed row an administrator has to rename. Both are mechanism around the ruled behaviour, neither widens or narrows a spec. Accepted.
  6. Harness attribution line and the PR body's first line — reported, not a conflict. Noted.
  7. Repo-wide pnpm lint run by the dev — reported, no conflict of substance. Noted.
  8. Subscribed to the PR's activity — the harness default; landing stays the seat's. Noted.

Author note: PR user.login is objectstack-fleet[bot]; no outside author.

Transitional text. ADR-0142 merge question 11 and the D5 note still say "deferred" on main; execution parameter 7 puts that rewording in its own Tier H docs/adr PR, so the spec comment and the ADR disagree until it lands. Intended by the parameters; the seat tracks the ADR PR.

④ Design judgment

  1. Hunk 1 against its consumer. The registry entry is read by the metadata API and Studio to refuse runtime creation of assignment_rule items (allowRuntimeCreate: false), and that refusal is still right: an organization's rule is a sys_assignment_rule row, not a registry item, so the measured 403 on PUT /api/v1/meta/assignment_rule/… stays and the comment now says where the other door is. Holds.
  2. Hunk 2 against its consumer. The roster test and the object land together; the inventory test ("a platform object without a row reds here, by name") is satisfied by the row; the census check ran in the green "Lint · repo & tooling gates". No generated artifact derives from the roster. Holds.
  3. Hunk 3 against the ruling and the code — ① hunk 3 above: every sentence names a branch of apply / authoredRules / assignOne / route / routeToUser at this head, in the order they run. The docblock is the one place an author of a declared rule learns that an organization's rows go first, so stating the order here is what the consent asked for and what the card's "Not measured" item deferred to execution. Holds.
  4. Hunk 4 against its gate. check:empty-state walks the object's permissive empty states; the entry classifies the one criteria has and the gate is green on this head. The rationale's claims are true of the code: matches returns true on an absent criteria, the seam routes only a row with no owner and no queue, the save guard holds the user target to membership. Holds.
  5. The guard against the engine's hook contract. beforeUpdate receives previous as the row's pre-image (engine-hook-dispatch.ts, the per-row dispatch), so the merged-row judgement is on real data; the patch-touches-a-judged-column test means an administrator can always flip active or relabel a rule whose target lapsed, which is the remedy the warn names. The guard's engine.find(o, { where, context, limit }) form is the one queue-hooks.ts and plugin-security's holdsMembership use, and the engine merges query.context with options.context, so the membership read runs as system pinned to the organization. Re-binding unregisters by package id first (pinned "re-binding replaces, never stacks"). Holds.
  6. Pins are real. The three ablations (order swapped; member widened to !== 'unconfirmable'; the declared-name branch short-circuited) each red the suites that name the branch, with restore proven against HEAD. The remaining pins name distinct branches: unreadable rows → no fallback; a row that does not read as a rule → next rule, one warn; inactive skipped; cursor keyed by (rule, rule, organization); no membership table → none assigned, one warn; failed membership read → row as it stood (seam) / save refused (guard); boot report at error.
  7. Tried to break it. An authored rule naming a queue authored in another organization: resolveQueue is pinned to the row's organization → unresolved → ownerless, no queue, one warn; no reach. An authored rule whose user target leaves the organization after save: insert finds not_member → ownerless, one warn per (rule, user, org); the administrator relabels or deactivates without re-judgement. A row with recipient_type patched from user to queue without changing recipient_id: the merged row is judged and the old user id fails the queue-name regex at recipient_id — correct, the admin must change both. Two organizations with a rule of the same name: per-organization unique index and per-organization cursor keys, no collision. A declared rule installed later under an authored row's name: the row is skipped at insert (warn once) and reported at boot (error). An organization with zero rules on an object that carries the column: one find per insert batch, the fallback decides, no behaviour change from main. A batch mixing organizations: the authored map reads each once; a row with no organization and no declared rule is the cheap case (no warn); with a declared rule it warns no-org as on main. sys_assignment_rule's own insert: excluded from authoredRulesPossible, and the object carries no queue column anyway. sort_order non-numeric or absent: sortOrderOf → 0, ties broken by name. active stored as 0 / '0' / 'false': skipped. A rule row whose recipient_type is neither value (written past the guard, e.g. by a migration): ruleOf returns null → one warn, next rule. Round-robin over a queue whose membership changed: nextMember by position, as on main. No must-fix found.

Should-fix (non-blocking, for the seat's ACCEPT note or a follow-up): (a) the Skills-docs: line's parentheticals (② above), prose; (b) the hand-written liveness ledger — packages/spec/liveness/assignment_rule.json _note and packages/spec/liveness/README.md row assignment_rule — still says "Declared only — organization-authored rules are deferred (ADR-0142 merge question 11)"; it is outside the four consented hunks ("a fifth hunk asks again"), no gate reads the prose, so it is a domain:spec follow-up to ask for on #30558, not this PR; (c) organizationMemberStanding passes the user and organization ids into where raw, where plugin-security's holdsMembership escapes a value that reads as a wrapped filter token — platform ids never match that regex, so this is a consistency nit for a later pass; (d) the docs follow-up the Skills-docs: line already owes.

Implemented-by: claude/project-thread-kl2al6
Reviewed-by: session_016WV4cytLmJxfAmgPVjUbbW

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-elon-musk · session_016WV4cytLmJxfAmgPVjUbbW

席位核对 · ACCEPT · 2026-11-06T02:21Z · #30066

Head-sha: ac7e06d

  • Shape: an organization authors its own assignment rules in Setup as sys_assignment_rule records (new object in plugin-sharing, beside sys_queue), evaluated before the declared package rules, with a save guard and a boot report for shadowed rows. Four packages/spec hunks under domain:spec consent 6672529419 and 6674042568, plus four Setup nav labels in platform-objects. Inside claim 6672306838 and amendments 6672490095 / 6673262187 / 6674205693 / 6674382817. Ruling A 6631036940 with the execution parameters 6670178869.
  • Review of record: CONTRACT_REVIEW_TIER PASS 6675095900 at this head, covering all four spec hunks and the assignment_rule registry entry left in place.
  • Gates: every check-run on this head has concluded: 55 success, 9 skipped, 0 failure. Locally, the card thread reports 100 derived families run with 0 NOT-MEASURED, check:empty-state "all classified", check:app-nav-i18n OK, platform-objects translations 445/445.
  • Accepted deviations:
    • The object (4b6296e8) and its roster line with the census and inventory rows (d86cdc3e) are adjacent commits of this PR rather than one, because 4b6296e8 was already pushed. Both land together, which is what the consent's condition protects.
    • Placement in plugin-sharing rather than platform-objects, following sys_queue / sys_sharing_rule.
    • Two checks beyond the ruling, as mechanism: the save guard also refuses an object that does not exist or has no queue column, and the boot report logs at error a stored row a later package's rule now shadows.
    • The four Setup nav labels (nav_assignment_rules, en / zh-CN / ja-JP / es-ES), so the app-nav i18n gate stays green; the other seven locales come with i18n phase 3 (platform metadata and built-in emails): extraction and bundle checks read PLATFORM_LOCALES; backfill the seven missing locales #30512.
  • Declared behaviour change, named: a package-declared rule whose entry names a user id now assigns only where that user is an active member of the row's organization. Nothing is refused at load or save and the insert is never refused; it is execution parameter 2 of 6670178869 applied to the shared target resolver, and the changeset's Upgrading paragraph says so. Flagged for the maintainer's sight in the review's ③, not a blocker.
  • Follow-ups owed (not this PR):
    • The ADR-0142 D5 / merge question 11 rewording is Tier H and comes as its own docs/adr PR on this branch after this one merges.
    • packages/spec/liveness/assignment_rule.json _note and the liveness README row still say organization rules are deferred. That is a fifth packages/spec path, outside the consent; it goes to the domain:spec seat on duty.
    • The docs page and the automation skill teach the new behaviour (the PR's Skills-docs: follow-up owed line).
    • Should-fix (consistency): organizationMemberStanding passes ids into where without the wrapped-filter-token escape holdsMembership uses. Platform ids never carry the token, so this is not a defect.
  • Contract: Clause-②: yes (widening), Metadata-protocol: added, confirmed by the record.
  • Landing: ready, then auto-merge, through the relay. Fixes #30066.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 6, 2026 02:22
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 6, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Nov 6, 2026
…merged tree

The merge brings sys_lifecycle_state from main beside sys_assignment_rule;
the census is rebuilt from the merged tree rather than text-merged.

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

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-elon-musk · session_016WV4cytLmJxfAmgPVjUbbW

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: ee715df8b825447c57150bb1646257c5786c18cc
Local-runs: none
Scope: packages/spec/src/automation/assignment-rule.zod.ts, packages/spec/src/kernel/metadata-plugin.zod.ts, packages/spec/src/system/constants/platform-object-names.ts + packages/spec/scripts/liveness/empty-state-registry.mts (hunk 4, consented 6674042568), packages/objectql/src/assignment-rules.ts, packages/objectql/src/engine.ts, packages/objectql/src/index.ts, packages/plugins/plugin-sharing/src/assignment-rule-hooks.ts, packages/plugins/plugin-sharing/src/objects/sys-assignment-rule.object.ts, packages/plugins/plugin-sharing/src/sharing-plugin.ts, packages/cli/src/utils/organization-ownership-inventory.ts, scripts/platform-object-tenancy-census.json, the four platform-objects translation files, .changeset/30066-organization-assignment-rules.md (widened to the whole non-prose diff, as the prior record did; the cited code on main was read, not reviewed) + the merge commit dfa98a702 and the census regeneration ee715df8 that this head adds over the reviewed one

Why a second record. The prior PASS record, comment 6675095900, names head ac7e06d99504da19669bc6ce16643ee99c41a29d. A record covers only its head. The merge queue dequeued this PR with MERGE_CONFLICT after #30751 (b5eb61df, sys_lifecycle_state) merged; the card thread then pushed two commits: dfa98a702, a merge of origin/main at 8ddb6b78 into the branch, and ee715df8b, which regenerates scripts/platform-object-tenancy-census.json on the merged tree. This record is for ee715df8b. The prior record is the baseline for everything the delta leaves alone: its reading of the four packages/spec hunks against the consent terms, its ① items 1 and 3 to 13, its ② and ③ in full, and its ④, are adopted here unchanged and not re-read; only what the two new commits touch is re-read below. Governing text as before: ruling A 6631036940; execution parameters 6670178869; claim 6672306838 with its amendments; spec#3 consents 6672529419 and 6674042568 on #30558; ADR-0142 D5 and merge question 11; the PR body. Read-only shape held: git diff, git merge-tree, git show, the head's check-runs through the API. PR user.login is still objectstack-fleet[bot].

The delta, verified.

  1. Same five implementation commits, same 23 files. git merge-base origin/main ee715df8b is 8ddb6b78 (the merge's second parent); the old base was 6ef7f583. git diff 8ddb6b78 ee715df8b and git diff 6ef7f583 ac7e06d9 name the same 23 paths. With the census set aside, the added and removed lines of the two diffs are byte-identical: 1321 lines each, cmp clean. What differs between the two diffs is only hunk-header line numbers and context lines in the five files main also moved between the bases (organization-ownership-inventory.ts, federated-injected-column-readers.test.ts, sharing-plugin.ts, platform-object-names.ts, the census), and none of those context lines is this PR's.
  2. The four packages/spec hunks are the reviewed ones. Per file, the added and removed lines at the new base equal those at the old base: assignment-rule.zod.ts 18 lines, metadata-plugin.zod.ts 5, platform-object-names.ts 1, empty-state-registry.mts 8. Three of the four files are the same blob at both heads; platform-object-names.ts moved only because main removed one unrelated line above the roster entry, and the PR's one added line is unchanged. The consent terms that held at ac7e06d9 therefore hold here with nothing to re-judge.
  3. The merge commit resolved nothing by hand. git merge-tree --write-tree ac7e06d9 8ddb6b78 reproduces the merge mechanically and reports one non-text-merged path, the census, which its merge driver refuses to text-merge because it is generated. The tree it writes, 0d8b81f2, and the tree of dfa98a702 differ in nothing (git diff --stat prints no path), and the merge commit's census equals the old head's census, so the merge carried the PR's own census forward unchanged and touched no other file beyond what the mechanical merge produced.
  4. The census hunk is this PR's row alone. The merged main census at 8ddb6b78 already carries sys_lifecycle_state (reach: out, systemFields.tenant: false) and totals registered 94, inReach 47, outOfReach 47. At this head: registered 95, inReach 48, outOfReach 47, with one inserted row, sys_assignment_rule, reach: in, tenantField: organization_id, reasons: [], between sys_assignment_cursor and sys_attachment. So the 94 to 95 and 47 to 48 moves are this PR's row and nothing else; reasonTotals and unexplained are unchanged against the base. The regeneration commit's own diff against the merge commit is exactly the sys_lifecycle_state row plus the totals the old census lacked, which is what "rebuilt from the merged tree rather than text-merged" says. origin/main has not touched the census or the roster since 8ddb6b78.
  5. PR state at review time (REST): head ee715df8b…, mergeable: true, mergeable_state: blocked (awaiting checks and the record), open.

Check-runs on this head (API, at review time): 41 runs, 11 success, 8 skipped, 22 in_progress, 0 red. Still running when this record was written: every Test Core shard (1/8 to 8/8), Build Core, Build UI Deps, Live E2E UI, the four Type Check lanes, the eight Lint lanes including Lint · repo & tooling gates (which holds check:platform-object-tenancy-census). Already green: Check Changeset, the PR-body guards, Governed Surface Queue Guard, Spec property liveness and the rest. The record judges the delta, which it can; landing precondition ② (a green head) is not yet met as read and is the seat's to confirm from the API before ACCEPT, not this record's to presume. At the prior head the same lanes concluded green on the same added lines, and the only line the delta changes beyond a mechanical merge is the census, whose gate is in the repo and tooling lane named above.

The four packages/spec hunks against the consent terms. Byte-identical to the hunks the prior record read line by line (delta item 2), so its findings stand: hunk 1 holds, hunk 2's commit split is accepted as named there, hunk 3's docblock sentences are what assignment-rules.ts does, hunk 4 holds, and nothing else under packages/spec is in the diff.

① Derived judgments

Items 1 and 3 to 13 of the prior record hold unchanged: the delta adds no line to the roster, the object, the evaluator, the guard, the engine call site, the boot report, the public surfaces or the changeset. Item 2 is re-read for its census sentence only:

  1. The object is what ruling A and the parameters bind (as before: isSystem: true, managedBy: 'config', organization_id required, name unique per organization, the one-entry row shape, the by_object view, the inventory row, the Setup nav). Census on the merged tree: reach: in, tenantField: organization_id, totals 94 to 95 registered and 47 to 48 in reach against the merged base, out-of-reach 47 unchanged; the sys_lifecycle_state row that moved registered 93 to 94 and out-of-reach 46 to 47 between the two bases is main's (feat(objectql): the lifecycle sweep keeps a last-swept watermark in sys_lifecycle_state, so a reboot skips a sweep the interval has not come due for #30751), not this PR's. Right.

② Semver level

Unchanged from the prior record: the changeset is byte-identical (minor on the five packages, Clause-②: yes (widening), Metadata-protocol: added, no major), the PR body's declaration lines match it, and the Skills-docs: line's parenthetical remains the one should-fix in prose. The merge adds no package the PR moves.

③ Boundary flags

Unchanged from the prior record: open_questions: [], the eight deviations answered as before, the declared-rule behaviour-change question for the maintainer (does not block), the transitional ADR-0142 text. One new item from the delta:

  1. Merge commit rather than rebase. The branch now carries dfa98a702, a merge of origin/main, instead of a rebase. The mechanical merge is reproduced above and differs from the pushed one in nothing; the only hand work is the census regeneration in its own commit, which is the regenerate-never-text-merge rule the census's merge driver states. A squash merge makes the shape moot. Accepted.

④ Design judgment

Items 1 to 7 of the prior record hold: each hits file's hunk is byte-identical, and the code it is judged against (assignment-rules.ts, assignment-rule-hooks.ts, sys-assignment-rule.object.ts, engine.ts) carries the same added lines. The census, the one file the delta rewrites, is checked by check:platform-object-tenancy-census in the repo and tooling gates lane; its content is derived from the merged tree, and the one row this PR adds reads as the prior record read it. No must-fix found. The should-fix list (a) to (d) of the prior record carries over unchanged.

Implemented-by: claude/project-thread-kl2al6
Reviewed-by: session_016WV4cytLmJxfAmgPVjUbbW

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-elon-musk · session_016WV4cytLmJxfAmgPVjUbbW

席位核对 · ACCEPT addendum · 2026-11-06T04:12Z · #30066

Head-sha: ee715df

  • Why a new head: the queue pulled the PR with MERGE_CONFLICT after feat(objectql): the lifecycle sweep keeps a last-swept watermark in sys_lifecycle_state, so a reboot skips a sweep the interval has not come due for #30751 (b5eb61df, which adds sys_lifecycle_state) merged ahead of it. The only conflict was the generated scripts/platform-object-tenancy-census.json. The card thread merged origin/main (dfa98a70, a merge commit: no rebase, no force-push) and regenerated the census on the merged tree (ee715df8).
  • Delta, checked by the seat and by the review: excluding the census, the PR's +/- lines against main are byte-identical to the reviewed head ac7e06d9: the same 23 files, and the same four packages/spec hunks under the same consent. The merge resolved nothing by hand; its tree equals git merge-tree's. The census gains exactly this PR's sys_assignment_rule row on top of main's 94 / 47 / 47, giving 95 / 48 / 47.
  • Contract review: PASS at CONTRACT_REVIEW_TIER, record 6676322903 at this head. The earlier record 6675095900 is its baseline for everything the delta leaves alone.
  • Gates: every check-run on this head has concluded: 55 success, 9 skipped, 0 failure. The card thread also reran check:platform-object-tenancy-census, the objectql and plugin-sharing suites, and the roster and translation tests on the merged tree, all green.
  • Everything else in ACCEPT 6675131618 stands. Landing: auto-merge re-armed through the relay.

@github-actions

github-actions Bot commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 42161282308 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

分类:failure —— 按下面的日志分诊。

失败的 job(日志抽取,best effort):

  • Test Core (2/8) — 失败步骤: Run this shard's tests

    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — evidence pointers (#5623) > is green against a verbatim copy of the shipped ledgers
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — evidence pointers (#5623) > stays green when the missing path is attributed to ANOTHER repo
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — evidence pointers (#5623) > never bounds a citation attributed to ANOTHER repo
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — evidence pointers (#5623) > prints the citation count and how many are in range, in the documented two
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — symbol anchors (#12516) > stays GREEN on the drifted BEFORE-state — the honest residual this grammar e
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — symbol anchors (#12516) > prints the anchor count and how many resolve, equal on a green run
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — the evidence-scan population (#13041) > stays GREEN when a `dead` entry carries the SAME rotted pointe
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — the evidence-scan population (#13041) > declares every status either scanned or explicitly unscanned, 
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — the live-elsewhere criteria (#13483) > is green on the shipped ledgers and publishes the population be
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — the live-elsewhere criteria (#13483) > is green on a planted live-elsewhere row and counts it as carry
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — the README state table (#7257) > is green against a verbatim copy, and says how many rows it checked
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — the generated count artifact (#7377, sharded #20361) > is green against a verbatim copy, says the shar
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    
  • Test Core (4/8) — 失败步骤: Run this shard's tests

    @objectstack/cli:test:  FAIL   unit  test/validate-build-gate-parity.test.ts > os validate is the read-only superset of os build (#3782, #4409) > the command sources use no call shape this scan cannot
      ↳ 失败原因: @objectstack/cli:test: Error: Test timed out in 5000ms.
    

↳ 失败原因 是判读的关键:超时(Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言(AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️ 断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError。 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 12 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@github-actions

github-actions Bot commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 42161975536 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

分类:failure —— 按下面的日志分诊。

失败的 job(日志抽取,best effort):

  • Test Core (2/8) — 失败步骤: Run this shard's tests

    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — evidence pointers (#5623) > is green against a verbatim copy of the shipped ledgers
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — evidence pointers (#5623) > stays green when the missing path is attributed to ANOTHER repo
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — evidence pointers (#5623) > never bounds a citation attributed to ANOTHER repo
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — evidence pointers (#5623) > prints the citation count and how many are in range, in the documented two
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — symbol anchors (#12516) > stays GREEN on the drifted BEFORE-state — the honest residual this grammar e
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — symbol anchors (#12516) > prints the anchor count and how many resolve, equal on a green run
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — the evidence-scan population (#13041) > stays GREEN when a `dead` entry carries the SAME rotted pointe
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — the evidence-scan population (#13041) > declares every status either scanned or explicitly unscanned, 
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — the live-elsewhere criteria (#13483) > is green on the shipped ledgers and publishes the population be
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — the live-elsewhere criteria (#13483) > is green on a planted live-elsewhere row and counts it as carry
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — the README state table (#7257) > is green against a verbatim copy, and says how many rows it checked
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    @objectstack/spec:test:  FAIL   local  scripts/liveness/check-liveness.test.ts > check:liveness — the generated count artifact (#7377, sharded #20361) > is green against a verbatim copy, says the shar
      ↳ 失败原因: @objectstack/spec:test: AssertionError: Spec liveness gate (registry-rooted) — governed types: object, field, flow, action, hook, permission, position, agent, tool, skill, dataset, page, view, report,
    
  • Test Core (4/8) — 失败步骤: Run this shard's tests

    @objectstack/cli:test:  FAIL   unit  test/validate-build-gate-parity.test.ts > os validate is the read-only superset of os build (#3782, #4409) > the command sources use no call shape this scan cannot
      ↳ 失败原因: @objectstack/cli:test: Error: Test timed out in 5000ms.
    

↳ 失败原因 是判读的关键:超时(Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言(AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️ 断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError。 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • ⚠️ 本 PR 过去 24h 已在队列失败 1 次(不含本次)。 内容未变而反复失败 ⇒ 高度怀疑 flaky 测试或与同组 PR 的语义冲突,重排不解决。
  • 过去 24h 队列共有 13 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

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?

2 participants