Repository navigation
Commit 38050ce
docs(adr): ADR-0142 — an ownership queue is a platform primitive: queue entity, membership through the existing recipient kinds, claim through the transfer gate, declarative assignment rules on insert (#25816)
<!-- claude-projects-attribution: {"github_login":"marchtian"} -->
_Requested by **田騏** · [project
thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSiJXXjfy6utB77xp1T2Vtxpw)_
Part of #25769
Clause-②: no
Metadata-protocol: none
Docs-only, Tier H (`docs/adr/**`): the PR stays draft and the maintainer
merges it; merging accepts the record. This is phase 1 of #25769, the
shape proposal the triage grade orders first. The two declaration lines
above describe this docs-only diff (no, none; the seat corrected them
from the claim's yes/added, as the docs-only ADR precedent #25717
carried). The claim's `Clause-②: yes` / `Metadata-protocol: added`
belong to the card's spec phase (a new metadata type, two re-admitted
enum members), which lands in the one later spec + server PR on this
same card. This diff moves no published package, so it carries no
changeset and takes `skip-changeset`. #25769 remains open after this
merges.
## 维护者速读(草稿)
**改了什么**
只新增一份决策记录
`docs/adr/0142-ownership-queue-membership-claim-and-assignment-rules.md`(Proposed,Tier
H,合并即接受),不改代码、不改 spec、不带 changeset。记录提出"工单队列"这一平台原语的形状:(1) 队列实体——像
`sharing_rule` 一样两个来源,包声明的 `queue` 元数据类型 + 组织自建的 `sys_queue`
行,按机器名引用;组合里没有队列服务却声明了 `queues` / `assignmentRules` 的 stack,由引擎在
`kernel:ready` 按名拒绝启动(ADR-0093 D5 的快速失败形),不留哑元数据。(2)
成员——沿用现有的分享接收人五种(用户/团队/岗位/业务单元/单元含下级),用现有的展开器,不造新主体。(3) 记录"坐进队列"=
`owner_id` 为空 + 新增系统托管列 `queue` 记队列名;队列成员通过按请求预解析的 `current_user.queues`
看到并可编辑坐在队列里的记录,这个析取加在 plugin-sharing 构建 owner 匹配的四个位置(读过滤;写过滤的 update
分支,不进 delete 分支;`checkEdit` 作为独立的正向依据,放在公开 OWD 的 abstain 之前、不碰
`checkDelete` 共用的快速路径;explain),队列服务是 `queues` 这一个键的 RLS 成员解析器,顺带抬起平台
`created_by` 地板(访客提的单成员才改得动,公开 OWD 上也一样);访客提单在两个门都写不进 `queue`(step 3.5
访客分支 + 公共表单服务器托管字段剥离);业务对象自带 `queue` 字段的,注册期拒绝并给出处方(`INVALID_METADATA`
422 信封,与注册表现有的字段拒绝同形)。(4) 认领 = 分享服务正向放行的人把 `owner_id`
改成自己——队列依据、编辑分享、Modify All,即 hotcrm 今天的认领人集合;仅过 2.7 不算:`created_by`
地板放行的创建者、app 自写的 RLS
放宽都不能认领,解除归属仍只能靠授权撤回;委托写入保留地板,所以访客提的单委托认领不了——由现有的所有权转移门禁(step
3.5)直接放行,写入带"owner 仍为空"的前像条件(引擎新增按 id 写入前置条件 `expect: {字段:
值}`,落到驱动的条件更新,成功后按 id 回读、按 id 分支照常继续,认领与轮询游标共用),并发只有一个赢家,输家
409;所有者可放回原队列;换队列仍需 `allowTransfer`;成员插入时点名队列 = 有 owner 的记录 +
归属队列,插入戳不让位。(5) 分派规则 = 声明式元数据,条件 → 队列或用户,具名策略 `round_robin` /
`least_loaded`,只在插入时、所有 beforeInsert 钩子之后、落库之前评估一次,只对仍无 owner
的行生效;池子为空或目标队列在该组织解析不到,绝不阻断录入——记录坐进队列或留空,平台记一次日志。两个预留成员(审批人类型
`queue`、分享接收人 `queue`)只在上述运行时分支落地、lint 与测试钉住之后才重新开放可编写。
**为什么改**
你 10-25 09:23Z 的裁决把评估 D6 定为 A:队列/分派规则是平台能力。今天 hotcrm 的
`_case-assignment.ts`(587 行)自己标注"⛔ STOPGAP,该删的代码":网页提单剥掉 owner
后"无处可坐",app 只能用钩子数行挑 owner、把认领拼成状态变更来绕过转移门禁,而 hotcrm 自己的规则 1–2
禁止补平台的洞。参考应用是 AI 抄得最多的样本,现在教的是错误模式。两个 `queue` 枚举成员自 #3508
起"声明了但没执行",注释里写着"只随真正的队列实现一起重开"。ADR-0138 D1(已接受)已经把"assignment
rules"写进了场景自管 owner 的三件工具里,却没有机制。
**风险与代价(含回滚)**
代价:跨包 L 级一个
PR(spec、objectql、plugin-security、plugin-sharing、plugin-approvals、lint、dogfood):两个新元数据类型、三个新系统对象(`sys_queue`、`sys_queue_member`、`sys_assignment_cursor`)、一列新注入列、一个新预解析集合、插入管线一个新接缝、引擎一个按
id 写入前置条件(认领多一次按 id 回读)、设计器契约一种新绑定;两个新类型各要一份手写的 liveness 账本,不是可再生成的产物。每个
`ownership: 'user'` 对象多一列可能用不上(和 `owning_business_unit_id`
同价);已有应用若在这类对象上自带 `queue` 字段,升级时注册被拒,要改名。多队列含 BU 成员的租户每请求多一次 BU
树读取(待缓存)。认领的唯一所有者保证与轮询公平性只在驱动有 `updateMany`
时精确(已发布的五个驱动都有),否则退化为前像检查并记一次日志。谁拿到无主记录的编辑分享,谁就能认领它——这是分享作者声明的宽度,也是
hotcrm 今天的契约。hotcrm 三个工厂里的"升级改派"(更新时改路由)本记录不覆盖,hotcrm 暂留;hotcrm
已有的无主积压要补一次 `queue` 系统写入才进得了队列路径。回滚:记录是 Proposed,合并前零代码;合并后若 PR
未落,改状态行即可;PR
落地后回滚是删三张表、一列、一个接缝、一个前置条件和两条枚举——两个枚举成员退回今天"保留不可编写"的状态,无数据迁移。
**席位意见**
**你要做的**
看 ADR 末尾"Open questions for the maintainer's merge decision"18
条,合并即接受,反对点编号:
1. 队列记录的位置——`owner_id` 留空 + 新列 `queue`(推荐),而非 owner 指向队列或单独的队列项连接表;
2. 队列既是元数据又是数据(regime C),归 `plugin-sharing`,还是归 `plugin-security`;
3. 成员看坐在队列里的记录——按请求预解析的成员集合(推荐),而非物化分享行;
4. 分派只作用于钩子之后仍无 owner 的插入,不提供"有 owner 也走规则"的写入方选项;
5. 更新时改路由(hotcrm 升级改派)不在本次范围,hotcrm 暂留该钩子——你可以选择现在就纳入;
6. 认领就是门禁放行的 `owner_id` 更新,不另造 `claim` 动词;
7. 轮询游标用状态行 + 按 id 前置条件推进,驱动无 `updateMany`
时退化为无条件推进(近似公平,记一次日志),不退化为最少负载;
8. 队列不作为消息受众(`notify` 不加 `queue:`),hotcrm"无人可通知"的缺口仍开;
9. 五种集合型接收人都可做队列成员,BU 树重算扩展到队列,而非只允许三种扁平类型;
10. 分派接缝放在引擎里、所有 beforeInsert 钩子之后落库之前,而非一个保留优先级的平台钩子;
11. 分派规则本次只有包声明,组织自建规则延后;
12. `least_loaded` 必须声明 `load` 谓词,平台不给默认值;
13. 成员插入时点名队列 = 有 owner 的记录 +
归属队列,插入戳不让位(推荐);另一选项是成员点名队列时戳让位、记录直接坐进队列,代价是多一条无授权产生无主行的路径、插入戳规则不再对每次插入成立;
14. 谁能认领——分享服务正向放行该无主前像的任何人(队列依据 / 编辑分享 / Modify All;推荐,hotcrm
今天的契约,覆盖升级席与历史积压;2.7 问过裁决的路径不多读,委托写入或无地板策略的路径门禁自己问一次),而非只限该队列成员,也不是仅过
2.7 的任何人(那会让创建者无授权收回被撤的归属、让 app 自写的放宽按声明发认领许可);代价是编辑分享即认领许可,app
自写放宽单独不算;
15. 业务对象在 `ownership: 'user'` 上自带 `queue` 字段,注册期拒绝并给处方(推荐),而非改用后缀列名
`owner_queue` 或像 `owner_id` 那样让作者字段胜出;
16. 按 id 写入前置条件 `expect` 只在引擎内部用(门禁的认领、分派接缝的游标),数据 API
不暴露,现有的乐观并发原样保留(推荐)。数据 API 今天已有 `If-Match` / `expectedVersion`,不匹配答 409
`CONCURRENT_UPDATE`,但那是协议层先比后写、不是原子的;另一选项是把这个现有检查落到同一个前置条件上、让乐观并发变成原子,代价是在队列卡上改写数据路径(先探存在再比版本的次序、令牌规范化、冲突时按调用者上下文回读
`currentRecord` 都要围着驱动的零行结果重保);推荐项的代价是现有检查仍不原子,日后再落是增量;认领输家的 409
直接用账本现有的 `CONCURRENT_UPDATE`,同一动词不开第二个 409;
17. 组合里声明了 `queues` / `assignmentRules`
却没有队列服务,启动即拒绝(推荐),而非响亮告警后继续:这不是外观退化——规则覆盖的插入无处可坐、认领一律关闭、声明方毫不知情,ADR-0093
D5 是先例;代价是故意不装 `plugin-sharing` 的精简组合要先删键或装插件;
18. 包声明的队列成员只能是岗位,用户/团队/业务单元只能通过组织自建的 `sys_queue_member` 行加入(推荐;这是起草者从
ADR-0131 D3/D4 与 ADR-0086 D1 推出的,不是裁决);另一选项是声明队列可写五种成员、`os lint` 给
warning(D5 对声明规则 `assignTo: user` 就是这样),代价是封印的包元数据里带组织 id、换个安装就解析不到。
另附 ADR-0138 D1 一致性说明在 Verification notes;hotcrm 删码卡由席位在你合并本 PR 后立(删 D8
列出的 STOPGAP),阻塞到发布版本带上 spec + server PR。
## What changed
| File | Change |
|:---|:---|
|
`docs/adr/0142-ownership-queue-membership-claim-and-assignment-rules.md`
| New record, **Proposed** (860 lines). D1 the queue entity: a `queue`
metadata type and `sys_queue` rows under ADR-0131 D6 regime C, owned by
`plugin-sharing`, a boot refusal for `queues` / `assignmentRules`
without a queue service · D2 membership through the five set-valued
recipient kinds and the expanders that exist today, `sys_queue_member` ·
D3 a sitting record = `owner_id` empty + a system-managed `queue`
column; members reach it through a pre-resolved `current_user.queues`
set added to the `plugin-sharing` owner match (edit, never delete); the
guest refusal at step 3.5 and at the public-form door · D4 claim,
release and move through the step-3.5 transfer gate, a by-id write
precondition, engine-internal, so concurrent claims produce one owner
(loser 409 `CONCURRENT_UPDATE`; the data API's existing `If-Match` check
is untouched) · D5 declared assignment rules (CEL criteria → queue or
user, `round_robin` with a cursor row, `least_loaded` with a required
`load` predicate), evaluated once per insert after `beforeInsert` hooks,
only on rows still ownerless, never blocking intake · D6 / D7
re-admission conditions for the `queue` approver type and the `queue`
sharing recipient, with every reader that special-cases `queue` today ·
D8 the HotCRM mapping (what the platform replaces, what HotCRM deletes
after a published release, what is not covered) · D9 names and codes ·
18 merge questions |
No other ADR is edited. ADR-0138 D1 already names "assignment rules"
among the instruments a scenario uses to own a guest-written row; this
record is consistent with it and amends nothing (Verification notes).
## Measurements (objectstack at `5f5f0b42`, HotCRM
`objectstack-ai/hotcrm` at `8f52859`)
- **Premise "the gate refuses a caller-supplied owner upstream of
hooks"** — holds: step 3.5 of
`security-plugin-authz-middleware.ts#registerAuthzMiddleware` stamps an
empty `owner_id` on insert, refuses a supplied foreign owner or an owner
change without `allowTransfer`, and a `beforeInsert` hook runs after it,
so HotCRM's door ① (a hook stamping the payload) is invisible to the
gate. `transfer` is an explain/grant verb only, never an engine
operation (`DESTRUCTIVE_LIFECYCLE_VERBS`).
- **Where the owner match lives** — `plugin-sharing`'s `SharingService`
(`buildReadFilter`, `buildWriteFilter`, `checkEdit` /
`matchesOwnerScope`), not the security middleware, which stashes depth
only; the platform ownership floor reads `created_by`
(`OWNERSHIP_FLOOR_PREDICATE`). The record places the queue access basis
there.
- **The two reserved members** —
`approval-service.ts#resolveApproverSpec` has no queue branch and
returns the dead `queue:VALUE` literal (`approver-address.ts` holds
slot-address readers only and no queue handling);
`sharing-rule-service.ts#expandRecipient` returns `[]` for `queue`.
Every reader that special-cases `queue` (spec, contracts, lint, the
semantic migration entry, the recompute modules, the dogfood matrix, the
select options on `sys_sharing_rule`) is listed in D6 / D7.
- **The pull** — HotCRM's `_case-assignment.ts` (587 lines) holds three
factories: intake least-loaded assignment, the escalation hand-off on a
status transition, and a self-claim spelled as a status move.
`lead_auto_assign` assigns leads; `lead-assignment.flow.ts` only alerts
the owner the hook chose. The record replaces intake assignment for
cases and leads, the self-claim and the triage sharing rule; the
escalation hand-off (an update-time re-route) is not covered and stays
in HotCRM (merge question 5).
- **ADR number** — 0141 is the newest landed record; 0136 and 0140 are
numbers of proposals that never landed; no open PR carries 0142.
## Review record
Drafted on the contract-review tier at effort xhigh, as the owner's
order requires for `docs/adr/**`. An independent full review by a
separate agent on the same tier, fed the record, the rulings and the
code and not the drafter's notes, ran seven rounds. Before the seat's
contract review: round 1 at `a36d060` FAIL (7 must-fix, 9 should-fix, 5
nits), applied in `19d7532`; round 2 FAIL (2 must-fix: the queue access
basis conferred delete through a shared fast path, and the claimant rule
missed the creator-floor arm), applied in `3c6be23`; round 3 PASS WITH
FIXES (1 should-fix, 1 nit), applied in `a2e71ca`; round 4 at `a2e71ca`
PASS (comments 6399544349 and 6399553138). `0ae85b0` merges `main` (no
conflict; the record is unchanged by it). The seat's contract review at
`0ae85b0` (PASS, comment 6399715745) asked for three more merge
questions and two nits, applied in `e08c90e`. Delta reviews of that
round: round 5 FAIL (1 must-fix: merge question 16 weighed
engine-internal `expect:` against a data API it described as having no
optimistic concurrency, but `If-Match` / `expectedVersion` with a 409
`CONCURRENT_UPDATE` already exists; 1 should-fix: reuse that code for
the lost claim; 1 nit), applied in `e765768`; round 6 PASS WITH FIXES (1
nit), applied in `05ae041a`; round 7 at `05ae041a` PASS, no finding
(comment 6400014400).
## Gates
Derived with `node scripts/pm/dispatch-gates.mjs --commands` (change set
vs merge base `52f3f518e`, 1 file, +860 / −0), all 20 run at head
`05ae041a`, every one `exit 0`: `check-adr-links` (+ `--self-test`),
`check-adr-symbol-anchors` (+ `--self-test`), `check-ci-filter-parity`,
`check-closing-keyword-parity` (+ `--self-test`),
`check-comment-mask-corpus`, `@objectstack/lint
check:doc-formula-expressions`, `check:adr-anchors`,
`check:closed-ceremony-text`, `check:cross-package-test-inputs`,
`check:doc-authoring`, `check:driver-memory-census`,
`check:gitlink-declared`, `check:nul-bytes`, `check:pm-governed-merges`,
`check:pm-prior-rulings`, `check:refd-timer-probe`,
`check:watch-hint-literal`. `check:doc-formula-expressions` reads the
built lint and formula packages. Before they were built with `turbo run
build` in this worktree, it exited 3 (PREREQUISITE NOT MET).
The derivation at `05ae041a` warned that the tree was 3 commits behind
`origin/main` `abebc4e1`. So the 20 were derived again and run again on
a local merge of `05ae041a` with `abebc4e1`, which was not pushed. The
result: the same 20 commands, every one `exit 0`, and no stale-tree
warning.
`dispatch-gates --ran`: 20 derived, 20 run, 0 NOT-MEASURED, 0 UNRUN, on
both runs.
No changeset: `docs/adr/**` publishes nothing (`skip-changeset`).
## Acceptance notes
- Post-merge item: the seat files the HotCRM STOPGAP-delete card after
the maintainer merges this PR (the seat's choice B, 2026-10-25). That
card deletes what D8 and the Execution plan name, and it stays blocked
until a published release carries the spec + server PR. It is not filed
this shift.
- HotCRM behaviours the record does not cover, each carried as a merge
question rather than dropped: the escalation hand-off on a status
transition (5) and a queue as a messaging audience for ownerless intake
(8).
- The whole-project assessment artifact the card cites for D6 was not
readable from this session; the ruling is quoted from the card body.
---
_Generated by [Claude
Code](https://claude.ai/code/session_017BFhbTnbbHcXkSnMss2btn)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 1ee1d1b commit 38050ce
1 file changed
Lines changed: 860 additions & 0 deletions
0 commit comments