Repository navigation
审批回写(系统身份)不触发共享规则物化,「批准后团队看不见」——平台只记一条日志、无补偿、无声明式手段 #13533
Description
Activity
实测补充:
restart to backfill属实(干净样本)环境:hotcrm @
1e40d16,@objectstack/*17.1.0,磁盘 SQLite(better-sqlite3→.objectstack/data/objectstack.db)。步骤:
- rep 提交一条请假;
- 经审批节点由 manager 批准(
POST /api/v1/approvals/requests/:id/approve)——状态由平台以系统身份回写; - 另一名 rep 查询:看不到该记录(criteria 共享规则
status == "approved"未物化); - 重启 dev server;
- 同一 rep 再查:可见。
结论:平台在
plugin-sharing日志里给出的补偿路径(re-evaluate rules or restart to backfill)经实测成立——重启那一半确实能补齐。附一次无效的对照说明(避免误读)
更早一次测试中,测试者在重启前已对该规则调用过
POST /api/v1/sharing/rules/:id/evaluate,样本在重启前即已可见,那一次不能作为「重启可补齐」的证据。上面这次是重新构造的干净样本(一条全新的、未经任何evaluate的远期记录)。
这条补充不改变原单的诉求:补偿路径存在不等于问题不存在——它仍需要有人知道要做这件事,而日志里那一行是目前唯一的提示,文档与测试均无说明。
Generated by Claude Code
⚖️ RULED — 维护者,2026-08-31,总监席第 5 场决裁批 #2,verbatim 「同意」(采方向 1,定性 Bug)
裁定:系统写参与逐记录共享物化 —— 删除
plugin-sharing两个钩子里的isSystem跳过,⛔ 不加声明式开关、不以文档代修。 定性为 Bug:共享规则声明的语义(status == "approved"⇒ 团队可见)是已发布承诺,钩子跳过使 declared≠enforced;这与同日 #13491 的裁定同病同判——isSystem(平台是操作者)不得被当作「后果不需要发生」的毯式静音器。执行要点:
- 删除
bindRuleHooks()两处if (ctx?.session?.isSystem) … return(:5110-5113/:5126-5129@ 发行包坐标,源码坐标派发时重取),noteSystemWriteSkipped通道随之退役; - 批量路径先测量后处置:census 哪些系统写路径(seed / import / backfill / 迁移)会命中带活跃共享规则的对象及其量级;若实测存在放大,批量路径以收尾一次
evaluateAllForRecord批算替代逐记录,⛔ 不得以未测量的性能恐惧保留跳过; - 钉子双向:审批回写(
lockRecord: true系统写)后共享立即物化 + 原「跳过」行为的 pin 反转登记; - 复现路径按卡面最小路径 + 独立实测评论(hotcrm 干净样本)作验收锚。
状态转换(同笔):摘
needs-user-decision→pm:queue;type 按本裁定记 Bug;domain:*归分诊铸造(预期domain:services,plugin-sharing 落点)。席位边界:裁不派。
Generated by Claude Code
- 删除
- addedbugSomething isn't workingSomething isn't workingand removed
on Aug 31, 2026 补充取证:影响面天然排除持
viewAllRecords: true的主体——被咬的是「只能看自己 + 靠共享规则看别人」的那一类角色同一载体(
objectstack-ai/hotcrm,钉@objectstack/* 17.1.0)的后续只读观察,为正文补上一处空白:审批人自己受不受影响。未改任何代码、配置或声明;未调用evaluate。正文的取证只覆盖了「同团队的另一位员工看不到」。下面这组对照说明影响面取决于读路径,而读路径取决于 profile 上的授予。
三身份同一时刻的只读对照
同一个对象
crm_leave_request,同一时刻,三个身份分别查询:身份 profile 关键位 可见条数 其中含 pending的那条主管( sales_manager,本例的审批人)viewAllRecords: true,modifyAllRecords: false9 可见 普通员工( sales_rep,非 owner)viewAllRecords: false,readScope: 'own'8 不可见 记录 owner 本人 — 9 可见 为什么这组对照是决定性的
那条共享规则的条件是
status == 'approved'。而被观察的那条记录当时是pending——共享规则不可能把它共享给主管。主管却看得见它。 因此主管的读路径根本不经过共享规则,走的是 profile 上的
viewAllRecords: true。(该 profile 源码里的注释亦自陈此授予的用途是让主管浏览团队的申请。)再推一步:既然主管在记录还没被批准时就已经读得到它,那么「批准」这个动作只能改变它的
status(从而决定它过不过按状态筛选的视图),不可能改变主管能不能读到它。结论(供分级参考,不预设修正方向)
- 持
viewAllRecords: true的主体不受本单现象影响——本例中审批人恰好属于这一类,所以「审批人自己看不见刚批准的记录」不会发生。 - 受影响的是「只能看自己 + 依赖共享规则看别人」的那一类角色(本例的
sales_rep)。正文里那位「同团队的另一位员工」正是这一类。
也就是说,本单现象的可观测性依赖于观察者的角色:用主管视角去复现会看不到问题,必须用一个不持
viewAllRecords的普通成员视角。这一点对复现步骤与后续验证都有影响,故记录在此。
Generated by Claude Code
- 持
⚠ 对上一条补充取证的更正与降级:其中一句是推断不是实测,且它依赖的一个前提已被推翻
上一条评论(三身份对照)里,有一句必须撤回到"待核"。分清楚哪些站得住、哪些不站得住:
仍然站得住(实测,未变)
- 三身份同一时刻的可见条数对照(主管 9 / 普通员工 8 / owner 9),以及其中那条
pending记录只有主管与 owner 看得见; - 由此得出的读路径判断:共享规则的条件是
status == 'approved',它不可能共享一条pending记录,而主管却看得见 → 主管的读路径不经过共享规则,走 profile 上的viewAllRecords: true; - 新增实测数据(本单此前没有任何数字):以主管身份从应用界面点批准(捕获到
POST /api/v1/approvals/requests/<id>/approve),未调用evaluate、未重启,该记录在批准后 18 秒内已出现在按status == approved筛选的视图上。⚠ 严格说这只证明「≤18 秒会出现」,未测出精确延迟——观察发生在 18 秒那个时点,无法分辨它是第 1 秒还是第 17 秒出现的。
⚠ 撤回到"待核"的那一句
上一条评论末尾写道:
「受影响的是「只能看自己 + 依赖共享规则看别人已批准」的那一类角色(本例的
sales_rep)。正文里那位「同团队的另一位员工」正是这一类。」加粗那句是推断,不是实测,且它依赖的前提刚被推翻。
- 它不是实测:上述对照只测到
sales_rep看不见一条pending记录(那本就符合共享规则的条件,不构成本单现象)。「sales_rep看不看得到一条刚批准的记录」并未在本轮实测中验证。 - 它依赖的前提已被推翻:该推断建立在"正文原始取证是以
sales_rep身份观察的"之上。而记录该次取证的会话今日自查后更正:当时的换人操作只清了 cookie,而会话令牌存放在localStorage,换人静默失败、日志上一切正常——那次观察实际使用的身份存疑。
因此对正文的影响,如实标注
正文描述的现象本身不受影响(批准后看不见、调一次
evaluate即恢复、返回matchedRecords:4 / expandedUsers:2 / grantsCreated:2),那是当场记录的接口输出。但"观察者是谁"这一项需要重新确认,而它恰恰决定了本单的影响面描述。尤其:若原始观察实际也是主管身份,则与本次"主管批准后 18 秒内即可见"的实测相互矛盾,说明还有本单尚未识别的变量。
已请原取证会话回查其记录并回报,结果出来后我再补一条,无论结论指向哪边。在那之前,请不要把上一条评论里的影响面推断当作已确认事实。
Generated by Claude Code
- 三身份同一时刻的可见条数对照(主管 9 / 普通员工 8 / owner 9),以及其中那条
销账:上一条"待核"已核完 —— 观察者身份成立,影响面描述成立,证据链补齐如下
上一条评论把「正文那位『同团队的另一位员工』是
sales_rep这一类」撤回到待核,理由是它依赖的身份前提被一次换人机制缺陷推翻。已回查原始取证记录,结论是:那个前提在这批取证上不成立坍塌——观察者确实是sales_rep。 上一条的撤回予以恢复,并把当时缺的证据链补在这里。一 · 那个换人坑对本单取证不适用(这是关键)
被推翻的是另一个场景:一个 SPA 页面把会话令牌存放在
localStorage,在同一个浏览器上下文里换人时只清了 cookie、没清 storage,于是换人静默失败。本单的核心取证不走那条路:
- 「
eu.rep看不到那条已批准记录」「evaluate后可见」「重启后干净复测」——三件全部通过curl完成,每个身份各自一个独立的 cookie jar(-c建 jar、-b用 jar),彼此不共享。纯 HTTP,根本不加载 SPA,也不存在"换人"这个动作——服务端按每次请求携带的会话 cookie 认人。 - 因此每一次观察都是一个独立的新请求,不受任何前端缓存或"页面不自动刷新"影响。
二 · 屏上可见的身份证据:有
浏览器侧的那批取证,每个脚本新开浏览器 + 新上下文、只登录一次
eu.rep,同样没有"换人"动作。身份印在屏上:- 当时抓取的页面文本,头部为
PREVIEW / HotCRM / Leave Request / Team Leave Calendar / Search… / ⌘K / 9+ / **ER**; - 当时留存的三张截图右上角头像逐张复核,全部是
ER(sales_rep),不是主管的缩写。
三 · 两条互相独立的旁证
- 同一时刻、同一查询,不同身份返回的记录集不同:admin 视角 4–5 条,
sales_rep视角 3 条,evaluate之后sales_rep视角变 4 条。同一个会话不可能返回两个不同的结果集。 - 结构性排除:本单现象(看不见一条已批准的他人记录)只可能发生在不持
viewAllRecords的主体身上。主管的 profile 明写viewAllRecords: true,admin 更是全见,owner 看得见自己的——一个"看不见已批准记录"的观察者,按定义不可能是主管或 admin。
附旁证:该sales_rep的"我的申请"视图当时只有 1 条(他自己的一条病假),主管/admin 视角不可能是 1 条。
四 ·
evaluate那次调用的完整口径(正文未写清,补此)项 事实 调用身份 admin(不是那位 sales_rep,也不是主管)规则 srule_af868c1f-3b8a-40ae-aec8-78c6b6b6289e=leave_request_approved_team_sharing_sales_rep返回 {"matchedRecords":4,"expandedUsers":2,"grantsCreated":2,"grantsUpdated":0,"grantsRevoked":0},HTTP 200紧接的复核 用该 sales_rep自己的 cookie jar 重新查询 → 4 条,此前不可见的那条已可见 ✔五 · 与"主管批准后自己立刻看得见"并不矛盾
前一条评论报告的「主管以界面批准后、≤9 秒内自己就在按状态筛选的视图上看见了那条记录」,与本单现象同时为真,中间没有未识别的变量——因为主体与路径都不同:
- 本单现象的主体 = 依赖 criteria 共享规则物化的普通成员(
viewAllRecords: false); - 那次实测的主体 = 审批人本人,走 profile 级
viewAllRecords: true,根本不经过共享规则。
结论
正文的现象、机制判定与影响面描述均成立。 影响面可表述为:本单现象只对"不持
viewAllRecords、依赖共享规则看他人已批准记录"的角色可观测;持viewAllRecords的主体(含本例的审批人与 admin)不受影响,用他们的视角去复现会看不到问题。
Generated by Claude Code
- 「
- addedpriority:p1High: required for production / M2High: required for production / M2
on Aug 31, 2026 分诊补车道 →
domain:services· p1 · bug。状态pm:queue保持(2026-08-31 01:41 裁决同笔已转)。锚定。 采纳裁决的预期落点:
plugin-sharing的bindRuleHooks()⇒domain:services。定级 p1。 裁决自己给了理由,我照它判:共享规则声明的语义(
status == "approved"⇒ 团队可见)是已发布承诺,钩子里的isSystem跳过使 declared ≠ enforced。这与同日 #13491 同病同判 ——isSystem(平台是操作者)不得被当作「后果不需要发生」的毯式静音器。失败方向是 fail-closed(该看见的人看不见),所以不是 p0;但它落在一条声明式权限承诺上,且现场是真实业务流程实跑,不是 p2。⭐ 这条卡的评论链是本轮读到的最好的一条,其中一处直接改变派单
评论区做了一件多数卡不会做的事:先给结论,再自己撤回到「待核」,再补完整证据链销账。 具体是 —— 「正文那位『同团队的另一位员工』属于不持
viewAllRecords的那一类」这句被作者自己降级为推断(因为它依赖的身份前提被一次「只清 cookie、令牌在localStorage」的换人静默失败推翻),随后回查原始取证、确认核心取证走的是各自独立 cookie jar 的curl(根本不加载 SPA,不存在「换人」动作),把结论恢复并补齐了四条独立旁证。⛔ 这不是流程洁癖,它决定了本卡能不能被验收。 结论是:
本单现象只对「不持
viewAllRecords、依赖共享规则看他人已批准记录」的角色可观测。持viewAllRecords的主体(含本例审批人与 admin)不受影响 —— 用他们的视角去复现会看不到问题。⚠️ 派单必须带上这条复现约束。 一个用主管或 admin 身份验证修复的 dev 会看到「一切正常」,然后判定已修复。同理,评论里那条「主管界面批准后 ≤18 秒内可见」与本单现象同时为真且不矛盾 —— 主体与读路径都不同(走 profile 级viewAllRecords,根本不经过共享规则)。⛔ 不要把它当作「问题不存在」的反证。裁决执行要点,原样进派单
- 删除
bindRuleHooks()两处if (ctx?.session?.isSystem) … return(发行包坐标:5110-5113/:5126-5129,⚠️ 源码坐标派发时重取,行号会漂),noteSystemWriteSkipped通道随之退役。⛔ 不加声明式开关、⛔ 不以文档代修 —— 两条都被裁决明确否掉了。 - 批量路径先测量后处置:census 哪些系统写路径(seed / import / backfill / 迁移)会命中带活跃共享规则的对象及其量级。若实测存在放大,批量路径以收尾一次
evaluateAllForRecord批算替代逐记录。⛔ 不得以未测量的性能恐惧保留跳过 —— 这句是裁决原话,也是这一步唯一的判据。 - 钉子双向:审批回写(
lockRecord: true的系统写)后共享立即物化 + 原「跳过」行为的 pin 反转登记。 - 验收锚:卡面最小路径 + hotcrm 干净样本那条独立实测评论(重启可补齐、
evaluate后matchedRecords:4 / expandedUsers:2 / grantsCreated:2)。
⚠️ 补偿路径存在不等于问题不存在 —— 立单方这句要保留在派单里。re-evaluate rules or restart to backfill经实测成立,但它只存在于一行日志中,文档与测试均无说明;一个搭建者不会知道自己需要做这件事。修复不得以「已有补偿路径」为由缩水。⚠️ MCP 读卡提示:本卡正文通过 MCP 读取时被截断(止于POST /api/v1/data/)。派发前请直接在网页上读全文,⛔ 不要以 MCP 返回的正文为准 —— 这正是 #13573 记录的那条截断缺陷。
Generated by Claude Code
- 删除
17 remaining items
Seat acknowledgement of the A ruling — patch round 1 dispatched, carriers clear after it
Ruling read and adopted as recorded by the director seat (13533#issuecomment-5511791709, maintainer verbatim 「#13564 转维护者处理;其他同意」 on decision batch #11, this card item 1, recommendation A).
needs-user-decisionwas stripped in the same stroke by that seat;pm:dispatchedstays while PR #14528 is open;needs:contract-reviewis this seat's to clear, and it clears with the round below.The contract review's DECISION verdict is now read as PASS. Its one unresolved item was ruling point 2's disposition, which the maintainer has settled: no boot-phase skip predicate, the census plus cost-equivalence measurement closes the point,
meta resyncstays per row and is carried by #14530. Zero blocking findings stand; the review's own text is unchanged and remains the record.Patch round 1 covers the review's non-blocking §5 notes and nothing else (no code change to the three removed skips, no new pins, no changeset level change):
- note 2 —
content/docs/permissions/system-context.mdxrow 30's "Lose: nothing" glosses the one shape where the cascade's delivery is the queued orphan sweep rather than a synchronous revoke (record-share-cascade.ts:384-400, the unbounded system-delete shape); the cell says "delivered, deferred on the unbounded shape". - note 3 —
bu-tree-recompute.ts:209-210still describesbindRuleHooks' materialisation skip as current. That file was deliberately untouched by this PR, so the seat widens the claimed file surface by exactly this docblock: a sentence that this change makes false is the change's own to correct, and leaving it is the declared-not-enforced shape the card is about. - note 4 — the changeset gains one sentence telling operators that seed and import-time system writes on rule-covered objects now pay per-record evaluation at write time, and "unexported" at
:32becomes "not exported from the package entry point" (the file's own:41-42already says it correctly). - note 5 — the PR body records the comment-only edits (
sharing-plugin.ts,boot-backfill.test.ts,bulk-recompute.test.ts) and thebeforeDeletestash-ordering side effect the review named in ① item 8.
note 1 (the hotcrm acceptance anchor of ruling point 4) is not takeable by this seat — the repository is unreachable from this session, tracked as #14362; the in-repo member-perspective pin remains the anchor and the gap stays named. note 6 is the seat's: the
meta resyncpointer goes on #14530 in a separate comment.The round also merges
origin/main— the PR readsmergeable_state: dirtyagain on the census page, whose merge driver refuses a text merge and requirespnpm gen:system-context-censuson the merged tree.After the round:
needs:contract-reviewcomes off this card and PR #14528 in one stroke (read back),node scripts/pm/check-clause2-carriers.mjs --pair 14528re-run, and the PR goes ready + auto-merge once every check is green on the new head. No delta review is owed — the ruling resolved the DECISION, and the round is documentation only; the seat verifies on the tree that it is.
_Generated by Claude Code
Generated by Claude Code
- note 2 —
os-dev-report
{ "issue": 13533, "status": "done", "branch": "claude/issue-13533-system-write-sharing-materialization", "pr": "https://github.com/objectstack-ai/objectstack/pull/14528", "premise_still_valid": true, "summary": "Documentation-only patch round 2 on the already-open PR #14528 — no new implementation, and the three removed isSystem skips, the reversed pins and the changeset level are untouched. Merged origin/main (it had moved to 20b883918) through the os-regen sequence: the one conflict was the MIXED census page content/docs/permissions/system-context.mdx, resolved by taking row 36 from this branch and row 37 from main — each side's own row — then committing the merge BEFORE regenerating; pnpm gen:system-context-census rewrote 0 anchors on the merged tree and check:system-context-census is OK, which is the proof rather than inspection. Then the review's non-blocking notes: note 2 (census row 30 now says the revoke is delivered but deferred on the unbounded shape), note 3 (the stale bu-tree-recompute.ts docblock now says what bindRuleHooks does after this change and that this file's own hooks never carried an isSystem branch), note 4 (one operator sentence in the changeset plus 'unexported' -> 'not exported from the package entry point'; level stays patch), note 5 (the PR body now records the comment-only edits and the beforeDelete stash-ordering side effect, in a new Patch round 2 section that keeps Patch round 1). No code path changed. New head e9b612a7a. A re-fetched merge-tree against origin/main 2aa8456cf is clean before the push.", "tests": "All through scripts/pm/os-verify-lock.sh, every exit code captured before any pipe, verdict lines quoted from the gates themselves. pnpm --filter @objectstack/plugin-sharing test -> 'Test Files 30 passed (30)' / 'Tests 729 passed (729)', os-verify-lock VERDICT command-exit 0. The delta from the PR's 718 is entirely main's: this round's diff touches no test file, and main's #14580 added a net 11 cases across share-link-eligibility.test.ts and share-link-service.test.ts (15 it( lines added, 4 removed); 718 + 11 = 729. pnpm --filter @objectstack/plugin-sharing typecheck -> clean, VERDICT command-exit 0, with 'check:test-typecheck: OK — @objectstack/plugin-sharing's test layer compiles under packages/plugins/plugin-sharing/tsconfig.test.json; 2 file(s) / 3 error(s) / 3 pinned signature(s) held in test-typecheck-debt.json' — the same pre-existing entries. pnpm check:system-context-census -> 'check-system-context-census --self-test: all cases passed' then 'check-system-context-census: OK — 106 elevation read sites in 20 packages across 45 files, all anchored; 140 anchors resolve, 27 declared non-read.' pnpm check:doc-authoring -> exit 0, four clean legs (388 files, 41 published skill files, 14500 customer-facing strings across 710 spec sources, 831 pinned sibling-prose sites with no growth and no burn-down unrecorded). node scripts/check-changeset-no-major.mjs -> 'This diff introduces no major bump.' node scripts/check-adr-0087-registration.mjs -> 'check-adr-0087-registration: this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen).' node scripts/check-empty-changeset.mjs -> 'No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added).' pnpm check:nul-bytes -> 'check-nul-bytes: OK (scanned 7996 text file(s) -- 7996 tracked, 0 untracked-not-ignored; skipped 7 binary; no raw ASCII control bytes).' pnpm check:engine-double-contract -> 'check-engine-double-contract: OK — 754 pinned, 134 in the DEBT ledger, 3 exempt.' (753 in round 1; main added one row, this round added none.) Gate family re-derived on the FINAL head with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands -> 69 commands over 10 paths (round 1: same count over 9; the tenth path is bu-tree-recompute.ts and it brought no additional command). Run in 7 foreground batches under the container cap, no batch hit exit 143: 64 GREEN, 5 NOT MEASURED — check-test-completeness (exit 3, 'Nothing was measured'), check:skill-examples ('packages/client-react/dist holds no .d.ts declarations'), check:dual-build-cjs-loads (exit 3, 'PREREQUISITE NOT MET'), check:i18n ('PREREQUISITE NOT MET — the workspace CLI is not built'), check:type-check-debt (exit 3, 'This is NOT a pass and NOT a finding') — the same five as round 1, each declaring its own unmet repo-build prerequisite that CI satisfies. NO ABLATION IS OWED and none was run: no code behaviour changed, so there is no red a mutation could produce, and a leg would measure the previous round's code. That claim is measured, not asserted: the round's own diff (merge commit 25d0307c2 -> head e9b612a7a) is 3 files — the changeset, one census-page cell, one docblock — and every added or removed line in the only .ts file among them matches '^[+-] \\* ' (a JSDoc body line), with 0 lines not matching. For the PR as a whole, sharing-plugin.ts and boot-backfill.test.ts each have 0 non-comment changed lines against origin/main.", "mcp_calls": "0 — the session's repo-scoped REST probe returned 200 (GET /repos/objectstack-ai/objectstack/pulls/14528), so every read and write in this round went through the core REST bucket; no MCP GitHub tool was called.", "open_questions": [], "out_of_scope_findings": [ "filed as #14671: os-regen-merge.sh step 1 misreports a MIXED os-regen conflict as 'conflicts in NON-generated files' and forbids the hand-resolution the driver itself just requested (finding label, unassigned)" ] }``` ### Round detail **New head: `e9b612a7a`** (was `376c04e00`). PR #14528 stays **draft**; `mergeable_state` moved `dirty` -> `blocked` (checks pending), and a re-fetched `git merge-tree --write-tree --name-only origin/main HEAD` against `2aa8456cf` is clean. **The four items.** 1. **note 2 — `content/docs/permissions/system-context.mdx`, row 30.** The cell's "Lose: nothing" now reads "Lose: nothing permanently — the revoke is **delivered, but deferred on the unbounded shape**", and names the shape: when the deleted ids are enumerable the cascade revokes inline; when they are not (a predicate delete whose row set the stash could not resolve) it hands the reclaim to a queued background orphan sweep, so the share rows outlive the deleted records until that sweep runs. It adds that no surviving record loses access either way and a restart re-runs the same sweep. **Prose only** — no anchor and no count was hand-edited; the generator owns those, and it rewrote 0 anchors afterwards. 2. **note 3 — `packages/plugins/plugin-sharing/src/bu-tree-recompute.ts` docblock.** The false sentence ("Its skip is about grant MATERIALISATION, which the boot backfill re-does anyway") is gone. It now states that `bindRuleHooks` no longer skips system writes — the `afterInsert` / `afterUpdate` materialisation skips and the `before*` stash skip that fed them are removed, and the one skip it keeps is `afterDelete` revocation, which `record-share-cascade.ts` delivers — and that this file's own hooks never carried an `isSystem` branch to skip with. That last claim was checked against the file's whole history, not just the head: `git log -S isSystem --follow` on it shows the only `isSystem` line ever added is its `SYSTEM_CTX` constant. This is the exact docblock the seat widened the claimed file surface for; nothing else in the file moved. 3. **note 4 — `.changeset/system-write-sharing-materialization.md`.** One operator sentence added: seed- and import-time system writes on rule-covered objects now pay per-record sharing evaluation at write time — the cost a user write of the same shape has always paid, with the `kernel:bootstrapped` backfill still reconciling behind it. "unexported" now reads "not exported from the package entry point". **Level stays `patch`**, and `check-adr-0087-registration` confirms no declared-breaking changeset. 4. **note 5 — the PR body.** A new **Patch round 2** section (Patch round 1 kept verbatim) records: the comment-only edits the body omitted (`sharing-plugin.ts` docblock corrections, the `boot-backfill.test.ts` header, the `bulk-recompute.test.ts` `ADMIN_SESSION` comment), and the behavioural side effect the review named — with the `before*` stash skip removed, the `beforeDelete` stash now runs for system deletes on rule objects, so the rule package (`priority: 180`, `rule-hooks.ts:177`) resolves the row set first and the cascade (`priority: 190`, `record-share-cascade.ts:283`) reads the stashed answer (`bulk-recompute.ts:304-305`); cost-neutral, one resolve either way. The body was read back in full after the PATCH: byte-identical apart from one blank line GitHub collapsed at the tail. **The merge, and how the page was proven.** `origin/main` was `20b883918`; the trial `git merge-tree` named exactly one deferred path, `content/docs/permissions/system-context.mdx`. `scripts/pm/os-regen-merge.sh` performed step 1 and stopped on that conflict (see the finding below — its message misclassifies this case). One hunk, two rows, opposite owners: **row 36** is this branch's (its `sharing-plugin.ts` anchor moved +11 by this PR's own comment lines), **row 37** is main's (#14580 landing #14033 rewrote the prose — link *creation* is bypassed, redemption is not — and moved five `share-link-service.ts` anchors). Each side's own row was taken, the merge was **committed first** (never regenerate in MERGE state), and only then `pnpm gen:system-context-census` ran on the merged tree: **`check-system-context-census --fix: 0 anchor(s) rewritten`**, i.e. the hand resolution already agreed with the tree, followed by `check-system-context-census: OK — 106 elevation read sites in 20 packages across 45 files, all anchored; 140 anchors resolve, 27 declared non-read.` Both sides' content is present on the merged page (main's row-37 redemption sentence; this branch's row-30 rewrite and rough edge 2), and the branch's deliverable is intact — `rule-hooks.ts` carries exactly one `isSystem` skip, the `afterDelete` one at `:292`. **No code path changed, and here is how that was verified.** The round's own diff (merge commit `25d0307c2` -> head `e9b612a7a`) is three files: the changeset, one census-page table cell, one docblock. `git diff -U0` on the only `.ts` file among them yields 7 removed and 11 added lines, **all 18 matching `^[+-] \* `** — a JSDoc body line — with **0** lines not matching. So the non-comment diff of this round outside the changeset and the two prose files is empty. Because nothing about behaviour moved, **no ablation is owed and none was run**; a leg would have measured the previous round's code rather than this one's. **Gate result.** Family re-derived on the final head (`node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands`): **69 commands over 10 paths**, run in seven foreground batches under the container cap (no batch hit exit 143). **64 green; 5 NOT MEASURED**, each declaring its own unmet prerequisite rather than a finding — `check-test-completeness` (exit 3, "Nothing was measured"), `check:skill-examples` ("`packages/client-react/dist` holds no .d.ts declarations"), `check:dual-build-cjs-loads` (exit 3, "PREREQUISITE NOT MET"), `check:i18n` ("PREREQUISITE NOT MET — the workspace CLI is not built"), `check:type-check-debt` (exit 3, "This is NOT a pass and NOT a finding"). Identical to round 1's five. Every exit code was read before any pipe, and each verdict above is the gate's own line, never a bare `$?`. **Clause ② unchanged at `yes`.** This round adds and removes no export; the PR's single removed export (`SYSTEM_WRITE_SKIP_NOTICE`) is unchanged, and `packages/spec/**` is untouched. `needs:contract-review` is still hanging on **both carriers** — this card and PR #14528 — for the seat to clear; this round did not touch a label. **Out-of-scope finding — filed as #14671** (`finding`, unassigned). `scripts/pm/os-regen-merge.sh` step 1 exits with "merge stopped on conflicts in NON-generated files … ⛔ Do not resolve generated files textually" when the only conflicted path is a `merge=os-regen` path — the MIXED census page — which the driver had just asked to be hand-resolved two lines earlier. Both halves of the message are wrong for that class. Dedup was done on the REST channel before filing: 487 open issues enumerated completely (6 pages, last page 17 rows, `next` links rewritten from GitHub's numeric-id form, which this session's proxy refuses), keyword-scanned over title and body, with two positive controls that both hit (#13533 and #14530). **One platform reading for the seat.** REST paginated listing needs its `Link: rel="next"` rewritten from `api.github.com/repositories/{id}/…` to `api.github.com/repos/{owner}/{repo}/…`; the session proxy answers the numeric-id form with `{"message":"Numeric-ID repository paths … are not supported through this proxy"}` at **HTTP 200**, so an unrewritten follow silently truncates the enumeration at page 1 and looks like a complete read. Caught here only because the parse of that page failed for an unrelated reason. --- _Generated by [Claude Code](https://claude.ai/code)_Unlocked —
Blocked-by: #14648is satisfied, and theUnlock-actionline has been executed.domain:servicesseat (sessionsession_01AUF1NoViznQK32gqpK8wS8), 2026-09-03 04:11Z.This card was flipped to
pm:blocked37 minutes ago on a measured threshold. Both halves of the unlock condition now hold, verified rather than assumed:- Queue-flake anchor: test/run-dev-unbuilt-workspace.e2e.test.ts #14648 is
closed. - The fix is on
main: PR test(cli): make the unread-reader ceiling a load-independent constant at RUN_TIMEOUT_MS #14715 merged asaccb9231c, andpackages/cli/test/run-dev-unbuilt-workspace.e2e.test.ts:230now readsconst UNREAD_HARD_CAP_MS = RUN_TIMEOUT_MS;onorigin/main(checked withgit show origin/main:…, not from the PR).
Per this card's own
Unlock-action: re-check PR #14528, the action is a re-check, not a re-dispatch. Done: PR #14528 ismergeable_state: clean, all its own checks green on unchanged head645b14e9e, and it is back in the merge queue at 04:11:13Z. Labels flippedpm:blocked→pm:dispatchedwith a comparative read-back (UNLOCK WRITE CORRECT: True).⚠️ This is a test, not a prediction, and my own recorded expectation is against it. Thedomain:engineseat's measurement (2026-09-03T02:24Z on #14648) showed #14715's own queue build failing with the raised ceiling in place — the child ran 180105 ms against a 6125 ms baseline on the same runner, ~29×, which is a stall that no constant ceiling closes. I adopted that reading in correction 73 and withdrew "wait for #14715" as a strategy.So I expect this attempt may fail like the previous five. It is still the right attempt, for a reason that has nothing to do with hope: the base materially changed. The previous five ejections tested this PR against a
mainwithout the fix; this one tests it against amainwith it. That is a different experiment, not a sixth repetition of the same one — which is exactly the line the merge-queue-triage bot's checklist draws, and the only thing that distinguishes a legitimate re-queue from the blind kind.If it ejects again on the same signature, this card goes back to
pm:blockedand the block is then against a closed anchor, which is a materially worse situation worth escalating rather than re-queueing.
Generated by Claude Code
- Queue-flake anchor: test/run-dev-unbuilt-workspace.e2e.test.ts #14648 is
os-dev-report
{ "issue": 13533, "pr": "https://github.com/objectstack-ai/objectstack/pull/14528", "round": "R21 measurement-only (no code change, no commit, no branch push)", "status": "done", "branch": null, "premise_still_valid": false, "verdict_on_hypothesis": "REFUTED", "prediction_written_before_any_run": "Indistinguishable distributions. Read statically first: the case-5 child is `tsx packages/cli/bin/run-dev.js i18n extract nope.ts`, which is the run-the-CLI-from-source shim, NOT a dev-server boot; nothing seeds and no database is opened. `@objectstack/plugin-sharing` appears in packages/cli/src only as a STRING in serve.ts's lazy plugin registry (line 1573), never as a static import, so it should not be in the graph oclif Config.load walks. Predicted: no measurable difference, hypothesis refuted. Secondary prediction: because the shim's 15 s no-progress bound is armed on every write, a child alive at 180 s must be blocked somewhere the bound cannot reach.", "premise_check": "The dispatch's premise sentence 'The test boots a dev server in an unbuilt workspace, a dev boot seeds' is FALSE for this test. The child runs the i18n:extract command with @objectstack/spec's dist masked by a resolve hook. Measured: child stderr contains 0 occurrences of seed / SeedLoader / sharing across 144706 bytes. The PR's subject (seed- and import-time system writes paying per-record sharing evaluation) has no execution path in this child.", "trees_compared": { "baseline": "4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 - the SECOND PARENT of the PR head, i.e. the exact main the PR merged", "head": "645b14e9eeb0d898ba2ecd52c020a0564d1cc10d", "deviation_from_dispatch": "The dispatch said 'current origin/main'. Current origin/main (2263ca4d6) is 46 commits ahead of what the PR merged, which would have put 46 unrelated commits inside the A/B. Using the merge's parent 2 instead makes the two trees differ by EXACTLY the 10 PR files, verified with `diff -rq --exclude=.git`: zero unrelated drift.", "build_state": "Both trees fully built (`turbo run build --filter=!@objectstack/docs`, 72/72 tasks successful each), matching CI's built checkout." }, "reachability_measurement": { "instrument": "NODE_V8_COVERAGE on the real case-5 child, counting script entries and max execution range count for plugin-sharing/dist.", "positive_control": "node -e \"await import('@objectstack/plugin-sharing')\" in the same tree: scriptEntries 1, entriesWithExecution 1, maxRangeCount 8. The instrument CAN see this module.", "same_child_sanity_control": "@oclif/core/lib/config/config.js in the child's own coverage: scriptEntries 1, entriesWithExecution 1, maxRangeCount 58. The coverage file is real and populated.", "reading": "plugin-sharing/dist in the case-5 child: scriptEntries 0, entriesWithExecution 0. The specifier is RESOLVED at 1649 ms (a resolve hook sees it) but the module is never compiled and never executed, because the importing module dies on the masked @objectstack/spec first.", "artefact_size": "head's plugin-sharing dist/index.mjs is 292445 bytes vs baseline 293388 - head is 943 bytes SMALLER, not larger." }, "interleaved_runtime_table_ms": { "note": "Each row is one case-5 child (spawn, stderr piped, pipe never read, SIGKILL at cap), reproduced out of band from runCliAgainstDeadReader. Order was alternated (main-first / head-first) after batch A. Every non-hang run exited code 2, signal null - the value the test asserts. Hangs exited code null, signal SIGKILL.", "A_warm_cap180": { "main": [4412, 4433, 4552, 4572, 4622, 4768, 19442, 19496, 19500, 19578], "head": [4481, 4521, 4571, 19479, 19488, 19493, 19570, 19577, 19759, 180009], "hangs": "main 0/10, head 1/10", "caveat": "That single 180009 is head's FIRST-EVER run in the session, so its tsx compile cache was cold for head's paths while main's had already been warmed by my earlier probes. That is an artefact of my own run order, not a tree effect - see the cold-cache batches, where the same failure reproduces on main at the same rate." }, "B_warm_cap60": { "main": [4456, 4490, 19451, 19571, 19580, 19715], "head": [4391, 19495, 19514, 19566, 19651, 19666], "hangs": "main 0/6, head 0/6" }, "C_warm_with_byte_accounting_cap180": { "main": [4691, 19428, 19448, 19501, 19517, 19746], "head": [4393, 4654, 19511, 19544, 19598, 19643], "hangs": "main 0/6, head 0/6" }, "D_contended_8_concurrent_cap120": { "main": [12834, 12892, 13105, 13118, 13120, 13219, 13332, 27624], "head": [11564, 12195, 12925, 13064, 13208, 13269, 13358, 28520], "hangs": "main 0/8, head 0/8" }, "E_cold_tsx_cache_cap180": { "main": [5596, 180011], "head": [180012, 180013], "hangs": "main 1/2, head 2/2" }, "F_cold_tsx_cache_cap45": { "main": [45009, 45009, 45010, 45010, 45011], "head": [45008, 45009, 45009, 45011, 45011], "hangs": "main 5/5, head 5/5", "note": "Cap lowered to 45 s for this batch only, to buy samples. Normal maximum on this box is 19.8 s, so a child alive at 45 s has already blown the shim's 15 s bound twice over." }, "pooled": "main 6 hangs / 37 runs, head 8 hangs / 37 runs. Restricted to cold cache: main 6/7, head 7/7 - the failure is a property of the cache state, not of the tree.", "bimodality_explained": "Runtimes are bimodal at 4.4-4.8 s and 19.4-19.8 s, separated by exactly the shim's 15 s STDERR_DRAIN_STALL_MS. Byte accounting settles which: a 4.x s run absorbed the FULL 144706 bytes and kept both the lead line and oclif's own 'not found'; a 19.x s run absorbed only 133.8-139.5 KB, lost both, and paid the bound. This container's unread stderr socketpair absorbs about 146 KB (measured with a 1 MB write against a parked reader: 146176 bytes), so the child's 144706-byte output sits on roughly 1 percent of headroom. That is the coin flip, and it lands the same way on both trees.", "byte_identity": "Every fast run on BOTH trees absorbed exactly 144706 bytes with 116 ModuleLoadError blocks. Truncated runs on both trees land on the same values (133937, 134541, 139513, ...). The PR does not move the child's stderr volume by a single byte." }, "vitest_runs_pass_fail": [ { "tree": "baseline", "cache": "warm", "line": "Tests 10 passed (10)", "test_files": "1 passed (1)", "duration_s": 46.36 }, { "tree": "head", "cache": "warm", "line": "Tests 10 passed (10)", "test_files": "1 passed (1)", "duration_s": 46.30 }, { "tree": "baseline", "cache": "warm", "line": "Tests 10 passed (10)", "test_files": "1 passed (1)", "duration_s": 46.63 }, { "tree": "baseline", "cache": "warm", "line": "Tests 10 passed (10)", "test_files": "1 passed (1)", "duration_s": 46.24 }, { "tree": "head", "cache": "warm", "line": "Tests 10 passed (10)", "test_files": "1 passed (1)", "duration_s": 46.40 }, { "tree": "head", "cache": "warm", "line": "Tests 10 passed (10)", "test_files": "1 passed (1)", "duration_s": 46.73 }, { "tree": "baseline", "cache": "cold", "line": "Tests 10 passed (10)", "test_files": "1 passed (1)", "duration_s": 30.82 }, { "tree": "head", "cache": "cold", "line": "Tests 10 passed (10)", "test_files": "1 passed (1)", "duration_s": 47.05 } ], "vitest_command": "pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 test/run-dev-unbuilt-workspace.e2e.test.ts, each under scripts/pm/os-verify-lock.sh; every run printed 'os-verify-lock: VERDICT command-exit 0'.", "why_the_suite_passes_even_cold": "Cases 1-4 drain their children normally and WARM the tsx cache before case 5 runs. So inside one suite run the cold-cache condition and the never-read condition do not coincide. That is also, most likely, why the merge queue is intermittent rather than always red: whether case 5's child meets a warm cache depends on what the shard compiled before it.", "what_the_stuck_child_was_doing": { "process_tree": "Three processes when the cache is cold: the tsx launcher, the tsx WORKER that actually runs run-dev.js, and an esbuild service process. Only two when the cache is warm - no esbuild service is spawned.", "the_worker_while_hung": "state S (sleeping); wchan sock_alloc_send_pskb; /proc/PID/syscall = '1, 0x2, ...' which is write(2) on file descriptor 2, i.e. stderr; CPU frozen - 534 ticks at t=4 s and 546 ticks at t=38 s, about 0.12 s of CPU across 34 s, and in the 180 s runs 526 ticks at t=6 s and 542 ticks at t=178 s. It is GENUINELY BLOCKED, not slow, and it makes no progress of any kind.", "the_one_bit_that_decides_it": "/proc/PID/fdinfo/2 flags. Cold cache (hangs): 'flags: 02000002' - O_NONBLOCK CLEAR, the fd is in BLOCKING mode. Warm cache (exits on its own at about 19.6 s): 'flags: 02004002' - O_NONBLOCK SET. Same instrument read the bit both ways in the same session on both trees, so a 'clear' reading is not an instrument failure.", "why_the_shim_bound_cannot_save_it": "STDERR_DRAIN_STALL_MS is enforced by a setInterval inside writeStderr, i.e. it lives ON THE EVENT LOOP. A blocking write(2) parks the main thread BELOW the event loop, so the interval can never fire - which the frozen CPU counter independently confirms, since 20 wakeups per second for 170 s would be visible. The bound can cap a wait that is on the loop; it cannot cap a wait that has taken the loop away. bin/run-dev.js already refuses process.stderr._handle.setBlocking(true) for exactly this reason, but stderr is in blocking mode here anyway, so something other than the shim cleared the bit.", "most_probable_cause_INFERENCE_not_measurement": "All three processes share ONE open file description for fd 2 (same inode: 90157 on the baseline run, 87823 on the head run). O_NONBLOCK is a property of the open file description, so it is shared. The blocking mode appears exactly when the esbuild service process is spawned with that fd inherited, which is consistent with libuv clearing O_NONBLOCK on inherited stdio at spawn. I MEASURED the shared inode, the cleared bit and the correlation with the esbuild process; I did NOT measure the flag immediately before and after that spawn, so the attribution to libuv is inference, not measurement.", "consequence_for_the_test": "Once the bit is clear the child cannot exit at all, so any finite ceiling reports SIGKILL. Raising the ceiling a third time would not close this: the wait is unbounded in the strict sense - 180 s, 45 s and 40 s caps all produced the identical picture." }, "summary": "The hypothesis is refuted, with the mechanism named. The case-5 child never boots a dev server, never seeds, and never executes plugin-sharing at all (V8 coverage: 0 script entries against a positive control of 1 with execution count 8), and the PR's built artefact is 943 bytes smaller than baseline. Across 74 interleaved out-of-band children the two trees are indistinguishable, and the failure the merge queue sees reproduces on the PR's OWN BASELINE at the same rate as on the head - 6 of 7 cold-cache runs on baseline, 7 of 7 on head, byte-for-byte identical output. The real failure is that when the tsx compile cache is cold, tsx spawns an esbuild service process sharing stderr's open file description, O_NONBLOCK is cleared on it, and the worker then parks forever in a blocking write(2) to the full stderr socketpair - below the event loop, where run-dev.js's 15 s no-progress bound cannot reach. PR 14528 neither causes nor worsens this.", "tests": "All heavy runs via `bash scripts/pm/os-verify-lock.sh -c ...`; every one printed 'os-verify-lock: VERDICT command-exit 0'. Builds: 72 successful, 72 total on each tree. Test file: 8 runs, all 'Test Files 1 passed (1)' / 'Tests 10 passed (10)'. Exit codes captured by redirect-then-read, never across a pipe. Out-of-band children: 74 runs, per-run numbers in the table above.", "not_measured": [ "I could not make the test file itself go red on either tree. Every one of the 8 vitest runs passed, warm and cold, because cases 1-4 warm the tsx cache before case 5 runs. The out-of-band harness is where the failure reproduces, so the merge-queue red is NOT reproduced through the real instrument here - only through a faithful reconstruction of case 5.", "The libuv attribution above is inference. A before/after read of the O_NONBLOCK bit across the esbuild spawn would settle it and I did not take one.", "Merge-queue runner conditions (six-way sharding, that hardware) were approximated with 8 concurrent copies on this container, not reproduced." ], "mcp_calls": "2 - this comment and its read-back. No other MCP GitHub call was made this round.", "open_questions": [ { "question": "This round forbade me from acting on a fix, so this is recorded rather than done: what should bound the unbounded wait, given the bound cannot live on the event loop?", "options": [ "A. Leave the ceiling alone and treat the red as environmental. Cheapest, but the queue keeps paying rebuilds and the next round re-litigates the ceiling for a third time.", "B. Have the harness, not the child, own the give-up: case 5 already SIGKILLs at the cap, so make the SIGKILL the expected product for the never-read reader and assert what is asserted today only for the closed-reader path. Changes what the case pins.", "C. Make the child immune by keeping stderr non-blocking regardless of who inherits it - e.g. re-arm O_NONBLOCK in the shim after Config.load, or stop the child spawning a service process that inherits stderr. Fixes the defect rather than the instrument, but is a change to bin/run-dev.js and to tsx's environment.", "D. Give the never-read child a stderr that cannot fill - a socket the harness drains into a sink but does not deliver, which preserves 'the reader never returns the bytes' while removing 'the reader never reads'. Weakens exactly the property case 5 exists to pin, so I do not recommend it." ], "recommendation": "C first, B as the fallback. Long-term soundness dominates: the child hanging forever on a full stderr is a real defect in the shipped shim's failure path, not merely a test artefact - the same binary runs `os serve` and `os dev`, and the shim's own docblock already reasons about blocking stderr stalling the loop. A ceiling change (A) is the move that has already failed twice by the file's own record, and D removes detection. But note C is a code change on packages/cli, which this round forbade and which another lane owns - it needs its own card and its own writer." }, { "question": "Should PR 14528 stay blocked on this test at all?", "options": [ "A. Unblock it - the measurement exonerates it and the break is pre-existing on its own baseline.", "B. Keep it queued behind a fix for the hang, so the queue is not re-entered into a known-flaky gate." ], "recommendation": "A on the evidence, since 7-for-7 against a break that reproduces identically on the PR's own merge base is not evidence about the PR. Whether to re-enter a queue whose gate is flaky is a scheduling call that is yours, not mine." } ], "out_of_scope_findings": [ "Not filed as issues - this was a measurement round and filing would have needed a dedup search I was not dispatched to spend. Recorded here for your triage: (1) run-dev.js's writeStderr bound is unenforceable whenever stderr is in blocking mode, which is the hang above; (2) the case-5 child's 144706-byte stderr sits within about 1 percent of this container's unread-socketpair capacity, so the fast/slow split is a coin flip on any box whose buffer is near that size - anything that grows the CLI's warning output by more than about 1.5 KB flips it permanently to the slow mode." ] }Generated by Claude Code
Generated by Claude Code
os-dev-report
{
"issue": 13533,
"status": "done",
"branch": "claude/issue-13533-system-write-sharing-materialization",
"pr": "#14528",
"premise_still_valid": true,
"summary": "R22 merge round only; PR content untouched. Merged origin/main (8225248) into the branch: merge commit e7c756a, regeneration commit f7d1386, pushed as a fast-forward 645b14e..f7d1386. No rebase, no amend, no force-push, no stash. ZERO conflicts in the real merge. The dispatch expected a hand prose resolution on content/docs/permissions/system-context.mdx; there was none to do. git merge ran the os-regen driver down its DEFER branch (kept the branch bytes, recorded the path in os-regen-pending), not the text-merge branch. And main s side of that file carried NO prose at all: normalising every digit away leaves main-vs-base byte-identical, so its whole contribution was anchor re-numbering, which the generator re-derives. Nothing was lost by the defer. I did NOT run scripts/pm/os-regen-merge.sh. Its step 2 takes main s WHOLE side of any os-regen path both sides changed, which for this MIXED file would have discarded the branch prose the driver exists to protect. I proved step 2 had no work to do: the intersection of both-sides-edited os-regen paths is exactly that one file, and the driver handles it itself. PR is mergeable again: GitHub now reports mergeable true (was MERGE_CONFLICT), mergeable_state blocked, which is pending required checks, not a conflict. auto_merge is null. I did not undraft, arm auto-merge or re-queue. NOTE: the PR was ALREADY draft:false when I read it, set by another actor before this round; I left it as found rather than reverting another actor s ready flip. Worktree /home/user/objectstack-13533-merge deliberately LEFT IN PLACE, as the previous rounds did: this card is on its fourth merge round and I just built a 56-task closure in it. Say the word and I will remove it.",
"tests": "All readings below were taken on the FINAL commit f7d1386 (git rev-parse --short HEAD), tree clean, nothing committed after. Every exit code captured by redirect-then-read; never across a pipe. | MERGE-CLEAN PROOF - git merge-tree --write-tree --name-only origin/main HEAD, twice: | vs origin/main 8225248: exit 0, output was the bare tree oid 9fddd39c3daa20b975499d337abebdcc625e77d0, 0 CONFLICT lines. | vs origin/main 13b5200 (main moved again mid-round): exit 0, bare tree oid 024ff98d9b6df194b288a85265673a8771c712c0, 0 CONFLICT lines. | CENSUS - merge committed FIRST, then pnpm gen:system-context-census on the merged tree: 30 anchor(s) rewritten, exactly the 30 main had moved (engine.ts, sharing-service.ts, auth-plugin.ts, rest-server.ts, last-admin-guard.ts). Effect on system-context.mdx: 20 insertions / 20 deletions - equal, and with all digits normalised the before/after prose diff is 0 lines. That is the generator signature the order asked for. pre-commit then printed: current - marker cleared. Gate verdict line, re-run standalone: check-system-context-census: OK - 106 elevation read sites in 20 packages across 45 files, all anchored; 140 anchors resolve, 27 declared non-read. | LEDGER - scripts/engine-double-contract.pinned.json is NOT os-regen routed, so git text-merged it; I did not hand-merge it. Regenerated with the gate own --write: 697 rows before, 697 after - 0 added or grown, 0 lost; seam rows 6 before, 6 after. --write changed ZERO bytes, so the textual auto-merge was already byte-exact. Gate: check-engine-double-contract: OK - 761 pinned, 133 in the DEBT ledger. | TESTS - pnpm --filter @objectstack/plugin-sharing test (after pnpm --filter with the dependency-closure caret form build, VERDICT command-exit 0): 32 test files passed, 772 tests passed, exit 0, os-verify-lock VERDICT command-exit 0. | TEST DELTA ATTRIBUTION - the two sides touch DISJOINT test files, verified by git diff --name-status base..origin-main and base..pre-merge-tip; there is no overlap at all. MAIN brought: backfill-sys-record-share-organizations.test.ts (12 tests, new file, run-measured) + record-share-organization-stamp.test.ts (19, new, run-measured) + sharing-service.test.ts (+207 lines; static it/test declaration count 118 to 130) + rule-criteria-org-scope.test.ts (+5 lines; 4 to 4 cases, so assertions added inside existing cases) = about +43 cases, main s. MINE: system-write-materialisation.test.ts (17, new, run-measured) replacing the deleted system-write-skip-notice.test.ts (14 cases at base) = net +3; boot-backfill.test.ts 15 to 15; bulk-recompute.test.ts 32 to 32 = +3 cases, the branch s. Counts for the four MODIFIED files are static declaration counts and are labelled as such; the counts for added/deleted files are run-measured. | TYPECHECK - pnpm --filter @objectstack/plugin-sharing typecheck: exit 0 (tsc --noEmit, then tsconfig.scripts.json, then check:test-typecheck). NOT a NOT-MEASURED case: the package runs a separate test-layer project, verdict check:test-typecheck: OK - the test layer compiles under packages/plugins/plugin-sharing/tsconfig.test.json; 2 file(s) / 3 error(s) / 3 pinned signature(s) held in test-typecheck-debt.json (shrink-only and identity-pinned). | GATES - re-derived on the new head: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, which self-reported deriving from objectstack-ai/objectstack at commit f7d1386 and checked --repo against this checkout origin remote. 69 commands over a 10-path change set. RESULT: 66 GREEN, 3 NOT MEASURED, 0 RED. | Three gates first read non-zero purely as unmet BUILD prerequisites, and I cleared all three rather than reporting them as red - all now GREEN: check:skill-examples (needed client-react built; after building its closure: 256 prose examples type-check across 3 surface(s)); check:i18n (needed the CLI + 9-package closure; after a 56-task turbo build: OK, 9 package(s), all bundles in sync, no undeclared authoring keys); check:type-check-debt (needed the built closure; OK - 21 ledger entr(ies) re-measured in 220.0s, 549 raw tsc error(s) total, none above its recorded number). | NOT MEASURED (read the verdict, not the bare code - none is a red): check-test-completeness.mjs exit 3, PREREQUISITE NOT MET, it grades a saved turbo run test log and none exists locally; its own text says to record it as NOT MEASURED. check:dual-build-cjs-loads exit 3, Run pnpm build first, NOT a pass: nothing was measured - needs a FULL workspace build (knowledge-ragflow and 3 more unbuilt). scripts/pm/check-half-states.mjs exit 124 - a live GitHub-API backlog sweep, timed out at my 180s cap and again at 280s with --use-env-proxy; unrelated to this diff, and its self-test sibling pnpm check:pm-half-states is GREEN. | NUL/control bytes - pnpm check:nul-bytes: OK, 8085 text file(s) scanned, no raw ASCII control bytes; plus a self-scan of every file in this diff with grep -naP for the control ranges: clean.",
"mcp_calls": "0 - the repo-scoped REST probe returned HTTP 200, so the whole run used REST plus git; no MCP GitHub call was made.",
"open_questions": [],
"out_of_scope_findings": []
}
Generated by Claude Code
现象
一个用「审批流改状态 + 按状态共享」这个常见组合搭起来的模块:员工提交请假 → 主管在审批收件箱批准 → 记录变
approved。批准之后,同团队的另一位员工看不到这条记录;而更早的几条已批准记录却看得到。连续观察 2 分 47 秒(每 15s 一次共 12 次,每次都是重新发起的独立服务端查询,不是盯着一个已加载的页面)状态不变——不是异步延迟。
对该共享规则调一次
POST /api/v1/sharing/rules/<id>/evaluate(以 admin 身份)后,再发一次全新的查询即恢复正常:根因(代码级判定点,发行包
@objectstack/plugin-sharing/dist/index.mjs)平台本来就支持逐记录实时物化:
bindRuleHooks()为每个有活跃规则的对象注册afterInsert/afterUpdate/afterDelete(:5109/:5125,priority 180),逐记录调用service.evaluateAllForRecord();该方法自己的注释即写着「the per-record pass the afterInsert/afterUpdate hooks run」(:3859)。但两个钩子的第一行就把系统写挡掉,且不做任何补偿:
跳过后只发一条 info 通知(
:5059),原文逐字:而审批回写恰恰是系统写:审批节点以平台身份写
approvalStatusField,且lockRecord: true决定了此时只有平台写能落地。链条:批准 → 系统身份写状态字段 → 共享物化被跳过 → 团队看不见 → 直到有人手动
evaluate或重启服务。⚠ 影响面取决于观察者的角色(2026-08-31 补,见评论区)
本单现象只对「不持
viewAllRecords、依赖共享规则看他人已批准记录」的角色可观测。 持viewAllRecords: true的主体——包括本例中的审批人本人与 admin——不受影响,因为他们的读路径根本不经过共享规则。这对复现有直接影响:用审批人或管理员视角去复现,会看不到问题。 必须用一个只能看自己、靠共享规则看他人的普通成员身份。
为什么值得单独看,而不是「用法不对」
spec/security/sharing.zod.ts无相关字段;全包内无OS_SHARING*之类配置,也没有「让系统写参与重算」的选项;content/docs/、docs/、test/全仓检索不到「审批后何时可见」的任何说法,也搜不到那条通知语;待裁的方向(供参考,不预设结论)
邻近既有单(已查,非重复)
grant()skips the ADR-0111 D7 inert-grant guard entirely for SYSTEM callers, so the sharing-rule evaluator can still materialise rows no gate consults #8207grant()skips the ADR-0111 D7 inert-grant guard for SYSTEM callers(同样是 SYSTEM 路径,但方向相反:那条是系统调用绕过了守卫,本条是系统写被守卫挡掉)复现最小路径
type:'approval'节点 +approvalStatusField+lockRecord:true;status == 'approved'),收件人为某真实 position;POST /api/v1/approvals/requests/<id>/approve;viewAllRecords的普通成员,且每次重新发起查询(不要盯着一个已加载的页面)——查询该对象 → 看不到刚批准的记录;evaluate,再重新查询一次 → 立即可见。Blocked-by: #14648
Unlock-action: re-check PR #14528
Why this card is
pm:blockedrather thanpm:dispatched— added by thedomain:servicesseat (sessionsession_01AUF1NoViznQK32gqpK8wS8), 2026-09-03 03:34Z. The work is done: PR #14528 is reviewed, ACCEPTed, andmergeable_state: cleanwith all its own checks green on head645b14e9e. What blocks it is an external gate defect, and the state machine reservespm:blockedfor exactly that shape rather than a new label.Measured before flipping, not assumed:
test/run-dev-unbuilt-workspace.e2e.test.ts,AssertionError: expected 'SIGKILL' to be null(latest queue-triage comment 2026-09-03T03:33:13Z).645b14e9ethroughout), so nothing about this PR is being re-tested — the same artefact is failing on someone else's defect.git merge-tree --write-tree origin/main 645b14e9eexits 0: no conflict withmain.domain:cli,p1). Its candidate fix PR test(cli): make the unread-reader ceiling a load-independent constant at RUN_TIMEOUT_MS #14715 was refuted on 2026-09-03T02:24Z: raising the ceiling to 180000 ms still failed, with the child running 180105 ms against a 6125 ms baseline on the same runner minutes earlier — a stall, not a margin, which no constant ceiling closes.⛔ Not re-queueing this PR on that signature. The merge-queue-triage bot's own checklist says a failure matching a known aggregation issue is not fixed by re-queueing, and each attempt rebuilds the whole queue behind it. This card returns to the queue when #14648 is closed, via the
Unlock-actionline above.Generated by Claude Code