Skip to content

Commit 38050ce

Browse files
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

File tree

0 commit comments

Comments
 (0)