Skip to content

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

Merged
os-zhuang merged 8 commits into
mainfrom
claude/project-thread-eypatm
Oct 26, 2026

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Requested by 田騏 · project thread
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

…ue entity, membership, claim through the transfer gate, declarative assignment rules (Proposed)

Phase 1 of the queue card: the record only. Spec and server follow after
the maintainer accepts the shape.

Claude-Session: https://claude.ai/code/session_017BFhbTnbbHcXkSnMss2btn
Co-authored-by: Claude <noreply@anthropic.com>
The owner match and ownership floor live in plugin-sharing; the guest
refusal also holds at the public-form door; the claim's one-owner write
names its engine precondition; round-robin's degraded path and the
unresolved-target path are defined; three new forks (member insert with a
queue, who may claim, a declared queue column) join the merge questions.

Claude-Session: https://claude.ai/code/session_017BFhbTnbbHcXkSnMss2btn
Co-authored-by: Claude <noreply@anthropic.com>
The queue access basis confers edit and never delete; a claim needs the
sharing service's positive verdict, so the creator floor and an app
widener license none; the precondition write re-reads its row; the boot
and registration refusals name their owner and code; the plan lists the
hand-authored liveness rows and the added tests.

Claude-Session: https://claude.ai/code/session_017BFhbTnbbHcXkSnMss2btn
Co-authored-by: Claude <noreply@anthropic.com>
D8's claim rows and the refusal string follow D4's positive-basis rule;
D4 says where the gate reads the sharing verdict and where it asks one.

Claude-Session: https://claude.ai/code/session_017BFhbTnbbHcXkSnMss2btn
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet objectstack-fleet Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Oct 25, 2026
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation labels Oct 25, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Independent full review — ADR-0142 (rounds 1 and 2 of 4)

An independent full review of the whole record, on the contract-review tier at effort xhigh, by a reviewer agent separate from the drafter (fed the record, the rulings and the code at 5f5f0b42 plus HotCRM at 8f52859, never the drafter's notes), per the owner's order that spec, skills and ADR changes owe one before the seat's ACCEPT. One full review of the first substantive head, then delta reviews by the same reviewer of the commits that only apply findings, stopping at the first round with no must-fix.

Round Head Verdict must-fix / should-fix / nit Applied in
1 (full) a36d060 FAIL 7 / 9 / 5 19d7532
2 (delta) 19d7532 FAIL 2 / 7 / 4 3c6be23
3 (delta) 3c6be23 PASS WITH FIXES 0 / 1 / 1 a2e71ca
4 (final delta) a2e71ca PASS 0 / 0 / 0 —

0ae85b0, the PR head, merges main onto a2e71ca and leaves the record byte-identical.

Round 1 — full review of a36d060

Verdict: FAIL

Reviewed: docs/adr/0142-ownership-queue-membership-claim-and-assignment-rules.md at a36d060 (branch claude/project-thread-eypatm, committed; working tree clean; base origin/main 5f5f0b4221d9; no other docs/adr/** file touched). HotCRM read at 8f52859.

Summary. The shape is the right one and most of Context §1 is accurate against the code: step 3.5's arms, the hook-after-middleware ordering, OWNER_FIELD_DEF, the ownership tier table, the two reserved members and every comment the record quotes about them, the five expanders, the regime-C precedent, updateMany's optionality, and HotCRM's three factories, view, rules and tests are all as described. The record fails on seven points that are not polish: it places the access path in the wrong package and omits the one gate (the created_by ownership floor) a queue member's write must pass; it leaves the public-form door open to a supplied queue; it contradicts itself on a member's insert with a queue, on a rule whose queue does not resolve, and on round-robin's degradation; it promises claim atomicity by a primitive the engine does not have and names no seam; and it silently narrows the first consumer's claimant set and strands its existing ownerless rows. Each is fixable in one round, and the fix is named below.

Gates (run from the worktree root):

  • node scripts/check-adr-symbol-anchors.mjs — EXIT=0 (2627 anchors across 144 records resolve; the 47 hotcrm: anchors are reported as cross-repo and not judged — I verified each of them by hand against 8f52859: all resolve).
  • node scripts/check-adr-links.mjs — EXIT=0 (807 relative links resolve).
  • House rules: no HTML comments; no model names; no path:NNN anchors; codes SCREAMING_SNAKE; proposed refusal strings carry no tracker number; type names singular, machine names snake_case, stack keys camelCase like sharingRules; the three Chinese rulings match their sources character for character (.claude/skills/pm-dispatch/references/cross-repo-coordination.md for D5, ADR-0138 Alternatives for the D1 quote, the brief's text for the D6 ruling).

F1 — must-fix (wrong fact + mechanism gap). The OWD owner gates are not in the security middleware, and the platform's ownership floor does not read owner_id.

Evidence. Context §1 says the readers of owner_id include "the OWD / RLS owner gates in the same middleware", D1 says plugin-security "consumes the contract through the kernel, as its middleware already consults the sharing service to widen an owner match", D3 injects the queue disjunct "beside the owner match in the read filter and the write gate", and the execution plan puts "the access path in plugin-security". In the code the middleware only stashes a depth string (packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts#registerAuthzMiddleware step 2.6: sc.__readScope = … / sc.__writeScope = …, "so the sharing service can widen the owner-match"). The owner match itself is built in plugin-sharing: packages/plugins/plugin-sharing/src/sharing-service.ts#buildReadFilter (ownerMatch = { owner_id: … } OR the caller's sys_record_share ids, reading __readScope), #buildWriteFilter, and #checkEdit, whose ownership fast path #matchesOwnerScope "Returns false when the record has no owner value" — a sitting record is refused there by construction until that method learns the queue disjunct. Separately, the by-id write path carries the platform's ownership floor owner_only_writes / owner_only_deletes, using: created_by == current_user.id (packages/plugins/plugin-security/src/platform-ownership-policies.ts#OWNERSHIP_FLOOR_PREDICATE; the header calls it "a second implementation of ownership"), and the floor is dropped only when the sharing write verdict is allow (packages/plugins/plugin-security/src/security-plugin-sharing-verdicts.ts#resolvePreImageFloorDrop). So the sentence "Every reader of ownership reads owner_id" is false for the floor, and a queue member claiming a guest-written case (created_by NULL) is refused at step 2.7 unless checkEdit says allow — which it only will if the disjunct lands in plugin-sharing, not in the middleware.

Fix. (a) Correct Context §1: the OWD owner match lives in plugin-sharing's SharingService; the middleware stashes depth; the platform floor reads created_by and is lifted by the sharing verdict. (b) In D3 name the four sites the disjunct lands in: #buildReadFilter's ownerMatch, #buildWriteFilter, #checkEdit via #matchesOwnerScope (which then also lifts the floor through #resolvePreImageFloorDrop with no change there), and the explain engine. (c) Name where current_user.queues is assembled — beside the other pre-resolved sets in packages/core/src/security/assemble-execution-context.ts (rlsMembership), or say the middleware stashes it as it stashes __readScope. (d) Move "the access path" from plugin-security to plugin-sharing in execution plan step 1, which also removes the cross-plugin dependency merge question 2 worries about.

F2 — must-fix (broken mechanism; a hole). The guest refusal for queue is placed at step 3.5, which the public-form door never reaches.

Evidence. D3: "a guest never supplies it (ADR-0138 D1's branch, extended to the second anchor)". The publicFormGrant branch of the same middleware says "The grant bypasses every downstream write gate (FLS 2.5, owner anchor 3.5, tenant CHECK) — so the system-managed anchors must be forced HERE" and strips exactly packages/spec/src/security/public-form.ts#PUBLIC_FORM_SERVER_MANAGED_FIELDS (id, owner_id, owning_business_unit_id, organization_id, tenant_id, …), with the comment "this is the data-layer boundary that holds even if a FormView declares … one of these columns". ADR-0138 D1's "Enforced by" names both halves (the step-3.5 guest branch for endpoint doors, the publicFormGrant strip for form doors). The record names one. HotCRM's web-to-case is the form door; without queue on that list a submission could park itself in any queue.

Fix. D3 states both halves; execution plan step 1 adds queue to PUBLIC_FORM_SERVER_MANAGED_FIELDS (the list already pre-registers owning_business_unit_id "BEFORE it exists" for exactly this reason).

F3 — must-fix (hidden fork; the record says two incompatible things). A member's insert with a supplied queue cannot produce a sitting record under the record's own rules.

Evidence. D3: "on insert, a supplied queue is admitted when the caller holds the transfer grant or is a member of that queue (an agent logging a case straight into the intake queue)" and "owner_id and queue may be supplied together only under the transfer grant". Step 3.5's INSERT arm stamps an empty owner_id with the acting user (if (userId) row.owner_id = userId). So the agent's case lands with owner_id = agent and queue = intake — by D3's own meaning an owned record with a home queue, not a record sitting in the queue; and the agent cannot send owner_id: null beside queue without the grant. The parenthetical implies the opposite. D5 then never evaluates a rule for it (owner set).

Fix. Decide and say: either (a) the stamp is suppressed when a member supplies queue and no owner, so the record sits (a change to the stamp rule that must be stated as such, with the explain reason), or (b) a member's insert with queue is an owned record with home queue Q (today's behaviour for an agent-created case in HotCRM) and the parenthetical goes. This is a fork the maintainer should see; add it to the merge questions.

F4 — must-fix (contradiction). A rule whose queue does not resolve in the row's organization is both a 422 and a never-refused insert.

Evidence. D9: "QUEUE_NOT_FOUND 422, for a write or a rule naming a queue that resolves nowhere in the row's organization". D1: "A name that resolves nowhere fails closed for that reference". D5: "Intake is never refused by assignment: that is HotCRM's contract" and "A read that fails during evaluation leaves the row as it was before evaluation … and logs at warn". An authored sys_queue that exists in one organization of several is the realistic case (HotCRM's system import, the Context §2 "organization pin" row): the rule's target resolves in one organization and nowhere in the next.

Fix. Split the two: a declared rule's declared-queue target is validated at load (fail loud, as D5 already does for a user id); at insert, a target that resolves nowhere in the row's organization is the D5 degradation (ownerless, queue unset, one warn naming the queue and the organization); QUEUE_NOT_FOUND 422 stays for a caller-supplied queue on a write. State it in D5 and D9 in the same words.

F5 — must-fix (broken mechanism). Round-robin's degradation to least-loaded has no load predicate to count by.

Evidence. D5's shape: "load? … REQUIRED with 'least_loaded', refused otherwise". D5's round-robin bullet: "Where the driver implements no updateMany, round-robin degrades to least-loaded for that write and logs once at warn". A round_robin entry has no load by the record's own rule, so the degraded path has nothing to count — the exact "rule that silently counts everything" D5 refuses two paragraphs earlier. Merge question 7 and the Consequences repeat the degradation as if it were defined.

Fix. Degrade to park (queue = Q, owner_id empty, one warn), or to the next member by ordering position without a cursor (fair under no concurrency, approximate otherwise) — and say which in D5, merge question 7 and Consequences.

F6 — must-fix (broken mechanism; no seam named). The claim's one-owner guarantee rests on a conditional by-id write the engine does not have.

Evidence. D4: "The write itself is conditional on the pre-image — 'where owner_id is still empty' — so two members claiming at once produce exactly one owner; the loser's update matches no row and answers 409 RESOURCE_CONFLICT". The admission is adjudicated in the middleware; the write is the engine's by-id update. packages/objectql/src/engine.ts has no precondition / expected-version option on a by-id update (grep for precondition, expectedVersion, ifMatch: none on the write path); the only predicate write is the driver's optional updateMany (packages/spec/src/contracts/data-driver.ts). The record's Context §1 notes the primitive exists but D4 never says who issues it: the middleware cannot rewrite the operation into a predicate update without changing hook dispatch (dispatch.mode becomes per-row) and without colliding with the same step's rule that "a bulk change-set carrying owner_id fails closed"; and nothing today turns "0 rows affected" on an update by id into a 409 rather than a success envelope.

Fix. Name the seam: an engine-level precondition on the by-id update ({ expect: { owner_id: null } } or similar) that the gate sets and the engine lowers to the driver's conditional write, mapping 0 affected rows to RESOURCE_CONFLICT; state that sys_assignment_cursor's version check uses the same primitive; and state what happens on a driver without updateMany (the record covers that for the cursor, not for the claim).

F7 — must-fix (silent drop of consumer behaviour; hidden fork). The self-claim's claimant set narrows, and HotCRM's existing ownerless backlog becomes unreachable.

Evidence. HotCRM's createCaseSelfClaim stamps the caller for "A write carrying a real user id … whoever they are" with edit reach — it names the admin and "the manager holding the escalation share" explicitly, and its guard 4 lets a person claim a RESOLVED unowned case by reopening it. HotCRM's service_agent set holds no crm_case.allowTransfer (src/sales/profiles/service-agent.profile.ts: "the agent holds no transfer grant on crm_case"); system_admin does. Under D4 the admission requires membership of the queue the pre-image's queue resolves to; a service_manager holder reaching an unowned critical case through case_escalation_sharing can claim it today and cannot under the record unless a member of the intake queue or a grant holder; reopening a resolved unowned case by claim has no spelling. D8's row for case_self_claim says only "the gate's claim admission". And every ownerless row HotCRM already holds (the triage tab is "the intake backlog") has owner_id NULL and no queue: the admission requires "its queue resolves to Q", so those rows cannot be claimed without the grant, and the access path (queue IN current_user.queues) does not show them to agents — the state HotCRM filed #1096 to end.

Fix. D8 names the narrowing as a behaviour change HotCRM accepts (and whether the escalation desk needs its own queue or a grant), names "reopen-by-claim of a resolved unowned case" as dropped, and the execution plan or the HotCRM card names the backfill (queue = intake where owner_id IS NULL on the objects a rule targets) or says legacy rows need the transfer grant. If the backfill is HotCRM's, say it is a system write of queue (D3 admits it as the platform).


F8 — should-fix. A reader that special-cases queue is missing from D6's re-admission list.

Evidence. packages/lint/src/validate-approval-approvers.ts: the APPROVAL_APPROVER_TYPE_UNSUPPORTED finding keys on APPROVER_VALUE_BINDINGS[...].source === 'unsupported' with the hint "Queue approvers need a real ownership-queue implementation before they take effect."; GROUP_ROUTED_TYPES is position, team, department and its header excludes "the opaque queue" from approval-approvers-may-resolve-empty. Once a queue is a group whose membership is runtime data, it belongs in GROUP_ROUTED_TYPES (or the node's empty-slate lint is blind to a queue-only step), and the hint is dead text. D5 cites that very lint as the model for the assignment rule's authoring-time check, so the omission is in the record's own frame.

Fix. D6 gains item: validate-approval-approvers.ts adds queue to GROUP_ROUTED_TYPES and retires the unsupported hint; the APPROVAL_APPROVER_TYPE_UNSUPPORTED arm stops firing by construction once the binding changes.

F9 — should-fix. D6 item 2 contradicts itself on the empty queue.

Evidence. "an empty expansion takes the path the graph types already take (the 'expanded to nobody' warning, then the node's onEmptyApprovers)" and, in the same item, "The queue:ID literal slot is never written again". The graph path (packages/plugins/plugin-approvals/src/approval-service.ts#resolveApproverSpec, types in GRAPH_APPROVER_TYPES: team, department, business_unit, bu, position, org_membership_level) warns and then return [${type}:${a.value}] — it writes the literal slot. The return [] path is PERSON_APPROVER_TYPES (manager, field).

Fix. "warn as the graph types do, then return [] as the person types do" — a new combination, stated as such.

F10 — should-fix. D5's organization pin names the wrong deriver for most writes.

Evidence. D5: "pinned to the row's organization as resolveSystemInsertOrganization derives it (ADR-0131 D9)". packages/objectql/src/engine.ts#resolveSystemInsertOrganization returns undefined when the context carries tenantId or every row names its organization; it derives only for an organization-less system write. The guest door's organization is the door's binding (ADR-0138 D4 item 4); a system import naming organization_id per row carries it on the row. Context §1 states this correctly; D5 does not.

Fix. "the row's organization — the context's tenantId, the row's own column, or the one resolveSystemInsertOrganization derives when neither names one".

F11 — should-fix. Context §3 contradicts D8 and the execution plan on what HotCRM deletes.

Evidence. Context §3: "HotCRM deletes 587 lines plus a hook, a sharing rule and four tests". 587 lines is the whole of _case-assignment.ts, which D8 and execution plan step 2 keep one third of (createCaseEscalationReassign). Of the four pins Context §2 names, test/case-assignment.test.ts and test/flow-sla-ownerless-assignment.test.ts drive the escalation hand-off that stays, and test/ownership-model.test.ts pins the transfer-grant roster and door ① for lead_auto_assign, most of which stays. References says "the five tests named in Context §2".

Fix. "two of the three factories, lead_auto_assign, the triage rule, claim-case-one-owner-writer.test.ts, and the covered cases of the other pins".

F12 — should-fix. Least-loaded under concurrent or batch inserts is not walked.

Evidence. D5 gives round-robin a cursor and a retry; for least_loaded it says nothing about two inserts (or N rows of one batch) that each count before any is written and all choose the same member. HotCRM's hook has the same property, so this is not a regression, but the brief's walk asks for it and the record claims "the race is settled by the write" only for the claim.

Fix. One sentence in D5: least-loaded is approximate under concurrency and within a batch (as HotCRM's is today), or the batch adjusts counts locally per row.

F13 — should-fix (hidden fork). The injected column is named with a plain business word and the collision rule is unstated.

Evidence. packages/objectql/src/registry.ts#applySystemFields injects owner_id only when the schema declares none (if (wantOwner && !schema.fields?.owner_id)). D3 injects queue "on exactly the objects it injects owner_id on". queue already names a column (packages/platform-objects/src/audit/sys-job-queue.object.ts) and config keys (packages/spec/src/system/worker.zod.ts) — on ineligible objects today, but an app's ownership: 'user' object with its own queue picklist is ordinary business modelling. Skipping injection would leave the anchor silently absent (ADR-0078); overriding would break the author's field.

Fix. D9 states the rule — recommend refusing at registration with a prescription, as HotCRM's rule 10 does for a declared organization_id — or names the column with a suffix (queue_name) and says why not.

F14 — should-fix (ADR-0078). A stack that declares queues / assignmentRules in a composition with no queue service is silently inert.

Evidence. D1: "when no queue service is registered, no queue exists, and every queue-dependent admission below fails closed". That covers the gate; nothing covers a declared assignment_rule whose evaluator never runs, or a declared queue nothing resolves — a Zod-valid instance that does nothing, the state ADR-0078 forbids. Also unstated: the sys_metadata_activation types queue and assignment_rule (its type column lists the registry types in its description; plugin-sharing spells SHARING_RULE_ACTIVATION_TYPE for its own) and the lint completeness row.

Fix. D1 or D5: a stack declaring either key in a composition that registers no queue service is refused at boot (or warned once, loudly, by name); execution plan step 1 names the two activation types.

F15 — should-fix. A member losing membership while holding records is not stated.

Evidence. The brief's walk; D3 says a set queue on an owned record "confers no access", which implies the answer, but D4's release admits "the pre-image's owner_id is the caller and queue is unchanged" without saying whether a non-member may release to a queue they no longer belong to.

Fix. One sentence: ownership is not membership — the owner keeps the record; release back to the home queue is admitted for the owner whether or not still a member (recommended, since the record then becomes visible to the queue's members), or refused — pick one.

F16 — should-fix. D7's wording and reader list.

Evidence. D7 item 2: "the runtime union is then 'the authorable enum plus field's per-record arm'" — field is already in ShareRecipientType; after re-admission the two lists are the same seven members, and packages/spec/src/contracts/sharing-service.test.ts (which today pins "the stored-row union is exactly the authoring enum plus the reserved queue", with an @ts-expect-error on queue) flips to equality. Two readers D7 does not list: packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts#SysSharingRule — recipient_type is a Field.select whose options comment says "queue was removed" and which field-recipient.test.ts holds equal to ShareRecipientType; and the SharingLevel comment in packages/spec/src/security/sharing.zod.ts ("the same reason ShareRecipientType below dropped queue / guest").

Fix. Rephrase item 2 as equality; add the select options and the two comments to item 5.


N1 — nit. Context §1: "transfer is a member … of the grant bits (packages/core/src/security/effective-object-permissions.ts#GRANT_BITS)" — the bit is allowTransfer; transfer is the verb checkObjectPermission maps onto it.

N2 — nit. D1: "a registry entry beside position and sharing_rule, allowRuntimeCreate: false" — position's entry in packages/spec/src/kernel/metadata-plugin.zod.ts#DEFAULT_METADATA_TYPE_REGISTRY is allowRuntimeCreate: true; only sharing_rule is the model.

N3 — nit. Context §1 cites "ADR-0052 §5" for the polymorphic pointer shape; §5 of ADR-0052 is the ActivityPointer model (a structured pointer on sys_activity); the referenceVia key itself was ruled on objectui#12489 (6203737086), per packages/spec/src/data/field.zod.ts. Cite both or the field schema alone.

N4 — nit. Verification notes refer to "the brief's citation" and to "the repository's 14 open pull requests at drafting time"; a PR reader cannot see the brief and the count is ephemeral. Cite the card comment, drop the count.

N5 — nit. D4's admission says "the write is single-record (single; …)" — single is a local in the middleware (const single = !Array.isArray(opCtx.data)), not a named symbol; spell the condition out.


What I checked and found correct (so the fix round need not re-verify): step 3.5's INSERT/UPDATE/guest/delegation/referential-clear arms and its "runs inside the engine's operation, after this middleware" sentence; permission.zod.ts#ObjectPermissionSchema allowTransfer text; explain.zod.ts#ExplainOperationSchema; engine-middleware-operation-vocabulary.test.ts#DESTRUCTIVE_LIFECYCLE_VERBS (purge, transfer, restore); the explain engine's transfer-as-update deferral; OWNER_FIELD_DEF; the applySystemFields tier table; ObjectAccessScopeSchema; recipient-resolver.ts#DEFAULT_OWNER_FIELDS and the user:/role:/team:/owner_of: grammar; claimSeedOwnership's NULL arm since #23354; the insert order (middleware → applyFieldDefaults → beforeInsert per row → resolveSystemInsertOrganization → write → afterInsert); HookContextSchema fields and HookSchema.runAs; every queue item in approval.zod.ts, approval-service.ts#resolveApproverSpec, sharing.zod.ts, contracts/sharing-service.ts, sharing-rule-service.ts#expandRecipient / #warnOnEmptyUnitExpansion, bu-tree-recompute.ts#BU_TREE_RECIPIENT_TYPES, recipient-membership-recompute.ts#RECIPIENT_MEMBERSHIP_SOURCES, validate-org-axis-red-lines.ts, migration 17, the dogfood sharing-rules row and access-level.ts; PositionGraphService / TeamGraphService / BusinessUnitGraphService and expandPositionUsers's "deliberately NOT identical"; the four membership objects; sharing_rule's registry entry; declared-sharing-rules.ts, SysSharingRule, compileCelToFilter, PositionSchema; updateMany across the five drivers (SqliteWasmDriver extends SqlDriver); PermissionDeniedError 403; RESOURCE_CONFLICT in the standard catalog; the job-queue capability and sys_job_queue; ADR numbering (0136, 0140 absent, 0141 newest) and the 0019 collision; and in HotCRM: the three factories' triggers, priorities, runAs, pools, load predicates, organization pin, guard order, CLAIMABLE_TARGET_STATUSES, the triage view's filters, the four sharing rules, the claim flow and action, lead_auto_assign, the lead_assignment header, setup.mdx, AGENTS.md rules 1–2, 9, 10, the SLA sweep's status: 'escalated' transition, and the five tests.

Consistency with accepted records: no silent contradiction found of ADR-0049, 0056 D3, 0057 D1/D3/D5, 0058 D1–D3, 0073 D2/D3, 0077, 0078 (except F14's gap), 0086 D1, 0087 D2, 0090 D3/D8/D10, 0105 D9, 0111 D3/D7, 0112 D1/D3, 0117 D1/D4, 0118 D2/D3, 0131 D1–D4/D6/D9 or 0138 D1/D4. The quoted D1 text of ADR-0138 is verbatim. The regime-C characterisation matches the 2026-10-19 note in ADR-0131 D6.

Round 2 — delta review of 19d7532

Verdict: FAIL

Reviewed: docs/adr/0142-ownership-queue-membership-claim-and-assignment-rules.md at 19d7532 (branch claude/project-thread-eypatm, committed; working tree clean; delta from a36d060 is 209 insertions / 108 deletions in the one file). Code read at origin/main 5f5f0b4221d9; HotCRM at 8f52859.

Summary. Every round-1 finding is closed or honestly carried (closure table below), and the rewritten Context §1 is now accurate against plugin-sharing and the created_by floor to the sentence. The delta fails on two points that the new mechanisms introduced rather than inherited, both one-paragraph fixes: the named landing site for the access path (matchesOwnerScope) is the fast path shared with checkDelete, so the disjunct as placed confers the delete the same paragraph says is never conferred; and the new claimant rule's "nobody else does" misses two arms the two by-id write gates actually admit, one of which lets a disowned record's creator re-own it without the transfer grant. Seven should-fix items follow, mostly places where a new mechanism needs its owner or its missing artefact named.

Gates (worktree root, at 19d7532):

  • node scripts/check-adr-symbol-anchors.mjs — EXIT=0 (2648 anchors across 144 records resolve; the record's 14 hotcrm: anchors are reported as cross-repo, not judged; I re-checked the ones the delta touched against 8f52859: all resolve). The new in-repo anchors (rls-membership-resolver.ts#IRlsMembershipResolver, platform-ownership-policies.ts#OWNERSHIP_FLOOR_PREDICATE, security-plugin-sharing-verdicts.ts#resolvePreImageFloorDrop, the four sharing-service.ts symbols, public-form.ts#PUBLIC_FORM_SERVER_MANAGED_FIELDS) all resolve to the symbols the text describes.
  • node scripts/check-adr-links.mjs — EXIT=0 (807 relative links resolve).
  • House rules: no HTML comments, no model names, no path:NNN anchors, codes SCREAMING_SNAKE, proposed runtime strings carry no tracker number, Chinese rulings unchanged from a36d060.

Closure of round 1

  • F1 (owner match in plugin-sharing; created_by floor) — closed. Context §1, TL;DR 2, D1 placement, D3 "Who sees it" and execution plan step 1 all moved; the text now matches buildReadFilter / buildWriteFilter / checkEdit / matchesOwnerScope, step 2.6's stash, OWNERSHIP_FLOOR_PREDICATE and resolvePreImageFloorDrop as they stand. See R2-F1 for the one site it names wrongly.
  • F2 (public-form door) — closed. D3 names both halves; plan step 1 adds queue to PUBLIC_FORM_SERVER_MANAGED_FIELDS.
  • F3 (member insert with queue) — closed. D3 states the stamp is unchanged and the outcome is an owned record with a home queue; merge question 13 carries the fork. See R2-F8 on that question's cost line.
  • F4 (rule target vs QUEUE_NOT_FOUND) — closed. D1, D5 and D9 now say the same thing in the same words.
  • F5 (round-robin degradation with no load) — closed. Unconditional advance, one warn; D5, merge question 7 and Consequences agree.
  • F6 (claim atomicity seam) — closed as a design: a by-id write precondition recorded by the gate and lowered to the driver's conditional updateMany, zero rows → 409, with the degradation named. See R2-F3 for the readback the lowering still owes the by-id contract.
  • F7 (self-claim narrowing; ownerless backlog) — closed. D4 keeps HotCRM's claimant set, D8 gains the backlog row (system backfill under the grant), the reopen-by-claim note and the view-filter difference; plan step 2 names the backfill; merge question 14 carries the narrower reading. See R2-F2 for the precise set.
  • F8 (validate-approval-approvers.ts) — closed. D6 item 4.
  • F9 (D6 item 2 graph vs person path) — closed; stated as the new combination it is.
  • F10 (organization pin) — closed.
  • F11 (Context §3 "587 lines, four tests") — closed.
  • F12 (least-loaded under concurrency) — closed.
  • F13 (queue column collision) — closed. Registration refusal with a prescription; merge question 15. See R2-F4 on the refusal's code.
  • F14 (inert declared keys; activation types; per-type completeness row) — closed as applied: boot refusal by name in D1, both activation types named. The author's reason for not applying the "lint completeness row" is verified: there is no per-type completeness table under packages/lint/src (validate-functional-completeness.ts is a walk over @objectstack/spec/kernel's FUNCTIONAL_COMPLETENESS_RULES, a per-rule list; lint-liveness-properties.ts reads ledgers and holds no table). The per-type artefact that does exist is elsewhere — see R2-F6.
  • F15 (owner no longer a member) — closed. D4 release.
  • F16 (D7 wording; recipient_type options; SharingLevel comment) — closed; field-recipient.test.ts's "superset" is quoted correctly.
  • N1 (GRANT_BITS is allowTransfer) — closed. N2 (position allows runtime create) — closed. N3 (ADR-0052 §5) — closed by dropping the citation. N4 (the brief; 14 PRs) — closed. N5 (single) — closed.

R2-F1 — must-fix (mechanism contradicts the same paragraph and ADR-0111 D3). The site D3 names for the access path confers delete.

Evidence. D3: the disjunct lands "in checkEdit through matchesOwnerScope", and three sentences later "delete is never conferred". packages/plugins/plugin-sharing/src/sharing-service.ts#matchesOwnerScope is documented as "The shared ownership fast-path behind both canEdit and canDelete", and #checkDelete calls it first — "Ownership / write DEPTH only — no share branch. This is the whole difference from checkEdit" — before hasModifyAllBypass. A disjunct inside matchesOwnerScope makes checkDelete answer allow for a queue member on a sitting record, so canDelete passes the by-id delete in sharing-plugin.ts#buildSharingMiddleware (which "picks the gate by op") and the floor is dropped at 2.7 for delete too (resolvePreImageFloorDrop is verb-agnostic). The same applies to #buildWriteFilter, which serves delete({multi:true}) / deleteMany as well as predicate updates and gates the share widening on verb === 'delete' ("the shared record ids are NOT OR-ed in").

Fix. Land the disjunct in checkEdit as a third positive basis — after the matchesOwnerScope fast path and the edit-share lookup, before hasModifyAllBypass, exactly where a share widens update and not delete — and in buildWriteFilter inside the verb === 'update' arm beside the share ids; leave matchesOwnerScope, checkDelete and the delete arm of buildWriteFilter untouched. Rewrite D3's sentence and the "four sites" list accordingly (the four become buildReadFilter's ownerMatch, buildWriteFilter's update arm, checkEdit's share tier, the explain engine), and say in one clause why: matchesOwnerScope is the fast path checkDelete shares.

R2-F2 — must-fix (wrong fact about the admission; a widening the gate's own rules forbid). "Nobody else" can edit an unowned row is false on two counts, and one of them is a claim leak.

Evidence. D4: "Who may edit the row was decided one step earlier, at the 2.7 pre-image gate: a queue member reaches a sitting record through D3's disjunct, a holder of an edit share or of modifyAllRecords reaches it as today, and nobody else does — so membership is not re-tested here". The by-id write reach is two gates, not one: sharing-plugin.ts#buildSharingMiddleware refuses on canEdit === false (the deny verdict; step 2.7's own comment says "The refusal itself belongs to the sharing middleware that produced the verdict"), and step 2.7 composes the floor and app policies. Two further arms admit:
(a) The created_by floor itself. Where checkEdit answers abstain — "a public object, an object with no owner_id field, a bypass-listed internal" (#checkEdit's docblock; the model === 'public' return comes before the owner fast path) — or where the sharing service is mounted with enforce: false (sharing-plugin.ts: "Disable enforcement (read filter + canEdit) while still registering"), the sharing middleware does not refuse and 2.7 keeps the floor, so the row's creator passes created_by == current_user.id. Under the new admission (pre-image owner empty, self scalar, by id) the creator of a record an admin disowned under the transfer grant re-owns it with no grant — on a public-write OWD object today that is a 403 at step 3.5. That inverts the sentence in the same section, "Releasing a record that has no home queue stays what it is today, a disown that needs the grant", because the disown is now reversible by the creator alone.
(b) An app-authored update widener. buildSharingMiddleware's by-id refusal is retracted when "an APP-AUTHORED RLS widener admits this row by declaration" (#5493: "admit does NOT authorize the write: it retracts THIS authority's refusal and hands the row to the security pre-image gate"). A policy such as status == "new" on update therefore makes every unowned new record claimable by whoever the policy names, with no share and no membership. That may be intended (it is HotCRM's "whoever they are"), but it is not in D4's list and not in merge question 14's cost line.

Fix. (1) State the reach correctly: "a by-id update is admitted by the sharing middleware's canEdit and by step 2.7's composed filter; the claim rides on that composite — a queue member through D3's disjunct, an edit share, modifyAllRecords, an app-authored update widener, and, where sharing abstains, the row's creator through the created_by floor". (2) Decide the creator arm and say so: either require a positive basis for the claim (the admission reads the sharing verdict — allow through D3's disjunct, a share or the bypass — not merely passage through 2.7; one more call on a path that already made it), or accept the arm and add it to merge question 14's cost ("a disown is reversible by the creator on an object where sharing abstains"). The recommendation is the first: it keeps "a disown needs the grant" true and costs nothing HotCRM uses. (3) Add arm (b) to merge question 14's cost line either way.


R2-F3 — should-fix. The lowering to updateMany loses the by-id readback the rest of the by-id path consumes.

Evidence. D4: "the engine's by-id update lowers the write to the driver's conditional updateMany({ id, owner_id: null }, data) … Hooks see an ordinary by-id update." packages/objectql/src/engine.ts's by-id branch uses the row driver.update returns — "[#21613] The by-id update returns the driver's post-write readback … before the formulas, the afterUpdate hooks, the roll-up, the data.record.updated event and the door's ingress strip" — while the predicate branch "resolves to the affected-row COUNT driver.updateMany returns (#4639) — it names no row and returns none, so there is nothing to materialize", publishes data.records.updated instead of the per-record event, and hydrates no formulas. IDataDriver.updateMany is Promise of number on every driver. A claim that returns a count breaks the afterUpdate hook's result, the PATCH 200 record, the per-record event the flows (HotCRM's lead_assignment among them) subscribe to, and the roll-up recompute.

Fix. One sentence in D4: after a conditional write that affected one row the engine re-reads the row by id (the readback the by-id contract owes) and continues on the by-id branch — hooks, formulas, event, roll-up, response unchanged; the cost is one read per claim. Also spell the call on the contract's shape, updateMany(object, { where: { id, owner_id: null } }, data) — updateMany takes a DriverQuery, not a bare filter.

R2-F4 — should-fix (ADR-0112 D1/D3, which the record binds itself to). The registration refusal for a declared queue is a request-time refusal on one door and needs a code.

Evidence. D9: "the boot-time and registration-time refusals of D1 and D3 are startup reports, not request codes." packages/objectql/src/registry.ts#registerObject is "the ONE choke point every metadata door passes — declared stacks, plugin/package manifests, saveMetaItem, the sys_metadata boot rehydration and raw registerObject calls"; saveMetaItem is Studio's and PUT /api/v1/meta/:type/:name's door, at request time. Its refusals carry the envelope by rule: ObjectFieldTypeRefusedError ("Carries the ADR-0112 envelope (code + status) — the shape this repository's rejection tests assert against, never a bare throw"; code = OBJECT_FIELD_TYPE_REFUSED_CODE, status = 422), ExtensionDefaultRowScopeRefusedError, ExtensionMembershipKeysRefusedError — all thrown "BEFORE applySystemFields", which is where D3's refusal would sit.

Fix. D9: the registration refusal is an ADR-0112 envelope on registerObject, 422, with a code the PR registers (one new code such as OBJECT_SYSTEM_COLUMN_NAME_RESERVED, or state that it reuses INVALID_METADATA and why); keep "startup report" for the boot refusal of D1 only, which no request reaches.

R2-F5 — should-fix. The boot refusal has no owner, and the stack keys have a second list to join.

Evidence. D1: "a stack that declares queues or assignmentRules in a composition that registers no queue service is refused at boot by name". The only party that knows what a queue is, plugin-sharing, is by hypothesis absent, so the refusal must belong to code that is always present. The plan lists it in the clause that names objectql but the semicolons leave it unowned. Separately, a stack collection reaches the registry only through packages/objectql/src/engine.ts#METADATA_ARRAY_KEYS (the #22203 lesson in its own comment: positions missing from that list meant "a stack-declared position had no registry entry under its package" and the save door took the wrong tier); the plan names the keys in stack.zod.ts only. The precedent for a fail-fast boot guard is ADR-0093 D5 (the degraded-tenancy refusal) and PLUGIN_CONTRACT_VIOLATION in packages/core/src/plugin-contract.ts.

Fix. D1 and plan step 1: the engine owns the refusal — at kernel:ready, a non-empty queues or assignmentRules collection with no queue-service contract registered refuses the boot, naming the key and the missing service (the ADR-0093 D5 shape) — and both keys join METADATA_ARRAY_KEYS beside sharingRules.

R2-F6 — should-fix (the per-type artefact round 1's F14 was reaching for). Two new metadata types need two liveness ledgers and a schema binding, and neither is a regenerated artifact.

Evidence. Plan step 1: "the queue and assignment_rule types and registry entries … regenerated artifacts per AGENTS.md's spec table". packages/spec/liveness/README.md: "every property of a governed metadata type must declare a liveness status with evidence, or CI fails"; the gate (packages/spec/scripts/liveness/check-liveness.mts) is rooted on BUILTIN_METADATA_TYPE_SCHEMAS in packages/spec/src/kernel/metadata-type-schemas.ts (where position and, since ADR-0131 D6, sharing_rule are bound), and a type with no TYPE.json is read as an empty ledger (loadLedger: existsSync(f) ? … : { props: {} }), so every property reports UNCLASSIFIED and the gate fails. AGENTS.md: "The pure source audits (check:liveness, …) have no generator — a failure there is a real finding to fix, never an artifact to regenerate." sharing_rule.json and position.json are the two the new types would be modelled on.

Fix. Plan step 1 names packages/spec/liveness/queue.json and packages/spec/liveness/assignment_rule.json (hand-authored, every property classified with evidence) and the BUILTIN_METADATA_TYPE_SCHEMAS bindings for both types; and the sentence about regenerated artifacts stops implying it covers them.

R2-F7 — should-fix (design walk: visibility under a non-private OWD). On a public-write object the disjunct never answers allow, so the floor refuses the claim.

Evidence. D3: "On an object whose effective model is already open there is nothing to widen." #checkEdit returns abstain at if (model === 'public') return 'abstain' before any owner or share test; step 2.7 keeps the created_by floor on abstain (its own table: "abstain → … The floor is the ONLY row-level write gate such objects have, so it STAYS"); a guest-written sitting record has created_by NULL. So on a public write OWD a queue member can read the sitting record and cannot claim it, and neither can anyone else below modifyAllRecords (which checkEdit also never reaches on a public object). The read side is fine; the claim path is dead on exactly the objects D3 says need nothing.

Fix. Either state that queues are a write-owned-OWD feature (a rule on a public object parks records nobody can claim without the grant; os lint reports a rule on such an object), or make the queue branch of checkEdit answer allow for a sitting record before the public abstain (a positive basis is still a positive basis on an open object) and say so in D3. HotCRM's crm_case is sharingModel: 'private', so the first consumer is unaffected either way.

R2-F8 — should-fix (an honest cost on a fork). Merge question 13's cost for the alternative is not a cost.

Evidence. Merge question 13: the alternative "adds a membership read to the INSERT arm". D3's recommended rule already admits a supplied queue on insert when the caller "is a member of that queue", which is that same membership read in the same arm. The real costs of the alternative are the two the question's next clause names (a second way to an ownerless row without the grant; a stamp rule that no longer holds for every insert). Delete the clause.

R2-F9 — should-fix. The boot refusal, the registration refusal and the claim precondition each name no test; the plan's test list does not grow with the delta.

Evidence. Plan step 1's test sentence is unchanged from a36d060 ("tests for every admission and every refusal, the claim race, the empty pool, the unresolved target, the cursor under concurrency, and both re-admitted members") while the delta added the readback-bearing precondition, the registration refusal, the boot refusal, the PUBLIC_FORM_SERVER_MANAGED_FIELDS strip and the approver lint's group-routed set. The repository's convention (ObjectFieldTypeRefusedError: "the shape this repository's rejection tests assert against") expects each refusal pinned.

Fix. Add to the sentence: the form-door strip of queue, the registration refusal, the boot refusal, the 409 on a lost claim with the readback on a won one, and the lint rule's queue case.


R2-N1 — nit. D5 reuses "D4's by-id precondition" for the cursor ("where the version still equals the one read"), but D4 defines the precondition as the fixed pair { id, owner_id: null }. Define the primitive once as an equality predicate the caller supplies (expect: { field: value }), with the claim and the cursor as its two users.

R2-N2 — nit. D8, triage-rule row: "the rule hid resolved unowned cases" — the rule's criteria hide resolved and closed (hotcrm:src/service/sharing/case.sharing.ts#CaseUnassignedTriageSharingRule: record.status != "resolved" && record.status != "closed").

R2-N3 — nit. D4: "Under delegation both principals pass the same gate." True, with a consequence worth one clause: resolvePreImageFloorDrop returns false when delegated ("The delegated by-id write keeps BOTH floors", step 2.7's comment), so a delegated claim of a sitting record whose created_by is not the agent — every guest-written one — is refused by the floor whatever the membership. Say it, or point at the card's recorded residual.

R2-N4 — nit. Context §1 now says the IRlsMembershipResolver seam is for keys "a policy reads as IN (current_user.KEY)"; the contract's own header adds that it is "for app-shaped sets" and that org_user_ids is core-resolved. A platform plugin publishing queues through it is within the contract (plugins may register resolvers), but D3 should say the queue service is the registered resolver for that one key, so the seam's "declared keys" audit lists an owner.


What the delta got right (so the fix round need not re-verify): the rewritten Context §1 paragraph on readers (every quoted phrase is in the cited symbol's docblock, including "Returns false when the record has no owner value", "a second implementation of ownership", and the 2.7 allow-only drop); IRlsMembershipResolver at the cited path with ADR-0105 D11 as its provenance; sys_metadata_activation.metadata_type and its enumerated description; sharing_rule's and position's registry entries; field-recipient.test.ts's "superset" assertion; sharing-service.test.ts's @ts-expect-error pin; the engine's by-id branch (priorRecord read, recordNotFoundError on a miss, driver.update(object, id, data, options)), the predicate branch's count and the isPredicatePath test on typeof driver.updateMany === 'function'; updateMany declared on memory, sql, mongodb and turso with Promise of number; ObjectFieldTypeRefusedError and its siblings as registration-time refusals thrown before applySystemFields; HotCRM's case_escalation_sharing (edit, position service_manager, priority == "critical" && is_closed == false), system_admin's modifyAllRecords and allowTransfer on crm_case, crm_case.sharingModel: 'private', and service_agent's lack of allowTransfer. Merge questions 13 and 15 are real forks with honest costs; 14 is a real fork whose cost line R2-F2 completes. No new contradiction was found between the delta and ADR-0049, 0056 D3, 0057, 0058, 0073, 0078, 0086, 0090, 0105 D11, 0111 D3 (except R2-F1's placement), 0112 (except R2-F4), 0117, 0118, 0131 or 0138.

Rounds 3 and 4 follow in the next comment.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Independent full review — ADR-0142 (rounds 3 and 4 of 4 — final verdict PASS)

An independent full review of the whole record, on the contract-review tier at effort xhigh, by a reviewer agent separate from the drafter (fed the record, the rulings and the code at 5f5f0b42 plus HotCRM at 8f52859, never the drafter's notes), per the owner's order that spec, skills and ADR changes owe one before the seat's ACCEPT. One full review of the first substantive head, then delta reviews by the same reviewer of the commits that only apply findings, stopping at the first round with no must-fix.

Round Head Verdict must-fix / should-fix / nit Applied in
1 (full) a36d060 FAIL 7 / 9 / 5 19d7532
2 (delta) 19d7532 FAIL 2 / 7 / 4 3c6be23
3 (delta) 3c6be23 PASS WITH FIXES 0 / 1 / 1 a2e71ca
4 (final delta) a2e71ca PASS 0 / 0 / 0 —

0ae85b0, the PR head, merges main onto a2e71ca and leaves the record byte-identical. Rounds 1 and 2 are in comment 6399544349.

Round 3 — delta review of 3c6be23

Verdict: PASS WITH FIXES

Reviewed: docs/adr/0142-ownership-queue-membership-claim-and-assignment-rules.md at 3c6be23 (branch claude/project-thread-eypatm, committed; working tree clean; delta from 19d7532 is 86 insertions / 62 deletions in the one file). Code read at origin/main 5f5f0b4221d9; HotCRM at 8f52859.

Summary. Both round-2 must-fixes are closed by text that is true to the code: the queue basis is now its own positive basis in checkEdit and sits in buildWriteFilter's update arm only, and the code confirms it stays out of every delete path (checkDelete does not call checkEdit or share the new basis; canDelete projects checkDelete; the bulk path threads the verb so buildWriteFilter('delete') returns the owner match before the share block; the 2.7 floor drop asks checkDelete for the delete class). The claim now requires a positive sharing verdict and names the two arms it excludes. The precondition, the registration refusal's code, the boot refusal's owner and the liveness ledgers are all stated correctly. What remains is one consistency fix (D8 and the proposed refusal string still describe the claimant set in the words D4 abandoned) and one nit on where the verdict comes from when step 2.7 does not compute it. No must-fix; the record can go to the maintainer with these applied.

Gates (worktree root, at 3c6be23):

  • node scripts/check-adr-symbol-anchors.mjs — EXIT=0 (2654 anchors across 144 records resolve; the record's hotcrm: anchors are reported cross-repo, not judged; metadata-type-schemas.ts, sharing-plugin.ts and liveness/README.md resolve as file-level anchors).
  • node scripts/check-adr-links.mjs — EXIT=0 (807 relative links resolve).
  • House rules: no HTML comments, no model names, no path:NNN anchors, codes SCREAMING_SNAKE, proposed runtime strings carry no tracker number; Chinese rulings unchanged.

Closure of round 2

  • R2-F1 (disjunct in matchesOwnerScope confers delete) — closed. D3 now places the basis "in checkEdit as a positive basis of its own, tested before the public-OWD abstain and apart from matchesOwnerScope, the fast path checkDelete shares; in buildWriteFilter's update arm beside the share ids, never its delete arm", and says why. Verified against sharing-service.ts: checkDelete is "Ownership / write DEPTH only — no share branch" and never calls checkEdit; buildWriteFilter has if (verb === 'delete') return ownerMatch; before the share lookup; sharing-plugin.ts#buildSharingMiddleware threads the verb for bulk writes ("a bulk DELETE scopes to owned rows alone (no share widening)"); security-plugin-sharing-verdicts.ts#resolveSharingWriteVerdict picks checkDelete for the delete class. Context §1 now notes the shared fast path.
  • R2-F2 (claimant set; creator arm; app widener) — closed. D4 requires "the sharing verdict … is allow — a queue member through D3's basis, an edit share, or modifyAllRecords", names the two excluded arms with the reason for each, states the delegation consequence, and TL;DR 3 and merge question 14 follow. Verified: checkEdit answers allow only from matchesOwnerScope, the edit-share lookup or hasModifyAllBypass; abstain on a public model is where the floor arm lives; the On objects that carry sharing rules, sharing middleware answers FORBIDDEN before RLS update-wideners are consulted — the identical widener works on rule-less objects #5493 admit "does NOT authorize the write" and changes no verdict. See R3-F1 for two descriptive sentences that did not follow.
  • R2-F3 (readback) — closed. updateMany(object, { where: { id, ...expect } }, data) matches IDataDriver.updateMany(object, query: DriverQuery, data); "one affected row is re-read by id and the by-id branch continues unchanged" restores what engine.ts's by-id branch consumes.
  • R2-F4 (registration refusal code) — closed. D9 names INVALID_METADATA, 422, "as ObjectFieldTypeRefusedError does"; verified registry.ts: export const OBJECT_FIELD_TYPE_REFUSED_CODE = 'INVALID_METADATA' as const;, status = 422, and the same envelope on describeUnboundActionTargets and the package-action schema refusal.
  • R2-F5 (boot refusal owner; METADATA_ARRAY_KEYS) — closed. The engine, at kernel:ready, in ADR-0093 D5's shape (refuse to boot with an actionable error naming the thing and the remedy); both keys join METADATA_ARRAY_KEYS beside sharingRules and positions (both present in that list at 5f5f0b42). ADR-0093 is added to References with the D5 pointer.
  • R2-F6 (liveness ledgers; schema bindings) — closed. Plan step 1 names BUILTIN_METADATA_TYPE_SCHEMAS bindings and two hand-authored ledgers "every property classified with evidence (the liveness gate has no generator)".
  • R2-F7 (public-OWD floor) — closed. The basis is tested before the public abstain, and D3 says what that buys on an open OWD.
  • R2-F8 (merge question 13's cost) — closed; the phantom cost is gone and the two real ones stand.
  • R2-F9 (tests for the delta) — closed; the plan's test sentence names each new refusal and the readback.
  • R2-N1 (expect: { field: value } once) — closed. R2-N2 (resolved and closed) — closed. R2-N3 (delegated claim) — closed, in D4. R2-N4 (queue service as the registered resolver) — closed.

R3-F1 — should-fix (internal consistency). D8 and the proposed refusal string still describe the claimant set as "anyone who can edit", which D4 no longer means.

Evidence. D4 now admits a claim only on a positive sharing verdict and says in the same paragraph that two ways of being able to edit — the created_by floor where sharing abstains, and an app-authored update widener — "neither licenses a claim". D8's case_self_claim row still reads "with the same claimant set — anyone who can edit the unowned case", the backlog row "claimable by whoever can edit it (D4)", and D4's own message list proposes "claim only an unowned record you can edit". A reader who starts at D8 (HotCRM's engineer will) gets the superseded rule, and the refusal string would misreport the condition that failed to exactly the two callers it refuses.

Fix. D8 rows: "the same claimant set — anyone the sharing model positively admits to the unowned case: the agent through the queue, the manager through the escalation share, the admin through Modify All" and "claimable by whoever holds a positive basis (D4)". Message: "claim only an unowned record the sharing model admits you to" (or name the three bases).

R3-N1 — nit. D4 says the admission reads "the sharing verdict step 2.7 already obtained for this row", and merge question 14 "it adds no read: the verdict is already computed". Step 2.7 asks the verdict only inside resolvePreImageFloorDrop, which returns false before asking when the write is delegated or when no platform floor policy is in the caller's collection (floorApplies), and 2.7 itself runs only with permissionSets.length > 0 && userId && this.ql; nor does 2.7 stash the verdict anywhere today (it passes only dropPlatformOwnershipFloor on into preImageFloorOpts). Say "the verdict 2.7 obtained where it asked one, stashed on the operation context beside the depth; where 2.7 did not ask, the gate asks resolveSharingWriteVerdict itself — one read on the delegated and floor-free paths only". The mechanism is unchanged; the sentence is.


What the delta got right (so the fix round need not re-verify): every quoted phrase and symbol in the new D3 and D4 text is in the cited code as stated; the DriverQuery spelling; INVALID_METADATA on ObjectFieldTypeRefusedError; METADATA_ARRAY_KEYS's current members; ADR-0093 D5's shape; BUILTIN_METADATA_TYPE_SCHEMAS as the liveness gate's root (its sharing_rule.json _note still says the type is bound in UNREGISTERED_KIND_SCHEMAS, which ADR-0131 D6 since moved — a stale note in the ledger, not in the record); the removal of ADR-0052 is complete (no residual citation); HotCRM facts unchanged. No new contradiction found elsewhere in the record against ADR-0049, 0056, 0057, 0058, 0073, 0078, 0086, 0090, 0093 D5, 0105 D11, 0111 D3, 0112, 0117, 0118, 0131 or 0138.

Round 4 — final delta check of a2e71ca

Verdict: PASS

Reviewed: docs/adr/0142-ownership-queue-membership-claim-and-assignment-rules.md at a2e71ca (branch claude/project-thread-eypatm, committed; working tree clean; delta from 3c6be23 is the 15-line change to D4's admission sentence and refusal string, two D8 cells, and merge question 14's cost clause). Code read at origin/main 5f5f0b4221d9; HotCRM at 8f52859.

Summary. Both round-3 items are closed and the new wording is true to the code. No finding remains; the record is ready for the maintainer.

Gates (worktree root, at a2e71ca):

  • node scripts/check-adr-symbol-anchors.mjs — EXIT=0 (2654 anchors across 144 records resolve; the record's hotcrm: anchors are reported cross-repo, not judged).
  • node scripts/check-adr-links.mjs — EXIT=0 (807 relative links resolve).
  • House rules: no HTML comments, no model names, no path:NNN anchors, codes SCREAMING_SNAKE, the three proposed refusal strings carry no tracker number; Chinese rulings unchanged. No residual "can edit" / "already obtained" / "already computed" phrasing anywhere in the record.

Closure of round 3

  • R3-F1 (D8 and the refusal string described the claimant set in D4's abandoned words) — closed. D8's case_self_claim row now reads "anyone the sharing model positively admits to the unowned case: the agent through the queue, the manager through the escalation share, the admin through Modify All"; the backlog row "claimable by whoever holds a positive basis (D4)"; the message "claim only an unowned record the sharing model admits you to". All three agree with D4's admission (a sharing verdict of allow: the queue basis, an edit share, modifyAllRecords) and with merge question 14. The three HotCRM claimants named are each a positive basis at 8f52859: service_agent through the intake queue (D3's basis), service_manager through case_escalation_sharing (accessLevel: 'edit', priority == "critical" && is_closed == false), system_admin through modifyAllRecords: true on crm_case.
  • R3-N1 (the verdict is not always "already obtained" at 2.7) — closed, and the new sentence matches the code exactly. packages/plugins/plugin-security/src/security-plugin-sharing-verdicts.ts#resolvePreImageFloorDrop has two early returns before it asks resolveSharingWriteVerdict: if (delegated) return false; and if (!floorApplies) return false; where floorApplies is collectRLSPolicies(...).some(isPlatformOwnershipFloorPolicy) — the record's "a delegated write, or no floor policy in the caller's collection" is that pair. Step 2.7 today carries forward only preImageFloorOpts = { dropPlatformOwnershipFloor, masterGateCoversThisWrite }, so "stashed on the operation context beside the depth" is correctly stated as the PR's addition (the depth stash it sits beside is sc.__writeScope on opCtx.context, step 2.6). "The gate asks resolveSharingWriteVerdict itself, one read on those paths only" names the existing function with the right cost. Merge question 14's cost clause ("it adds a read only where step 2.7 asked no verdict") follows. The other way 2.7 can be skipped altogether — permissionSets.length === 0, no userId, or no this.ql — needs no mention: a caller with no sets fails the object gate before step 3.5, a caller with no userId is the guest branch, and an engine with no ql has no middleware at all.

No new contradiction. The changed sentences are consistent with TL;DR 3, D3's basis placement, D4's two excluded arms and delegation clause, D9's codes, the Consequences bullet on edit shares, and merge question 14's recommendation. Nothing outside the diff changed, and the round-3 verification of the rest of the record stands.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 0ae85b0a3f37ef589c22aac1487aec32685ae848
Local-runs: none
Scope: docs/adr/0142-ownership-queue-membership-claim-and-assignment-rules.md + packages/spec/src/automation/approval.zod.ts, packages/spec/src/security/sharing.zod.ts, packages/spec/src/security/public-form.ts, packages/spec/src/security/permission.zod.ts, packages/spec/src/security/explain.zod.ts, packages/spec/src/contracts/sharing-service.ts, packages/spec/src/contracts/sharing-service.test.ts, packages/spec/src/contracts/rls-membership-resolver.ts, packages/spec/src/contracts/data-driver.ts, packages/spec/src/data/injected-system-column-provenance.ts, packages/spec/src/data/hook.zod.ts, packages/spec/src/data/field.zod.ts, packages/spec/src/kernel/metadata-plugin.zod.ts, packages/spec/src/kernel/platform-capabilities.ts, packages/spec/src/migrations/entries/semantic/17.sharing-rule-recipient-reconcile.ts, packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts, packages/plugins/plugin-security/src/platform-ownership-policies.ts, packages/plugins/plugin-security/src/security-plugin-sharing-verdicts.ts, packages/plugins/plugin-security/src/explain-engine.ts, packages/plugins/plugin-security/src/claim-seed-ownership.ts, packages/plugins/plugin-security/src/objects/sys-user-position.object.ts, packages/plugins/plugin-sharing/src/sharing-service.ts, packages/plugins/plugin-sharing/src/sharing-rule-service.ts, packages/plugins/plugin-sharing/src/bu-tree-recompute.ts, packages/plugins/plugin-sharing/src/recipient-membership-recompute.ts, packages/plugins/plugin-sharing/src/access-level.ts, packages/plugins/plugin-sharing/src/declared-sharing-rules.ts, packages/plugins/plugin-sharing/src/position-graph.ts, packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts, packages/plugins/plugin-sharing/src/field-recipient.test.ts, packages/plugins/plugin-approvals/src/approval-service.ts, packages/plugins/plugin-approvals/src/approver-address.ts, packages/objectql/src/engine.ts, packages/objectql/src/registry.ts, packages/objectql/src/engine-middleware-operation-vocabulary.test.ts, packages/core/src/security/effective-object-permissions.ts, packages/lint/src/validate-org-axis-red-lines.ts, packages/lint/src/validate-approval-approvers.ts, packages/qa/dogfood/test/authz-conformance.matrix.ts, packages/services/service-messaging/src/recipient-resolver.ts, packages/platform-objects/src/system/sys-metadata-activation.object.ts, docs/adr/0019-approval-as-flow-node.md, docs/adr/0057-erp-authorization-core-business-units-and-scope-depth.md, docs/adr/0058-expression-and-predicate-surface.md, docs/adr/0086-authz-metadata-config-boundary-and-cross-package-composition.md, docs/adr/0090-permission-model-v2-concept-convergence.md, docs/adr/0093-tenancy-mode-and-membership-lifecycle.md, docs/adr/0111-record-share-management-authority-and-verb-boundary.md, docs/adr/0117-owning-business-unit-record-stamp.md, docs/adr/0131-total-organization-ownership-no-null-organization-id.md, docs/adr/0138-guest-model-anonymous-principal-doors-grants-and-organization.md, .claude/skills/pm-dispatch/references/cross-repo-coordination.md, .claude/agents/os-dev.md, AGENTS.md, scripts/pm/clause2-line.mjs

Read-only shape held: the net diff against main at this head is one added file (+822 / −0, docs/adr/0142-ownership-queue-membership-claim-and-assignment-rules.md); no package, schema, changeset or other ADR moves. Code was read at the record's own measurement base 5f5f0b42 (an ancestor of main) and re-checked against main at 17f8217b: of the cited files only field.zod.ts and ADR-0093 changed in between, and both anchors the record uses (referenceVia; ADR-0093 D5) survive. HotCRM anchors (hotcrm:…, repository objectstack-ai/hotcrm at 8f52859) were not re-read here — they are outside this repository and check-adr-symbol-anchors reports them without judging; the independent review on the PR states it verified each by hand.

Check-runs on this head at review time: 51 total, every one completed; conclusions success for Governed Surface Queue Guard, Check Changeset (one run success, one skipped on the pre-label event), Check PR Size, Check Documentation Links, Part-of PR must not also close its card, The card this PR closes must claim this branch, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Test Core 1–8 of 8 and the Test Core roll-up, Dogfood Regression Gate, Lint · docs and code guards, Lint · CI/release/engine gates, Lint · repo and tooling gates, Lint · PM dispatch-gates, Lint · scoped (tooling self-tests, slot lookup and corpus, query-options erasure), Lint · ESLint, Lint and Repo Gates, Type Check · workspace / source gates / consumer gates / debt ledger, TypeScript Type Check, Auto Label, filter; skipped by path selection for Build Core, Build Docs, UI lanes, Console Pin Gate, Temporal Conformance, dogfood shards, Dogfood Verify CLI, the opt-in harnesses. Nothing is still running; nothing failed. No review has been submitted on the PR yet.

① Derived judgments

The contract the record fixes, each claim about today's code read in the cited code — holds.

  • Queue entity (D1). sharing_rule is the stated model: its registry entry is allowRuntimeCreate: false, domain: 'security' and the entry comment names ADR-0131 D6 regime C; position is allowRuntimeCreate: true and is correctly named as not the model (metadata-plugin.zod.ts#DEFAULT_METADATA_TYPE_REGISTRY). METADATA_ARRAY_KEYS in engine.ts exists and carries sharingRules / positions. sys_metadata_activation.metadata_type is a text column whose description lists the types, so "joins the vocabulary" is a description edit, as stated. The job-queue capability queue and sys_job_queue exist; D9 keeps them apart. ADR-0131 D2/D3/D4 and the regime-C note of its D6 read as cited ("Assignments are organization rows, in every posture" is verbatim).
  • Membership (D2). ShareRecipientType is the six-member authorable enum without queue; field is per record (expandRecipientForRecord); the three graph services and the sys_member.role transition source are as described; position-graph.ts says the approvals expander is "deliberately NOT identical". sys_user_position carries user_id, position (machine name), organization_id, business_unit_id and a validity window.
  • Claim through the existing gate, not around it (D3/D4). Step 3.5 of registerAuthzMiddleware is exactly as Context §1 reports: INSERT stamps an empty owner with the acting user (batch rows included), a foreign owner is a forge without transfer; UPDATE treats a supplied owner as transfer/disown, tolerates the single-id echo via the caller's pre-image, fails closed on a bulk change-set; the guest branch stamps nothing and refuses a supplied owner whatever it is granted; both principals must hold the grant under delegation; the referential-integrity clear is exempt; the step's own text says a beforeInsert hook runs "inside the engine's operation, after this middleware". The owner match is built in plugin-sharing (buildReadFilter's ownerMatch, buildWriteFilter with if (verb === 'delete') return ownerMatch; before the share widening, checkEdit → matchesOwnerScope, which "Returns false when the record has no owner value" and is shared with checkDelete); the middleware only stashes __readScope / __writeScope at step 2.6. The platform floor is created_by == current_user.id (OWNERSHIP_FLOOR_PREDICATE, "a second implementation of ownership"), dropped only on a sharing allow through resolvePreImageFloorDrop, which returns false before asking when the write is delegated or no floor policy applies — the record's D4 sentence on where the verdict comes from matches this exactly. checkEdit answers allow only from ownership-at-depth, an edit-level share, or hasModifyAllBypass, so "a positive sharing verdict" names the three bases correctly, and the two excluded arms (floor passage where sharing abstains; an app-authored widener) are real paths. transfer is in ExplainOperationSchema, maps to allowTransfer in GRANT_BITS, is listed in DESTRUCTIVE_LIFECYCLE_VERBS as never dispatched, and the explain engine defers it to the by-id update of owner_id; allowTransfer's description reads "ENFORCED via the insert/update owner_id guard" with the M2 note. OWNER_FIELD_DEF is lookup → sys_user, readonly: false, system: true; applySystemFields injects by the positive tier list (user ✅, business_unit/org/none ❌) and lets an author-declared same-name field win — the rule D3/merge question 15 deliberately does not copy. PUBLIC_FORM_SERVER_MANAGED_FIELDS already pre-registers owning_business_unit_id. IDataDriver.updateMany is optional; memory, mongodb, sql and turso declare it, sqlite-wasm has none of its own and its tests call the inherited one; the engine's by-id update has no precondition option today; RESOURCE_CONFLICT is in the standard catalog; INVALID_METADATA 422 is the registry's existing refusal envelope. claimSeedOwnership's NULL arm is id-bounded since plugin-security: the seed-ownership claim hands every unowned row to the platform admin on each app:seeded — guest-written and public-form rows included — contradicting ADR-0138 D1's "the owner stays unset" #23354. IRlsMembershipResolver (ADR-0105 D11) fills rlsMembership; referenceVia is the polymorphic pointer shape.
  • Assignment rules (D5). The insert order holds: middleware → applyFieldDefaults → beforeInsert per row → resolveSystemInsertOrganization ("Called AFTER the beforeInsert hooks on purpose") → driver write → afterInsert. HookContextSchema carries previous, dispatch.mode (record / per-row), session, user, api; HookSchema.runAs is system | user | inherit. ADR-0138 D1 item 3 lists hooks, record-change flows and assignment rules in that order and is quoted verbatim, as is the maintainer's Alternatives sentence; the record amends nothing in D1. compileCelToFilter is the one compiler ADR-0058 D1 names.
  • Re-admission of the two reserved members (D6/D7). approval.zod.ts: queue still parses with the comment "Re-admit only together with a real ownership-queue implementation (queue entity + membership + claim)", sits in NON_AUTHORABLE_APPROVER_TYPES (published as xEnumDeprecated), APPROVER_VALUE_BINDINGS.queue = { source: 'unsupported' }, APPROVER_ORG_SCOPED.queue = false, xRef.map keeps queue. resolveApproverSpec has no queue branch: it warns "approver type 'queue' is not implemented — the slot resolves to nobody" and falls through to return [${type}:${a.value}]; the graph types warn then write the literal, the person types return [] — so D6 item 2's "warn as the graph types, return [] as the person types, a new combination" is accurate. approver-address.ts holds slot-address readers (positionAddresses) only. sharing.zod.ts says the runtime contract "additionally reserves queue (no sys_queue yet) — it is deliberately NOT authorable"; SharingRuleRecipientType carries queue as "reserved … expandRecipient returns []"; sharing-service.test.ts pins "the authoring enum plus the reserved queue" with an @ts-expect-error; expandRecipient falls through to return [] ("treat as no-op until queue impl lands"); warnOnEmptyUnitExpansion is the shape cited; BU_TREE_RECIPIENT_TYPES, recipient-membership-recompute.ts, the lint red-line table ("this table is the place to re-decide that, in ShareRecipientType order"), migration 17, the dogfood sharing-rules row, SysSharingRule.recipient_type's "queue was removed" comment and access-level.ts's stale sentence all read as listed. validate-approval-approvers.ts excludes "the opaque queue" from the group-routed set and carries the "Queue approvers need a real ownership-queue implementation" hint D6 item 4 retires.
  • Rulings and grades. The D6 ruling is quoted character-for-character from the card body; the triage grade is verbatim from comment 6396200200; the D5 project rule matches cross-repo-coordination.md line 28 byte for byte and chore(pm-dispatch): tooling cap per dispatch round, pm-rule cards on the maintainer's naming, two round-report readings, spec key lands with its first reader (D3 = A, D5 = A) #25795 is 5f5f0b42. ADR numbering: 0141 is the newest landed record, 0136 / 0140 are absent, the two 0019 files are a known collision in check-adr-anchors.mjs.

Does it decide only what the ruling (D6 = A) and the triage order allow? Yes. The ruling makes queue/assignment rules platform; the triage grade orders the ADR first with four parts (entity, membership, claim, assignment rules) and the two re-admissions after. The record declares exactly those, keeps Status Proposed, implements nothing, edits no other ADR (the diff is one file), and defers the execution to one spec + server PR under ruling D5 (spec key with its first reader). Everything deferred is actually deferred and named: update-time re-routing stays in HotCRM (merge question 5, D8 row), a queue as a messaging audience (8), organization-authored assignment rules (11), a field assignment target, the Console affordances, the authoring docs (Open items; Execution plan 2–4).

Drafter judgements the ruling did not settle — are they all among the merge questions? Fifteen are, and each is stated with its alternative and cost (1 column placement, 2 regime/owner, 3 pre-resolved set, 4 no owner opt-in, 5 insert-only, 6 no claim verb, 7 cursor degradation, 8 no queue: audience, 9 five member kinds, 10 engine seam, 11 declared-only rules, 12 load required, 13 member insert keeps the stamp, 14 who may claim, 15 registration refusal on a declared queue field). Three drafter choices are decided in the body but not carried to that list — should-fix, each one sentence, none changing a decision:

  • (a) D4's by-id write precondition (expect: { field: value }) is a new engine contract — the record says "the engine has no such primitive … today" and adds it. Merge question 6 mentions it only as a shared need of both claim spellings. Say whether expect is reachable from the data API (then it is public surface the spec + server PR must declare on its Metadata-protocol: / Clause-②: lines and in the designer contract) or is engine-internal, set only by the gate and the cursor; recommend internal-only, as the text's "set by the gate on the operation context" already implies.
  • (b) D1's boot refusal — a stack declaring queues / assignmentRules in a composition with no queue service is refused at kernel:ready. It follows ADR-0078 and ADR-0093 D5's shape, but it is a composition constraint (a package that declares a queue now requires plugin-sharing in the composition, including lean embeddings); the alternative — a loud once-per-boot warning with the keys inert — is not silent and so not what ADR-0078 forbids. Name the choice in the merge-question list.
  • (c) D1's "a declared queue's members may name positions only" is derived from ADR-0131 D3/D4 and ADR-0086 D1 (users, teams and units are organization rows) and is a sound derivation, but merge question 9 treats it as a given ("A declared queue is positions-only either way"). One clause in 9 saying it is a derived constraint, not a ruled one, lets the maintainer object by number.

Nits (non-blocking; fold into the same edit or the spec + server PR): ADR-0019 says the approver resolution moved "nearly verbatim", the record says "verbatim"; D7 item 4's lint-time clause ("admitted when the queue's declared members are of the three flat kinds") is vacuous once D1 makes declared queues positions-only — reword to "a declared queue's members are positions, so the table admits it on a platform-global object; an authored queue is judged at runtime"; ADR-0057's comparison table still maps "Public Group/Queue" onto sys_team + sharing rules — a one-line note there belongs to the spec + server PR, not to this record (the record's "amends nothing" stays true: the table is comparison, not decision). Every HotCRM-side statement (three factories, triggers, priorities, pools, the claim flow, the five tests, setup.mdx) is carried as cross-repo and was not re-read here.

Internal consistency. Context §1, TL;DR 2–4, D3's four sites (read filter; write filter's update arm only; checkEdit as its own positive basis tested before the public-OWD abstain and apart from the checkDelete-shared fast path; explain), D4's admission and the two excluded arms, D8's claimant set and the three refusal strings, D9's codes, the Consequences and merge question 14 all say the same thing; no "can edit" / "already computed" residue. Proposed runtime strings carry no tracker number; codes are SCREAMING_SNAKE; type names singular, stack keys camelCase, machine names snake_case.

② Semver level

Level: none for this PR. The diff publishes nothing from any released package — one new file under docs/adr/**. AGENTS.md's rule reads: skip-changeset "is for a diff that publishes nothing from any released package" — this is that diff, so the label is right and Check Changeset is correctly exempt (one run success, one skipped). The record's own Execution plan says the later PR takes minor on every package it names, with the two enum additions called out — that is the correct level for the spec + server phase and is not this PR's.

The two declaration lines. The PR body carries Clause-②: yes / Metadata-protocol: added, copied from the claim because os-dev may only copy and never invent (os-dev.md line 279), and the body says they describe the card's later spec phase. Read against AGENTS.md's definition — Metadata-protocol: is "the change to what a metadata author can write" and Clause-②: whether the card widens an accept set or the public surface — they describe a diff, and this diff changes nothing an author can write and widens no surface. The truthful lines for this PR are Clause-②: no / Metadata-protocol: none, exactly as the docs-only ADR precedent #25717 (ADR-0141, merged) carried. No gate reds on the current lines (the two CI readers of clause2-line.mjs, check-changeset-no-major and check-adr-0087-registration, read the changeset body and there is none; reviewFace is decided by the governed path, not the declaration), so this is not a merge blocker — but it is a false declaration on a Tier H PR and should be corrected before the maintainer reads it. Action (seat-side, two lines, no diff change, as the dev offered): edit the PR body to Clause-②: no / Metadata-protocol: none; the claim's yes / added belong on the spec + server PR of this card, with minor changesets carrying the same two lines. The claim comment itself may stand: it names the card's whole surface and its File-surface line already says docs/adr only "in this phase".

③ Boundary flags

Each deviation and open question of os-dev-report 6399577263, answered:

  1. Two writes beyond the three-write budget (push, pr_create, report — os-dev.md line 61): the two review-record comments 6399544349 / 6399553138 on 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. Accepted. They are additive comments that change no PR state; the content is what the owner's order for spec, skills and ADR diffs requires (an independent full review before the seat's ACCEPT, quoted by comment id) and what precedent docs(adr): ADR-0141 — an app may depend on another installed app: declared in manifest.dependencies, checked once before install, co-owning the namespace it shares #25717 did; the split in two is the relay's payload cap; the deviation is reported, not hidden. Note for the seat: the budget is the PM's to set (os-dev.md line 293) — the next ADR order should budget the review-record comment explicitly so it is no longer a deviation.
  2. Clause-②: yes / Metadata-protocol: added on a docs-only diff. Answered in ②: seat edits the two lines to no / none before the merge; the dev applied the copy rule correctly and flagged it correctly.
  3. No review request for marchtian. No rule requires one. The MCP update_pull_request channel is barred (execution-duties.md line 130); the review-request path is the relay's REST requested_reviewers op, a Tier H relay op (rest-channel.md line 47), which requested the governed approvers hotlong and os-zhuang; marchtian is the assignee and the maintainer lands Tier H by hand. No action owed; if the seat wants marchtian listed as reviewer, the relay op is the path.
  4. Not subscribed to PR activity. CI convergence is the seat's; at review time all 51 check-runs are complete and none failed, so nothing is left to converge on this head.
  5. Branch claude/project-thread-eypatm (platform-assigned) and the detached primary checkout. Consistent with the claim's Branch: line; "The card this PR closes must claim this branch" is green; Implemented-by: below reads that branch.
  6. Merge commit 0ae85b0 without the trailer pair. The four content commits carry the model-free pair; the merge commit is git's default message and the record is byte-identical across it. No gate reads the merge commit's message and a squash merge takes the PR body; noted, no action.
  7. First push refused by the pre-push card-trailer check and reworded before publishing. Correct handling; nothing published was rewritten.
  8. HotCRM read from an anonymous read-only clone. Read-only, nothing edited; the anchors are reported cross-repo by the gate. No action.
  9. Open question — the 15 merge questions. They are the maintainer's; carry them unchanged into the final 维护者速读, plus the three drafter choices named in ① (a)–(c) once added.
  10. Open question — when to file the HotCRM STOPGAP-delete card. B: after the maintainer merges ADR-0142, and by the accepting seat: cross-repo-coordination.md line 35 has the seat that accepts a PR whose product flows to another repo file the consumer-repo follow-up at acceptance, with Blocked-by:; for this phase acceptance is the maintainer's merge, and the delete list (D8) depends on merge questions 5, 13 and 14, so a card filed now could name the wrong deletions. Two things the card must carry when filed: Blocked-by: objectstack-ai/objectstack#25769 (line 29: an open Blocked-by keeps it undispatched), and — because Blocked-by releases when spec+server: an ownership queue primitive (queue entity, membership, claim) and declarative assignment rules, so an app routes ownerless records without app-level round-robin (maintainer ruling D6 = A) #25769 closes at the spec + server merge, not at a release — its body must state the real unblock condition in words: a published @objectstack/* release carrying that PR (the card's own words; HotCRM AGENTS.md rule 2, "merged is not available in the pin"). This satisfies the card's "filed alongside and blocked on this one" without pre-empting the maintainer's answers.
  11. Out-of-scope findings. access-level.ts's stale "was removed" sentence is carried by D7 item 5 into the spec + server PR — correct carrier; the redundant HotCRM guest owner strip is HotCRM's call per D8; the 0019 collision is an accepted state per check:adr-anchors. Nothing to file.

Implemented-by: claude/project-thread-eypatm
Reviewed-by: session_01K2Tat8JEt4AhLASV4V4QWE

VERDICT: PASS

…merge questions

The engine-internal reach of the by-id write precondition, the boot
refusal against a loud warning, and positions-only declared members are
now merge questions 16 to 18. The ADR-0019 quote says "nearly verbatim";
D7 item 4's lint clause says what holds under positions-only members.

Claude-Session: https://claude.ai/code/session_017BFhbTnbbHcXkSnMss2btn
Co-authored-by: Claude <noreply@anthropic.com>
Merge question 16 names the data API's existing If-Match / expectedVersion
check as compare-then-write and states the real fork; a lost claim answers
the ledgered CONCURRENT_UPDATE 409; question 17 quotes the degradation rule.

Claude-Session: https://claude.ai/code/session_017BFhbTnbbHcXkSnMss2btn
Co-authored-by: Claude <noreply@anthropic.com>
D9 says the PR adds CONCURRENT_UPDATE's objectql ledger row; Context §1
names both doors that compare the version, updateData and deleteData.

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

Copy link
Copy Markdown
Contributor Author

Independent full review — ADR-0142 (seat patch round: rounds 5 to 7 — final verdict PASS at 05ae041a)

The same independent reviewer as rounds 1 to 4 (comments 6399544349 and 6399553138), on the contract-review tier at effort xhigh, separate from the drafter. It ran delta reviews of the seat's patch round (comment 6399715745 asked for three merge questions and two nits) and of each commit that applied the findings, stopping at the first round with no finding.

Round Head Verdict must-fix / should-fix / nit Applied in
5 (delta) e08c90e FAIL 1 / 1 / 1 e765768
6 (delta) e765768 PASS WITH FIXES 0 / 0 / 1 05ae041a
7 (final delta) 05ae041a PASS 0 / 0 / 0 —

05ae041a is the PR head: 860 lines, 18 merge questions.

Round 5 — delta review of e08c90e

Verdict: FAIL

Reviewed: docs/adr/0142-ownership-queue-membership-claim-and-assignment-rules.md at e08c90e (branch claude/project-thread-eypatm, committed; working tree clean). The record at 0ae85b0 (the merge of main) is byte-identical to a2e71ca, so the delta under review is 0ae85b0..e08c90e: 33 insertions / 9 deletions — merge questions 16 to 18, their three cross-references, the ADR-0019 wording, and D7 item 4. Code read at origin/main 5f5f0b4221d9; HotCRM at 8f52859.

Summary. Two of the three new merge questions are true to the code and the cited records and state their costs honestly; the two nits are closed. Merge question 16 fails: its premise — that exposing a conditional update would give the data API optimistic concurrency it does not have, at the price of a new vocabulary, a new 409 and a new spec key — is contradicted by the code, where PATCH /data/:object/:id and DELETE already carry an opt-in If-Match / expectedVersion protocol with a spec key and a 409 CONCURRENT_UPDATE. The fork the seat asked for is real, but it is a different fork from the one written, with different costs. One paragraph to fix; one should-fix follows from the same fact.

Gates (worktree root, at e08c90e):

  • node scripts/check-adr-symbol-anchors.mjs — EXIT=0 (2655 anchors across 144 records resolve; the record's hotcrm: anchors are reported cross-repo, not judged).
  • node scripts/check-adr-links.mjs — EXIT=0 (807 relative links resolve).
  • House rules: no HTML comments, no model names, no path:NNN anchors; the merge-question list runs 1 to 18 with the three new cross-references (D1 → 18, D1 → 17, D4 → 16) pointing at the right numbers; Chinese rulings unchanged.

Closure of the seat's items

  • (a) merge question 16 (expect engine-internal or on the data API) — present, cross-referenced from D4; premise wrong, see R5-F1.
  • (b) merge question 17 (boot refusal against a loud warning) — closed. AGENTS.md's rule is characterised correctly: "Functional degradation → warn / info … an optional service never showed up" and "An if (!service) composition branch is usually functional and belongs at warn" (AGENTS.md, "Degradation log levels — warn vs error"); ADR-0093 D5 is correctly the precedent for refusing to boot a requested-but-degraded state ("Default: refuse to boot. Exit non-zero with an actionable error naming the package, the flag, and the two remedies"). Both costs are stated and both are real. See R5-N1 for one quotation worth adding.
  • (c) merge question 18 (declared members are positions only; drafter-derived) — closed. ADR-0131 D3 says what the record says it says ("Positions, permission sets and capabilities are definitions, and a definition lives only in the environment registry"; "assignments are organization-level" in its heading); D4 ("every reference to one … names it by its machine name"); ADR-0086 D1's table row ("Sharing rule | hybrid | Rule shape = metadata; recipients naming a real team/queue = config"). The alternative's costs ("organization ids inside sealed package metadata that resolve on no other install, and a config binding inside an artifact the organization cannot edit, only clone") follow from ADR-0131 D2 (declared, never materialised) and are honest; the parallel to D5's assignTo: user at warning is the record's own existing treatment.
  • Nit, ADR-0019 wording — closed: ADR-0019 says "moved nearly verbatim under the node, not rewritten", which the record now quotes.
  • Nit, D7 item 4 — closed, and true to packages/lint/src/validate-org-axis-red-lines.ts: its table intercepts only business_unit and unit_and_subordinates (BU_TREE_RECIPIENT_TYPES), lists position as a flat expansion that "②" lets past, and its hint names user / team / position as the sanctioned audiences on a tenancy.enabled: false object; so a declared queue whose members are positions is admitted at lint time, and only a BU-kind member can be refused, which the item now says happens at runtime for authored queues.

R5-F1 — must-fix (wrong fact at the premise of a merge question the seat asked for). The data API already has If-Match optimistic concurrency, a spec key and a 409 for it; merge question 16 says exposing a conditional update would add all three.

Evidence. Merge question 16: "Exposing it on the data API (a conditional update, If-Match-like, through REST and the SDK) would give every app optimistic concurrency — a real want — at the price of a new request vocabulary through every door, a 409 every client must learn to handle, a spec key and ledger entry of its own". At 5f5f0b42: packages/rest/src/crud-routes.ts, the PATCH /data/:object/:id handler — "OCC: clients opt in by sending either the standard If-Match header or an expectedVersion field in the JSON body", and the DELETE handler — "same opt-in protocol as PATCH (If-Match header or expectedVersion query string)"; packages/spec/src/api/protocol.zod.ts, UpdateDataRequestSchema.expectedVersion — "Optimistic concurrency token (typically the updated_at value the client read). When provided, the server compares it against the current record version and returns 409 CONCURRENT_UPDATE if they differ"; packages/metadata-protocol/src/protocol.ts#ConcurrentUpdateError — code = 'CONCURRENT_UPDATE', status = 409, carrying currentVersion and currentRecord; CONCURRENT_UPDATE is in packages/spec/src/api/error-code-ledger.zod.ts; the SDK door forwards the token (packages/runtime/src/domains/data.ts). So the vocabulary, the key, the code and the client handling all exist on every door today. What does not exist is atomicity: the check is compare-then-write in the protocol layer — updateData probes the row (probeRecord), runs assertVersionOf over that probe ("the COMPARISON half, over the row the existence probe has already read. A token that agrees (or no token) issues no query of its own"), then calls engine.update — so two writers that both read the same updated_at can both pass. That is the primitive D4 says the engine lacks, and the record's Context §1 paragraph on concurrency primitives names updateMany and "no atomic increment" but not this protocol, which is the one existing conditional-update contract on the data path.

Why it matters. The maintainer is being asked to choose "engine-internal" over an alternative described as expensive and new; the real alternative is cheap and old — lower the existing expectedVersion check onto the same precondition (expect: { updated_at: TOKEN }), which would make today's optimistic concurrency atomic for every client already sending If-Match, at the cost of moving the comparison from the protocol layer into the write: updateData's probe-first ordering (404 wins over 409, #4435), the token normalisation (normaliseVersionToken, #13382) and the #23586 rule that a conflict re-reads the row under the caller's context before it reports currentVersion / currentRecord would all have to be preserved around a driver-level zero-row result. Either answer is defensible; the question as written compares against something that does not exist.

Fix. (1) Context §1, the "Concurrency primitives" paragraph: add the existing protocol in one sentence — expectedVersion / If-Match on PATCH and DELETE, compared in updateData before engine.update, not atomic — so D4's "no such primitive" reads as the measured gap it is. (2) Rewrite merge question 16: the fork is "the precondition stays engine-internal, and the existing expectedVersion check stays where it is" versus "the existing check is lowered onto it and becomes atomic"; the recommendation can stay engine-internal, with the honest cost that the data API's optimistic concurrency remains compare-then-write and the honest benefit that nothing on the data path changes shape on a queue card. Drop "new request vocabulary", "a 409 every client must learn", "a spec key … of its own". (3) D4: say the existing expectedVersion check is untouched by the PR.

R5-F2 — should-fix (ADR-0112 D1/D3, which the record binds itself to). The lost claim's 409 is RESOURCE_CONFLICT while the ledger already has CONCURRENT_UPDATE for the same condition.

Evidence. D9: "RESOURCE_CONFLICT 409 for a lost claim race". ConcurrentUpdateError's message is "Record was modified by another user" — a lost claim is exactly that, on the same by-id update verb, through the same REST door, where clients already branch on CONCURRENT_UPDATE. Two 409 codes for "another writer got there first" on one verb is the vocabulary split ADR-0112 D1 exists to prevent.

Fix. D9 names CONCURRENT_UPDATE for the lost claim (and D4's "the record was taken" becomes its message), or states the distinction that earns a second code — for example that the claim's conflict is on a field predicate rather than a version token and must not carry currentRecord. Recommend the first.


R5-N1 — nit. Merge question 17 paraphrases AGENTS.md ("puts a functional degradation at warn"); the sentence it departs from is sharper and worth quoting, since it names this exact case: "An if (!service) composition branch is usually functional and belongs at warn". Quoting it makes the override visible as an override, which is what a merge question is for.


What the delta got right (so the fix round need not re-verify): merge questions 17 and 18 and their cross-references; the ADR-0019 quotation; D7 item 4 against the lint table; D4's "engine-internal … reachable from no data door" is true as a description of the PR's design (the REST door builds its update request from named fields — object, id, data, expectedVersion, context — and updateData composes the engine options itself, so no caller-supplied option reaches engine.update today). Nothing else in the record changed, and the round-4 verification of the rest stands; no new contradiction found against ADR-0019, 0049, 0057, 0058, 0078, 0086 D1, 0090, 0093 D5, 0105, 0111, 0117, 0118, 0131 D2/D3/D4 or 0138.

Round 6 — delta review of e765768

Verdict: PASS WITH FIXES

Reviewed: docs/adr/0142-ownership-queue-membership-claim-and-assignment-rules.md at e765768 (branch claude/project-thread-eypatm, committed; working tree clean; delta from e08c90e is 23 insertions / 12 deletions: one sentence in Context §1, two in D4, one in D9, merge question 16 rewritten, merge question 17's quotation). Code read at origin/main 5f5f0b4221d9.

Summary. R5-F1, R5-F2 and R5-N1 are closed and every new sentence is true to the code: the existing If-Match / expectedVersion check is described as what it is (a protocol-layer compare before engine.update, not atomic), merge question 16 now compares the real two options with the real costs, D4 leaves the existing check untouched, the lost claim's CONCURRENT_UPDATE 409 fits both the ledger and the REST arm's shape, and the AGENTS.md quotation is verbatim. No must-fix, no should-fix. One nit on a ledger mechanic the PR will meet, folded with one half-sentence of precision; the record can go to the maintainer with it applied or not.

Gates (worktree root, at e765768):

  • node scripts/check-adr-symbol-anchors.mjs — EXIT=0 (2658 anchors across 144 records resolve; the new packages/metadata-protocol/src/protocol.ts#ConcurrentUpdateError anchor resolves to the class as described; the record's hotcrm: anchors are reported cross-repo, not judged).
  • node scripts/check-adr-links.mjs — EXIT=0 (807 relative links resolve).
  • House rules: no HTML comments, no model names, no path:NNN anchors, codes SCREAMING_SNAKE; no residual RESOURCE_CONFLICT, "new request vocabulary" or "spec key … of its own"; Chinese rulings unchanged.

Closure of round 5

  • R5-F1 (merge question 16's premise) — closed. Context §1's new sentence is correct on every clause: PATCH and DELETE take an opt-in If-Match header or expectedVersion token (packages/rest/src/crud-routes.ts, both handlers; packages/spec/src/api/protocol.zod.ts, UpdateDataRequestSchema / DeleteDataRequestSchema.expectedVersion), answer 409 CONCURRENT_UPDATE when it differs from the row's updated_at (ConcurrentUpdateError: code = 'CONCURRENT_UPDATE', status = 409; the protocol's own words, "expectedVersion that does not match the current record's updated_at"), compared in updateData over the existence probe before engine.update (probeRecord → assertVersionOf → engine.update; the assertion is "the COMPARISON half, over the row the existence probe has already read … issues no query of its own"). Merge question 16's two options are now the real ones, and the three things the alternative must preserve are each in the code: the probe-first ordering ("404 wins over 409 when both could apply", updateData), token normalisation (normaliseVersionToken, rest/OCC: postgres 驱动下乐观锁必现假冲突 409 —— normaliseVersionToken 对 Date 做 String() 丢毫秒后与 ISO 字符串严格比较 #13382, "Both tokens are normalised to ONE representation before they are compared"), and the conflict's re-read under the caller's context before it reports currentVersion / currentRecord (metadata-protocol: a 409 CONCURRENT_UPDATE carries currentRecord as read with a system context (probeRecord), so a caller who sends a stale version may receive a row, or fields, that row- or field-level security hides from them #23586: readAsCaller then throw new ConcurrentUpdateError({ currentVersion, currentRecord: visible, … }), recordNotFoundError when the caller cannot read the row). Costs on both sides are stated and honest. D4 says the existing check is untouched by the PR and points at the question.
  • R5-F2 (one 409, not two) — closed. D9 and D4 name CONCURRENT_UPDATE for the lost claim with "the record was taken" as its message. This fits the shape at both ends: ConcurrentUpdateError's constructor takes message? beside currentVersion / currentRecord, and the REST arm in packages/types/src/data-error-classification.ts keys on error?.code === 'CONCURRENT_UPDATE' || error?.name === 'ConcurrentUpdateError', answers status: 409 with the error's own sentence, and adds currentVersion / currentRecord only when present — so an engine-raised envelope carrying the code, 409 and that message, with neither field, takes the curated arm and reports no winner's row (the metadata-protocol: a 409 CONCURRENT_UPDATE carries currentRecord as read with a system context (probeRecord), so a caller who sends a stale version may receive a row, or fields, that row- or field-level security hides from them #23586 posture). The code is in the ledger (@objectstack/metadata-protocol, and the classification table's own row under @objectstack/types). See R6-N1 for the one ledger mechanic that follows.
  • R5-N1 (quote AGENTS.md's exact sentence) — closed; the quotation matches AGENTS.md's "Degradation log levels" section across its line wrap: "An if (!service) composition branch is usually functional and belongs at warn".

R6-N1 — nit. D9 says the lost claim uses "the ledger's existing code", which is true of the code and not quite of the row: the ledger is keyed by the package that stamps the code, and a package that stamps a code it carries no row for needs a PROVENANCE_WAIVERS entry ("Why the stamping package carries no row — recorded so provenance is a decision, not drift", packages/spec/src/api/error-code-ledger.zod.ts). CONCURRENT_UPDATE sits under @objectstack/metadata-protocol and @objectstack/types; the claim's 409 is raised in @objectstack/objectql, which carries no row for it. The Codes bullet's opening "registered in the ledger by the PR" covers it in spirit; make it explicit — "the PR adds the row under @objectstack/objectql" — so the provenance gate is met by decision rather than by surprise. In the same pass, Context §1's "compared in updateData" can read "in updateData / deleteData" — the delete door's comparison is assertVersionMatch, which reads the row itself; both are compare-then-write, as the sentence says.


No new contradiction. The changed sentences agree with D4's precondition ("no such primitive … today" is sharpened, not weakened, by naming the non-atomic check), with D5's cursor, with D9's one-409 rule, with Consequences ("exact only where a driver implements updateMany") and with merge questions 6 and 7. Nothing outside the diff changed; the round-5 verification of merge questions 17 and 18, D7 item 4 and the ADR-0019 quotation stands.

Round 7 — final delta review of 05ae041a

Verdict: PASS

Reviewed: docs/adr/0142-ownership-queue-membership-claim-and-assignment-rules.md at 05ae041a (branch claude/project-thread-eypatm, committed; working tree clean; delta from e76576846bc2 is 6 insertions / 3 deletions in the one file, three hunks: Context §1's concurrency sentence, D9's Codes bullet, merge question 16's second sentence). Code read at origin/main 5f5f0b4221d9.

Summary. R6-N1 is applied as asked, the three edited sentences are true to the code, and nothing else changed. No finding. The record is ready for the maintainer.

Gates (worktree root, at 05ae041a):

  • node scripts/check-adr-symbol-anchors.mjs — EXIT=0 (2658 anchors across 144 records resolve; the record's hotcrm: anchors are reported cross-repo, not judged).
  • node scripts/check-adr-links.mjs — EXIT=0 (807 relative links resolve).
  • House rules: no HTML comments, no model names, no path:NNN anchors, codes SCREAMING_SNAKE; Chinese rulings unchanged.

Closure of round 6

  • R6-N1 — closed, both halves.
    • D9 now reads "the ledger is keyed by the stamping package, and the PR adds the row under @objectstack/objectql, where the claim's 409 is raised". True to packages/spec/src/api/error-code-ledger.zod.ts: the ledger is a map of owner keys to code arrays ('@objectstack/objectql': [ is one of them), and a package that stamps a code it carries no row for must appear in PROVENANCE_WAIVERS ("Why the stamping package carries no row — recorded so provenance is a decision, not drift"); CONCURRENT_UPDATE sits today under @objectstack/metadata-protocol and @objectstack/types only, and D4 places the claim's 409 in the engine's lowering of the precondition, so the objectql row is the right addition.
    • Context §1 now reads "compared in updateData (over the existence probe, before engine.update) and in deleteData (assertVersionMatch, reading the row itself)". True to packages/metadata-protocol/src/protocol.ts: deleteData calls this.assertVersionMatch(request.object, request.id, request.expectedVersion, request.context) before it builds the engine delete, and assertVersionMatch is composed from probeRecord and assertVersionOf (its body in protocol-record-reads.ts), so it performs its own read where updateData reuses the existence probe. Both are compare-then-write, as the sentence concludes.
    • Merge question 16 now reads "compared in updateData / deleteData before the engine write (Context §1)", which matches the corrected Context §1 sentence and the two doors it names.

No new factual error in the three hunks; no other line of the record changed (git diff e76576846bc2..05ae041a is exactly the three hunks above). The round-6 verification of the rest of the record stands.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 05ae041a202efd400df3e82d54a0ef035dfc9348
Local-runs: none
Scope: docs/adr/0142-ownership-queue-membership-claim-and-assignment-rules.md + packages/spec/src/api/protocol.zod.ts, packages/rest/src/crud-routes.ts, packages/runtime/src/domains/data.ts, packages/metadata-protocol/src/protocol.ts, packages/metadata-protocol/src/protocol-record-reads.ts, packages/types/src/data-error-classification.ts, packages/spec/src/api/error-code-ledger.zod.ts, packages/lint/src/validate-org-axis-red-lines.ts, AGENTS.md, docs/adr/0019-approval-as-flow-node.md, docs/adr/0086-authz-metadata-config-boundary-and-cross-package-composition.md, docs/adr/0093-tenancy-mode-and-membership-lifecycle.md, docs/adr/0112-error-code-vocabulary-and-ledger.md, docs/adr/0131-total-organization-ownership-no-null-organization-id.md

Delta review. The governing record for the previous head 0ae85b0a3f37ef589c22aac1487aec32685ae848 is comment 6399715745 (PASS, three should-fix merge questions a/b/c, two nits). This record judges the three commits since — e08c90e9, e7657684, 05ae041a — and whether the earlier judgments still hold. The delta 0ae85b0a..05ae041a is +51 / −13 in the one file; the net diff against main at this head is still one added file (+860 / −0), no package, schema, changeset or other ADR moves. Code was read at the record's own measurement base 5f5f0b42 and every anchor below re-checked against main at abebc4e1: of the cited files only protocol.ts (hunks far from the OCC path) and the error-code ledger (one unrelated NO_ACTIVE_ORGANIZATION row) changed in between, and every anchor this review relies on survives. The dev's own independent rounds 5–7 (comment 6400014400) were read after the code, not instead of it.

Check-runs on this head at review time: 53, every one completed, none failed, none still running. success: Governed Surface Queue Guard, Check PR Size, Check Documentation Links, Part-of PR must not also close its card, The card this PR closes must claim this branch, No other open PR may claim the same issue, No other open PR may claim the same single-writer path (the last four ran twice, on the push and again on a later PR event), Test Core 1–8 of 8 and the roll-up, Dogfood Regression Gate, Lint · docs and code guards, Lint · CI/release/engine gates, Lint · repo and tooling gates, Lint · PM dispatch-gates, Lint · scoped (tooling self-tests, slot lookup and corpus, query-options erasure), Lint · ESLint, Lint and Repo Gates, Type Check · workspace / source gates / consumer gates / debt ledger, TypeScript Type Check, Auto Label, filter. skipped: Check Changeset (skip-changeset), the UI lanes, Build Core, Build Docs, Console Pin Gate, Temporal Conformance, dogfood shards, Dogfood Verify CLI, the two opt-in harnesses, and the second-event Auto Label / Check Changeset / Check PR Size. Combined status: no statuses posted. No review submitted on the PR.

① Derived judgments

(a) Merge question 16 — is expect reachable from the data API, or engine-internal? Answered correctly, and the premise the dev's round 5 corrected is now right against the code.

  • The data API already has optimistic concurrency, as the record now says. UpdateDataRequestSchema.expectedVersion and DeleteDataRequestSchema.expectedVersion are in packages/spec/src/api/protocol.zod.ts; packages/rest/src/crud-routes.ts reads If-Match or the body field for PATCH and If-Match or the query parameter for DELETE and forwards expectedVersion on the typed request; the SDK door (packages/runtime/src/domains/data.ts) forwards the same token. ConcurrentUpdateError in packages/metadata-protocol/src/protocol.ts carries code = 'CONCURRENT_UPDATE', status = 409, currentVersion, currentRecord and an optional message; its own docblock says it is "an application-level compare-and-set — not an atomic storage-layer CAS".
  • "Compared in updateData (over the existence probe, before engine.update) and in deleteData (assertVersionMatch, reading the row itself)" — holds. updateData runs probeRecord → recordNotFoundError on a miss ("404 wins over 409 when both could apply") → assertVersionOf(object, id, current, expectedVersion, context) → engine.update. deleteData runs assertVersionMatch(object, id, expectedVersion, context) before engine.delete; its docblock says it "probes ONLY when the caller actually opted into OCC", so "reading the row itself" is exact for a token-bearing delete. assertVersionOf compares against current.updated_at after normaliseVersionToken on both sides, and on a mismatch re-reads the row as the caller (readAsCaller) before throwing — the three things merge question 16 says the alternative must preserve (probe-first ordering, token normalisation, the caller-context re-read for currentVersion / currentRecord) are each in the code.
  • "Reachable from no data door" — holds. Both doors build the update request from named fields (object, id, data, expectedVersion, context); updateData composes the engine options itself (where, context, the two observer callbacks); no caller-supplied option reaches engine.update, and no expect option exists in engine.ts today. So the record's description of the PR's design — the gate and the assignment seam set the precondition on the operation context, nothing on the wire can — is a true statement about the only two paths that reach the by-id update.
  • The fork is now the real one. Recommendation: precondition engine-internal, existing expectedVersion check untouched (D4 says so explicitly). Alternative: lower the existing check onto expect with the row's updated_at as the value, which would make today's OCC atomic for every client already sending the token. Costs on both sides are stated and honest; "lowering it later is additive" is true because the protocol-layer comparison would simply move, not change shape on the wire. This is a drafter choice correctly carried to the maintainer's list with its alternative.

(a′) The lost claim's 409 moved from RESOURCE_CONFLICT to CONCURRENT_UPDATE — correct against the ledger and the REST arm. CONCURRENT_UPDATE sits in packages/spec/src/api/error-code-ledger.zod.ts under @objectstack/metadata-protocol and @objectstack/types and under no other owner key; the @objectstack/objectql section carries no row for it. The file's own rule — the provenance gate "fails any stamp site of a registered code that the stamping package's own owner key does not list", with PROVENANCE_WAIVERS the only other sanctioned shape — is what D9's "the ledger is keyed by the stamping package, and the PR adds the row under @objectstack/objectql" paraphrases; D4 raises the claim's 409 in the engine, so the objectql row is the right addition. On the wire, packages/types/src/data-error-classification.ts keys its arm on error.code === 'CONCURRENT_UPDATE' or the error's name, answers status: 409 with the error's own message, and adds currentVersion / currentRecord only when present — so an engine-raised envelope carrying the code and "the record was taken" takes the curated 409 arm with no winner's row attached. ADR-0112 D1 (one spelling per condition) is the right rule for choosing the existing code over a second 409 on the same verb; the earlier record's note that RESOURCE_CONFLICT "is in the standard catalog" was true and is superseded by this better fit. No residual RESOURCE_CONFLICT anywhere in the record.

(b) Merge question 17 — the boot refusal named as a choice against a loud warning. Answered correctly. The AGENTS.md quotation is verbatim across its line wrap ("An if (!service) composition branch is usually functional and belongs at warn", Degradation log levels section). ADR-0093 D5 is correctly the precedent: "Default: refuse to boot. Exit non-zero with an actionable error naming the package, the flag, and the two remedies". Both costs are real: a composition that ships the package without plugin-sharing cannot boot until it drops the keys or adds the plugin; the warning leaves ADR-0078's inert instance alive behind one log line. The reason the degradation is not "functional" — every rule-covered insert parks nowhere and every claim fails closed — is a consequence of D1's fail-closed posture and D5's never-block-intake rule, so the override of the AGENTS.md default is visible as an override, which is what a merge question is for. D1 cross-references it.

(c) Merge question 18 — positions-only declared members, marked drafter-derived. Answered correctly. ADR-0131 D3's heading reads "the catalog is environment-level, assignments are organization-level" and its body makes positions "definitions" that live "only in the environment registry"; D4 says every reference to a catalog item "names it by its machine name"; ADR-0086 D1's table row reads "Sharing rule | hybrid | Rule shape = metadata; recipients naming a real team/queue = config". The record says exactly these three things and labels the result "Drafter-derived, not ruled". The alternative (any of the five kinds with os lint at warning, the treatment D5 gives a declared rule's assignTo: user) and its costs (organization ids inside sealed package metadata that resolve on no other install; a config binding inside a clone-only artifact) follow from ADR-0131 D2. D1 cross-references it.

The two nits — closed. ADR-0019 line 97 reads "moved nearly verbatim under the node, not rewritten"; the record now quotes "moved nearly verbatim under the node". D7 item 4 now says a declared queue's members are positions, "a flat kind the table already admits on a platform-global object", so the queue recipient is admitted at lint time, with BU-kind members of an authored queue refused at runtime by the same predicate — true to validate-org-axis-red-lines.ts, whose table intercepts only BU_TREE_RECIPIENT_TYPES (business_unit, unit_and_subordinates), lists position as a flat holder expansion, and whose hint names user / team / position as the sanctioned flat audiences on a tenancy.enabled: false object. The vacuous clause is gone.

Did anything else move in a way that decides something the ruling did not? No. Every other hunk is a cross-reference ("merge question 16/17/18") or the Context §1 fact sentence verified above. The merge-question list runs 1–18 and the three new entries are the three the earlier record asked for; nothing in TL;DR, D1–D3, D5–D8, Consequences, Alternatives, Open items, Execution plan or References changed. Status stays Proposed; the record still implements nothing and edits no other ADR. The code-vocabulary change (a′) is a correction toward an existing ledger code under ADR-0112 D1, with the provenance consequence stated — not a new choice the maintainer must be asked about separately.

Do the earlier record's judgments still hold? Yes, every one, with the single supersession noted at (a′): the contract claims in D1–D9 against the cited code, the ruling and triage-grade quotations, the ADR-numbering check, the internal-consistency pass, and the "decides only what D6 = A and the triage order allow" conclusion are unaffected by a delta that adds three merge questions and corrects one code name.

② Semver level

Level: none for this PR. Unchanged from the governing record: one new file under docs/adr/**, nothing published from any released package; skip-changeset is right and Check Changeset is correctly skipped on this head. The record's Execution plan still says the later spec + server PR takes minor on every package it names (now including the CONCURRENT_UPDATE ledger row under @objectstack/objectql and the QUEUE_NOT_FOUND row), which is the correct level for that phase and not this one.

The two declaration lines. The PR body still carries Clause-②: yes / Metadata-protocol: added, copied from the claim. For this docs-only diff the truthful lines are Clause-②: no / Metadata-protocol: none, as the governing record said and as the docs-only ADR precedent #25717 carried; the dev offered the seat-side edit and the seat makes it (no diff change; no gate reads the lines on a PR with no changeset). Still not a merge blocker. Seat note on adoption: the seat made this edit in the same pass as the body refresh below (relay issue_patch, run 40211332089, read back identical); the live body now reads Clause-②: no / Metadata-protocol: none.

③ Boundary flags

  1. PR body is stale against the new head — seat-side edit, in the same pass as the two declaration lines. The body's "What changed" table says "15 merge questions" and "(822 lines)"; the 维护者速读 says 15 条 and lists 1–15; the "Review record" names rounds 1–4 and 0ae85b0; "Gates" names head 0ae85b0. At 05ae041a the record has 18 merge questions and 860 lines, and the independent review ran to round 7 (comment 6400014400). Fold in: 18 merge questions with 16–18 one clause each in the 维护者速读 (16 前置条件只在引擎内部、不开放到数据 API,现有 If-Match 乐观并发不动;17 没有队列服务时拒绝启动而不是告警;18 包声明的队列成员只能是岗位), 860 lines, rounds 5–7 and the final PASS at 05ae041a in the Review record. The body is the maintainer's first read on a Tier H PR, so the count should match the record it introduces. Seat note on adoption: done in the same relay issue_patch (run 40211332089) from the dev's handoff text: 18 条 with items 16–18, 860 lines, Review record rounds 1–7, Gates at 05ae041a, and the HotCRM card as a post-merge item.
  2. Dispatch gates at the new head. The 20 derived gate commands were run at 0ae85b0; at 05ae041a the dev re-ran check-adr-symbol-anchors and check-adr-links only (both exit 0, per 6400014400). The change set is the same single path, so the derived command list is unchanged, and CI's Lint · docs and code guards, Lint · repo and tooling gates, Lint · PM dispatch-gates and Check Documentation Links are green on this head. Noted; nothing owed.
  3. For the spec + server PR, not this record (carrier: the one PR under Execution plan): whether the claim's CONCURRENT_UPDATE envelope carries currentVersion / currentRecord is an implementation choice the record leaves open — the REST arm adds them only when present, and the loser of a claim race has already passed a positive sharing verdict on the row, so either answer discloses nothing new. Noted so the PR decides it rather than inherits it.
  4. Earlier record's ③ items 1–11 (write budget, declaration lines, reviewer request, PR-activity subscription, branch, merge-commit trailer, pre-push rewording, HotCRM read-only clone, the merge questions as the maintainer's, HotCRM STOPGAP card filed after the maintainer's merge with Blocked-by: and the published-release unblock condition in words, out-of-scope findings) — unchanged by this delta; the three new merge questions join item 9's "carry them unchanged into the final 维护者速读". Nothing new to file.

Implemented-by: claude/project-thread-eypatm
Reviewed-by: session_01K2Tat8JEt4AhLASV4V4QWE

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

维护者速读

改了什么
新增 ADR-0142(Proposed,只有这一个文档文件,860 行),按 Tesla 的 D6 = A 把「所有权队列」定成契约:队列实体(元数据声明 + sys_queue 行)、成员(岗位等集合型接收人)、认领(就是走现有所有权门禁的 owner_id 更新,不另开动词)、声明式分派规则(条件 → 队列或用户,轮询 / 最少负载两种命名策略),以及弃用的 queue 审批人类型和保留的 queue 分享接收人在实现落地时一起重新开放。本 PR 不改代码;spec + server 在你批准形状后由同一张卡继续做。

为什么改
HotCRM 现在用 587 行的应用代码给无主的工单和线索做轮询分派(代码里自己标着 STOPGAP),还要绕开所有权转移门禁。平台缺这个原语。

风险与代价(含回滚)
只改文档,回滚 = 撤销合并。后续的 spec + server 是 L 级,跨 plugin-sharing、plugin-approvals 和引擎。

席位意见
建议合并。起草在 Fable 上跑,独立的 Fable 完整复核共七轮,最后一轮通过;席位的 Fable 契约复核做了两次,都通过,第二次是在补三条合并问题之后。请重点看合并问题 1(队列放在 owner_id 留空 + 新列 queue)、13(成员插入时点名队列)、14(谁能认领)和 16–18(这一轮新补的三条)。

你要做的
读 ADR 末尾的 18 条合并问题(PR 正文的「你要做的」有中文摘要)。同意就批准或直接合并本 PR(批准账户为 os-zhuang / hotlong)。合并后席位再立 HotCRM 删 STOPGAP 的卡,并派 spec + server。

席位核对

Seat domain:spec#4 (#18917) · session_01K2Tat8JEt4AhLASV4V4QWE · head 05ae041a202e · 2026-10-25T16:31Z · verdict ACCEPT (Tier H: stays draft, the maintainer merges).

  1. Shape. Draft, base main, Part of #25769: the card stays open for the spec + server phase. One file under docs/adr/**, +860/−0; labels documentation, size/l, skip-changeset, plus needs-user-decision (this pass); reviewers hotlong, os-zhuang. The body's declaration lines were corrected by the seat to Clause-②: no / Metadata-protocol: none (docs-only, precedent docs(adr): ADR-0141 — an app may depend on another installed app: declared in manifest.dependencies, checked once before install, co-owning the namespace it shares #25717), together with the dev's refresh of the 维护者速读, What-changed row, Review record and Gates (relay issue_patch runs 40211332089 and a second for the sentence under them, both read back identical).
  2. Independent full review (the owner's rule 「spec skills adr 都需要fable 完整的复核」): seven rounds at CONTRACT_REVIEW_TIER, separate from the drafter; final PASS at this head, 6400014400 (rounds 1–4: 6399544349, 6399553138).
  3. Contract review of record: PASS at 0ae85b0a (6399715745) and the delta PASS on this head (6400184767), both at CONTRACT_REVIEW_TIER. Every claim about today's code was read in the cited code; the ADR decides only what D6 = A and the triage order allow; the three drafter choices the first review found undeclared are now merge questions 16–18.
  4. Gates. Head-pinned check-runs on 05ae041a202e: 53 completed, 37 success, 16 skipped, none failed. Dev dispatch-gates --commands 20/20 exit 0 at this head and again on a local merge with main.
  5. Accepted as is: two extra review-record writes beyond the order's budget (the review rule needs them). For the spec + server phase, carried as a note: whether the lost claim's 409 carries currentVersion / currentRecord is left open by the ADR.
  6. Landing. Stays draft, no auto-merge, no queue. It lands by the maintainer's merge, or by an authorized APPROVED review from GOVERNED_APPROVERS, after which the seat lands it. After the merge, the seat files the HotCRM STOPGAP-delete card and dispatches the spec + server phase; neither happens this shift.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Batch #392 governed-draft line · maintainer 「同意」 2026-10-26T04:56Z

Recorded by the director seat (summon #51, session_01KrKXQVvgYk2AJ4VkqMobyb, ledger objectstack#12708). This PR was presented to the maintainer in batch #392 as the governed-draft line (ADR-0142, Tier H docs/adr/**, the four pieces complete: seat record 6400211070, 维护者速读, review requested from hotlong and os-zhuang, head 05ae041a green). The maintainer's reply to the batch was 「同意」. That word is recorded here verbatim; it is not an APPROVED review and nothing on this PR changes by it. The landing act stays the maintainer's: an authorized APPROVED review by os-zhuang or hotlong by hand, or a direct merge. This seat's account is an approver and submits no review. After the merge the domain:spec#4 seat files the HotCRM STOPGAP-delete card and dispatches the spec + server phase, as 席位核对 6400211070 states.

@os-zhuang
os-zhuang marked this pull request as ready for review October 26, 2026 06:09
@os-zhuang
os-zhuang added this pull request to the merge queue Oct 26, 2026
Merged via the queue into main with commit 38050ce Oct 26, 2026
66 checks passed
@os-zhuang
os-zhuang deleted the claude/project-thread-eypatm branch October 26, 2026 06:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants