fix(plugin-sharing): let system writes materialize sharing rules — drop the isSystem skips in bindRuleHooks (#13533) - #14528
Conversation
`bindRuleHooks` returned early on `ctx.session.isSystem` in `afterInsert` and `afterUpdate`, so a system-context write that moved a record into a sharing rule's criteria materialized no `sys_record_share` row. Approval write-back is exactly that write — an approval node with `lockRecord: true` mirrors the decision onto the subject record under a system context, because while the record is locked no other write can land — so a teammate who depended on the criteria rule could not see an approved record until somebody re-evaluated the rule or restarted the server. Removes three early returns: the two named above, and the one on the `beforeUpdate` / `beforeDelete` row-set stash they depended on. That third one is load-bearing: without a stash `readAffectedRows` reports the row set as `unbounded`, which would have sent every single-row system update down the object-wide revoke plus asynchronous re-grant branch instead of the per-record recompute. Retires the `SYSTEM_WRITE_SKIP_NOTICE` channel that announced the skip. `afterDelete` keeps its skip on separate grounds — its payload is revocation, which `record-share-cascade.ts` delivers for system writes on its own account. The two pins of the old behaviour are reversed and registered rather than deleted: `system-write-skip-notice.test.ts` is renamed to `system-write-materialisation.test.ts` and carries a row-by-row register of every old expectation against its new counterpart, and `bulk-recompute.test.ts` keeps its case under a docblock recording what it used to assert and why. `content/docs/permissions/system-context.mdx` is updated because `check:system-context-census` requires it: the page anchors every `isSystem` read site and states census-derived counts, and three sites vanished. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
…stem-write-sharing-materialization
…uble ledger after merging main Discharges the os-regen deferral the merge commit recorded. `content/docs/permissions/system-context.mdx` is os-regen-driven and both sides moved it, so the merge sequence took main's side and this commit re-applies the branch's hand-written half on top: row 30 now describes the one surviving skip (`afterDelete`, whose payload `record-share-cascade.ts` delivers) instead of all four, rough edge 2 records the reversal rather than the retired INFO notice, and the seven census-derived counts move with the population. Re-derived on the merged tree rather than carried over — `check:system-context-census` reports "OK — 106 elevation read sites in 20 packages across 45 files, all anchored; 140 anchors resolve, 27 declared non-read". `scripts/engine-double-contract.pinned.json` learns the test file's new path. The two rows are repathed, not renumbered: `--write` reports "692 (file, verb) row(s), 2 added or grown, 2 lost. No seam losses — this regeneration only records new seams." Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
…stem-write-sharing-materialization
…ain merge Patch round 1. `content/docs/permissions/system-context.mdx` is os-regen-driven and both sides moved it again, so the merge stopped there and neither side could simply win: main's #14383 (#12981 batch 9) moved row 37's `share-link-service.ts` anchors by +6, while this branch moved row 36's `sharing-plugin.ts` anchor by +11 and rewrote row 30 plus rough edge 2. The resolution takes row 37 from main and row 36 from this branch — the two sides moved different rows, so there was no real overlap, only the driver's refusal to text-merge a generated page. Verified by re-deriving rather than by inspection: `check:system-context-census` reports "OK — 106 elevation read sites in 20 packages across 45 files, all anchored; 140 anchors resolve, 27 declared non-read". Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
📓 Docs Drift Check3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 8 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 8679ceb0669befec570997248e0c6e199c7f51ee && git checkout 8679ceb0669befec570997248e0c6e199c7f51ee
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 645b14e9eeb0d898ba2ecd52c020a0564d1cc10d && git checkout -B drift-repro 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 && git merge --no-ff 645b14e9eeb0d898ba2ecd52c020a0564d1cc10d
node scripts/docs-audit/affected-docs.mjs --json 4d0d9445a8ed0240e7ca6a393bbe7f4c637e6bd6 |
…stem-write-sharing-materialization # Conflicts: # content/docs/permissions/system-context.mdx
…block, operator cost sentence Documentation-only follow-up on the contract review's non-blocking §5 notes 2, 3 and 4. No code path changes: the only .ts edit is a docblock body. - system-context.mdx row 30: "Lose: nothing" glossed the one shape where the cascade's delivery is the queued orphan sweep rather than a synchronous revoke. The cell now says the revoke is delivered but deferred on the unbounded shape. - bu-tree-recompute.ts: the docblock still described bindRuleHooks' materialisation skip as current. It now says what bindRuleHooks does after this change and why this file's own hooks never skipped system writes. - changeset: one sentence on the operator-visible cost of seed- and import-time system writes on rule-covered objects, and "unexported" becomes "not exported from the package entry point". Level stays patch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
Landing provenance — ready + auto-merge at head
|
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 33674211749 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Queue ejection triaged — this failure is not this PR's, and I am standing down on it rather than patchingQueue build 33674211749 ejected this PR. The failing job is Test Core (1/6), and the failing test is: Why it is not this PR's, on three readings rather than an assertion:
No fix exists to port. #14706 is explicit that it is a name and not a diagnosis: nothing there is graded, assigned, or in flight, and no PR proposes a repair. There is therefore nothing for this PR to carry, and ⛔ widening this PR to repair a CLI e2e test is not on the table — it would put an unrelated, unreviewed change inside a diff that carries a maintainer ruling. What I am doing: re-queueing once, and saying so here so the count is on the record. That is the single re-run this seat allows for a load-sensitive failure, and it is the discriminating act the queue-triage comment itself sanctions for this class. ⛔ If the same signature ejects this PR again, I will not re-queue a second time — it would burn a full queue round for every PR behind it. The PR would then wait on #14706, and this comment is where that wait is recorded rather than left in a session's memory. The seat's evidence has been added to #14706 for whichever lane owns Generated by Claude Code |
Correction to my earlier standing-down comment: a fix does exist and it has an ownerAt 19:59Z I wrote that "no fix exists to port" and that #14706 was ungraded with nothing in flight. That was accurate when written and is no longer true, so I am correcting it rather than leaving the PR's record stale. #14706 has been graded through the emergency direct-triage channel and closed as a duplicate of #14648, which is the working anchor for the same test: Why the fix still is not ported into this PR. The rule that says to port an existing fix rather than wait assumes the fix is one this PR may carry. It is not, on two independent counts:
Failure class, now read from the test rather than from the bot's summary: the asserted This PR is still queued and still not being manually re-queued. Confirmation that it is not stuck on itself: #14687, this seat's other victim of the same test, merged at 20:21Z on a later auto-re-queue of the same unchanged head. Generated by Claude Code |
Dequeued with
|
…stem-write-sharing-materialization Resolves the single CONFLICT (content) in content/docs/permissions/system-context.mdx (the os-regen driver flags this file as MIXED — generated anchors plus hand-written prose — so it text-merges instead of taking one side whole). Row 37 (share-link policy `enabled` check, isSystem) diverged: this branch's copy already carried the #14033 prose ("resolution is not bypassed since #14033 ... a link minted this way does not resolve until the block is enabled"), added when 13bf05d landed on both branches. main's copy had reverted to the pre-#14033 wording via an unrelated commit (8ce628a, "store a serializable manifest projection in the package registry (#14499)") whose own docs-regen step appears to have run against a stale pre-#14033 checkout of this file. Kept this branch's row 37 text: packages/plugins/plugin-sharing/src/ share-link-service.ts:652-697 (the `[#14033]` docblock and the `if (!policy.enabled)` redemption-time check it documents, citing the maintainer's 2026-09-01 ruling and this exact ledger row by number) confirms it is what the current source does, not the reverted main copy. Row 36 differed only in its generated anchor line number (1088 vs 1077) — anchor numbers are recomputed by `pnpm gen:system-context-census` below, so either side was equivalent; kept this branch's. Anchors re-derived on the merged tree with `pnpm gen:system-context-census` in a follow-up commit, per the os-regen driver's documented recovery for this file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
Landing provenance — merge round complete, re-armed at 22:1xZ
The conflict is resolved, verified by the seat rather than taken from a reportThe dev agent that ran this merge round was killed by a container restart before it could report, so the seat verified the work directly against the tree:
CI on
|
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 33689105623 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Re-queued at 23:0xZ — and this reverses a written commitment of mine, on a measurement that falsified its premiseAt 22:32Z this PR was dequeued when its merge-queue batch failed on the repo-wide That premise is false, measured, not assumed. At 23:00Z:
I am recording the reversal rather than quietly re-queueing, because the commitment was made in public on this PR and the reason it is being dropped is a fact anyone can re-measure from the State at re-arm, both readings taken at 23:0xZ, not recalled:
Auto-merge re-armed, SQUASH. This is the one manual re-queue this PR gets: if the next batch fails on the same flake again, that is a second data point about the flake and not a reason for a third arming, and it goes to #14648 rather than back into the queue. Generated by Claude Code |
Fixes #13533
A criteria sharing rule declares a promise —
status == "approved"means the named recipients can see the record.bindRuleHooksdid not keep that promise when the platform was the writer, so an approval write-back left a teammate unable to see an approved record until somebody ranPOST /api/v1/sharing/rules/:id/evaluateor restarted the server. This removes the skips that caused it.Implements the maintainer ruling of 2026-08-31 (issue comment 5472662038), quoted verbatim and untranslated:
The approval path, cited
The write the card is about is
mirrorStatusFieldinpackages/plugins/plugin-approvals/src/approval-service.ts(called from the decision, recall, reject and return paths). It writes:SYSTEM_CTXis{ isSystem: true, positions: [], permissions: [] }. Elevation is not incidental here: an approval node withlockRecord: truemeans only a platform write can land while the record is locked, so this is the only write that can carry the decision onto the record.plugin-approvalsis read-only in this PR — cited, not edited.What changed
Three early returns are gone from
bindRuleHooks, not two.afterInsertafterUpdatestashAffectedRows(beforeUpdate/beforeDelete)The third one is load-bearing, and removing only the two named skips would have shipped a different defect.
afterUpdatereads its row set throughreadAffectedRows, which answers{ kind: 'unbounded', reason: 'resolve-failed', detail: 'no before-hook stash' }when nobeforehook stashed. With the stash still skipping system writes, every single-row system update — every approval — would have takenrevokeThenQueueRegrant: an object-wide revoke of every rule grant plus an asynchronous re-grant, with awarnper approval. Ablation leg B below measures exactly that. Resolving is nearly free on this shape anyway: a write that names its row short-circuits inresolveAffectedRowsstep 1 without querying.The
SYSTEM_WRITE_SKIP_NOTICEchannel retires with the skips. No declarative switch was added and no documentation substituted for the fix, per the ruling.What deliberately did not change
afterDeletekeeps itsisSystemskip. Its payload is revocation, not materialisation, andrecord-share-cascade.tsdelivers that on every sharing-capable object, stashing for system writes on its own account (#5103), with the boot orphan sweep behind it. This is one subscriber declining work another subscriber owns, not elevation used as a blanket silencer — the distinction the ruling itself draws.bu-tree-recompute.tsis untouched. Its docblock describes the opposite disposition and is a different subject: BU-tree recompute deliberately does not skip system writes, because its payload is revocation on a path whose realistic production trigger (an HRIS or directory sync) is itself a system write. Nothing there was a materialisation skip, so nothing there was in the ruling's scope.The
kernel:bootstrappedboot backfill stays. It is no longer needed for seed rows, but it still reaches rows no hook saw — written before an object's hooks were bound, or while a rule was inactive — and it is the only pass that purges a deactivated rule's grants (#4433).Acceptance anchor: the member perspective, without
viewAllRecordsTriage's binding note (issue comment 5479116055) is that this defect is observable only to a principal without
viewAllRecordswho depends on the sharing rule. A manager or admin reads through the profile path, never consults the rule, and sees the record either way — "the manager sees it within 18 s" is true and is not a counter-proof.The acceptance pins therefore end at
SharingService.buildReadFilterfor a plain member context and then run that filter against the table, so the assertion is that the teammate can see the record, not merely that a row appeared insys_record_share:buildReadFilterfor the member is{ owner_id: 'rep2' }and the record is not admitted;{ $or: [ { owner_id: 'rep2' }, { id: { $in: ['lr1'] } } ] }and the record is admitted — additive access, the owner match still present;nullboth before and after, which is the reproduction constraint stated as a test: verifying this fix from a manager view proves nothing.Controls in the same file: a non-system write still materialises (the user path did not move); a system write that does not satisfy the criteria grants nothing; a system write on an object no active rule covers is a no-op; a recall revokes again.
The bulk-path census (ruling point 2), measured before disposal
The ruling forbids keeping the skip on unmeasured performance fear, and asks for the bulk system-write paths to be censused first. Measured on the merged tree:
packages/metadata-protocol/src/seed-loader.ts,SEED_OPTIONS=isSystem+skipTriggers+seedReplay)BULK_BATCH_SIZE200 per flush, dataset unboundedbackfillRuleGrantsatkernel:bootstrapped, after everykernel:readyhandler settlesclaimSeedOwnership(plugin-security)sys_, non-managed, non-external object declaringowner_idlimit: 10_000, written as single-id updates in a looppackages/rest/src/import-runner.ts)writeCtxis the importer's own contextmetadata-protocol/src/migrations/*)sys_/cloud_/ai_namespace onlysys_record_sharecarries noneskipTriggersdoes not suppress these hooks:engine.tsskips only metadata-bound hooks and only underskipAutomations(if (skipAutomations && entry.meta)), and seed writes setskipTriggers. So the seed path really does reachbindRuleHooks.Disposition: no new batch code. The ruling's remedy — replace per-record with one trailing batch on the bulk path — already exists for both shapes the hooks can see, and removing the skips is what makes system writes reach it:
multi) overRULE_RECOMPUTE_ROW_CAP(1000) already takesrevokeThenQueueRegrantintoevaluateAllRulesForObject;backfillRuleGrantsreconciling every rule behind it.What remains is per-record cost on single-row writes, and that is not new amplification: it is the cost a user write of the same shape has always paid. An executable pin (
#13533 census: a system write costs exactly what the same user write costs) measures the two populations against each other rather than asserting an absolute — a 25-row system insert batch performs exactly the reads a 25-row admin batch performs, and the cost is linear in the batch. Adding a batch shortcut keyed onisSystemwould reintroduce precisely the special case the ruling condemned. The one path the hook layer cannot help isclaimSeedOwnership's loop of single-id writes, which is a writer-side shape in another package; it is reported to the PM rather than folded in.Reverse verification
Both legs mutate the committed tree, prove the mutation on disk before reading anything, and prove the restore afterwards. No rebuild is involved or needed: the suites import
./rule-hooks.js, a same-package relative specifier vitest resolves tosrc/, and the mutation turning tests red with no rebuild is itself the proof that nodist/sits in the resolution path.Leg A — restore the
afterUpdateskip. Marker present x1, blob moves tocb675403…off HEAD23baa61d…. Result:Tests 5 failed | 44 passed (49)— the two approval write-back acceptance pins, both bulk-update directions, and the reversedbulk-recomputecase. Every control stayed green.Leg B — restore only the
before*stash skip, leaving bothafterskips removed. Marker present x1, blobb8e9c653…. Result:Tests 4 failed | 45 passed (49), and the discriminating one istakes the BOUNDED per-record branch, never the object-wide revoke, which reds only in leg B. That is the measurement behind the third removal.Baseline unmutated:
Tests 49 passed (49). After every leg the restore is proved by blob equality with the HEAD blob plus an emptygit diff HEAD, with an empty hash treated as failure rather than as "nothing to compare".Verification
All readings at
376c04e00, after the last commit and after mergingorigin/main(7251bdb30). Re-run in full for patch round 1 — see that section for what moved.pnpm --filter @objectstack/plugin-sharing test—Test Files 30 passed (30),Tests 718 passed (718)(712 before patch round 1; main's fix(plugin-sharing): report a refused resolveToken usage stamp once as a durability degradation — #12981 batch 9 #14383 added six share-link cases).pnpm --filter @objectstack/plugin-sharing typecheck— clean, and it now genuinely covers the test layer: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. Those 3 are pre-existing entries in two files this PR does not touch.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsafter the last commit — 69 commands over 9 paths. 64 green. Five are NOT MEASURED, every one a "build the whole repo first" prerequisite on packages this diff never touches, and each says so in its own verdict:check-test-completeness(exit 3, "Nothing was measured"),check:skill-examples("packages/client-react/distholds 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"). CI builds the closure before those steps. Every exit code was captured before any pipe.check:system-context-census—OK — 106 elevation read sites in 20 packages across 45 files, all anchored; 140 anchors resolve, 27 declared non-read.check:engine-double-contract— green after--writerepathed the renamed test file: "692 (file, verb) row(s), 2 added or grown, 2 lost. No seam losses". The script's own--self-testpasses too.eslint --print-configclaims all 5 lintable changed files, none ignored. (2) File count read from--format json: 5 files reported, 0 errors, 0 warnings, exit 0. (3) Invariance for untouched files: this repo runs oneeslint.config.mjswhich "never enables type-aware linting (noparserOptions.project, no typed@typescript-eslintrules) for ANY file" — the config states this itself, with a measured positive control — so no verdict on a file this diff does not touch can move. The repo-widepnpm lintremains CI's run.Documentation
content/docs/permissions/system-context.mdxis edited because a required gate demands it, not as a rider.check:system-context-censusholds that page to a census of everyisSystemread site — every site must be anchored at its exactfile:line, and the page's census-derived counts must equal the census. Three sites vanished, so seven counts moved (109 → 106 elevation reads, 20 → 17 for plugin-sharing's share, 115 → 112 property reads, 105 → 102 behaviour-bearing).regen-artifacts.mjsrecords that the--fixpath refuses a population change, so the rows were rewritten by hand: row 30 now describes the survivingafterDeleteskip alone, and rough edge 2 records the reversal instead of the retired INFO notice.Reversal register, not deletion
The two pins of the old behaviour are reversed and registered.
system-write-skip-notice.test.tsbecomessystem-write-materialisation.test.tscarrying a row-by-row table of every #6783 expectation against its #13533 counterpart, including the four that are unchanged and why.bulk-recompute.test.tskeeps its case under a docblock recording what it used to assert, what reversed it, and the ruling's own words. Neither pin was a mistake; both pinned a real, separately-ruled behaviour that a later ruling overturned.Contract review (Clause ②)
Declared
yes, re-derived from the actual diff, which flips the dispatch's claimedno.git diff -U0 origin/main...HEAD | grep exportreports exactly one exported symbol removed and none added:The published package surface is unchanged:
plugin-sharingdeclares a single"."entry point resolving tosrc/index.ts, andindex.tsre-exportsbindRuleHooks,unbindAllRuleHooks,bindRuleCriteriaGuard,ruleRegrantQueue,SHARING_RULE_HOOK_PACKAGEandRULE_CRITERIA_GUARD_PACKAGEfrom this module — neverSYSTEM_WRITE_SKIP_NOTICE. Its only consumer was the pin file renamed here. The declaration isyesbecause the dispatch's rule is mechanical about any exported symbol, and being over-conservative costs a label; the substantive review question is the behaviour change, which the ruling already settled.needs:contract-reviewis hung on this PR and on #13533.Changeset is
patchfor that reason — no published export moves — and the change is a bug fix, not a declared-breaking one, so it carries no ADR-0087 marker. No newerror-level log site through a published sink shape; the retirement removes aninfoline and adds none.Patch round 1
origin/mainmoved to7251bdb30while this PR was open and the merge wentdirtyon exactly one path:content/docs/permissions/system-context.mdx. No code file conflicted.What conflicted, and why it was not a real overlap. That page is routed to the
os-regenmerge driver, which refuses to text-merge it — so git stops there whenever both sides move it, regardless of whether the edits touch the same lines. They did not. Main's side (#14383, the #12981 batch 9 share-link work) moved row 37'sshare-link-service.tsanchors by +6. This branch had moved row 36'ssharing-plugin.tsanchor by +11 and rewritten row 30, rough edge 2 and the counts. The resolution therefore takes row 37 from main and row 36 from this branch — each side's own row, neither discarded.How it was verified. Not by inspection: the page was re-derived against the merged tree, and
check:system-context-censusreportsOK — 106 elevation read sites in 20 packages across 45 files, all anchored; 140 anchors resolve, 27 declared non-read. The census total is unchanged at 106 because main added and removed no elevation read sites.Re-run on the merged head
376c04e00, afterpnpm install --frozen-lockfileand a rebuild of the dependency closure (main movedpackages/objectql/src, which this package builds against):pnpm --filter @objectstack/plugin-sharing test—Test Files 30 passed (30),Tests 718 passed (718).pnpm --filter @objectstack/plugin-sharing typecheck— clean;check:test-typecheck: OK — 2 file(s) / 3 error(s) / 3 pinned signature(s), the same pre-existing entries.Tests 5 failed | 44 passed (49), leg BTests 4 failed | 45 passed (49)with the bounded-branch pin still the discriminating failure, baselineTests 49 passed (49), and the restore proved by blob equality plus an emptygit diff HEADafter every leg.Patch round 2 — documentation only, after the A ruling
The maintainer ruled A on 2026-09-02 (verbatim, untranslated: 「#13564 转维护者处理;其他同意」 — decision batch #11, this card item 1, recommendation A; recorded at 13533#issuecomment-5511791709). Ruling point 2 is closed by the census plus the cost-equivalence measurement already in this body: no boot-phase skip predicate is added, and
meta resyncstays per row, carried by #14530. The three removed skips, the reversed pins and the changeset level are untouched by this round.This round carries the contract review's non-blocking §5 notes 2, 3 and 4, plus this body's note-5 omissions. New head
e9b612a7a.No code path changed. The round's own diff is three files — the changeset, one census-page cell and one docblock — and every added or removed line in the only
.tsfile among them is a JSDoc body line (*): zero non-comment lines. No ablation is owed and none was run; with no behaviour change there is no red for a mutation to produce, and running one would measure the previous round's code.content/docs/permissions/system-context.mdx, row 30. "Lose: nothing" glossed the one shape where the cascade's delivery is the queued orphan sweep rather than a synchronous revoke. The cell now opens "Lose: nothing permanently — the revoke is delivered, but deferred on the unbounded shape" and says which 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. 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.packages/plugins/plugin-sharing/src/bu-tree-recompute.tsdocblock. It still describedbindRuleHooks' materialisation skip as current ("…is about grant MATERIALISATION, which the boot backfill re-does anyway"), a sentence this PR makes false. It now says thatbindRuleHooksno longer skips system writes — theafterInsert/afterUpdatematerialisation skips and thebefore*stash skip that fed them are gone, and the one skip it keeps isafterDeleterevocation, whichrecord-share-cascade.tsdelivers instead — and that this file's own hooks never carried anisSystembranch to skip with. That last claim is checked against the file's whole history, not just the head: the onlyisSystemline ever added to it is itsSYSTEM_CTXconstant. Nothing else in the file changed..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 thekernel:bootstrappedbackfill still reconciling behind it. And "unexported" now reads "not exported from the package entry point", matching what the file already said correctly further down. Level stayspatch; no ADR-0087 marker is owed.note 5 — what this body omitted
Comment-only edits this PR carries.
sharing-plugin.ts(docblock corrections at the rule-hook binding and at thekernel:bootstrappedhandler) — its whole diff againstorigin/mainhas zero non-comment changed lines.boot-backfill.test.ts(file header rewritten) — likewise zero.bulk-recompute.test.tscarries anADMIN_SESSIONexplanatory comment alongside the reversed pin this body already describes.A behavioural side effect this body did not state. With the
before*stash skip removed, thebeforeDeletestash now runs for system deletes on rule-covered objects, so hook ordering flips: the rule package (priority: 180,rule-hooks.ts:177) resolves the affected row set first, and the cascade (priority: 190,record-share-cascade.ts:283) then reads the stashed answer instead of resolving its own (bulk-recompute.ts:304-305). Cost-neutral — one resolve either way; only which subscriber pays for it changes.The merge
origin/mainhad moved to20b883918and the PR readmergeable_state: dirtyon the same single path as round 1:content/docs/permissions/system-context.mdx. No code file conflicted;sharing-plugin.tsandscripts/engine-double-contract.pinned.jsonauto-merged.That page is routed to the
os-regenmerge driver, which for this MIXED file — generated anchors plus hand-written prose — refuses to keep either side whole and text-merges instead, so it conflicts whenever both sides move it. One hunk conflicted, two rows wide, and the two rows belong to opposite sides: row 36 is this branch's (it moved thesharing-plugin.tsanchor by the +11 comment lines this PR added above it), row 37 is main's (#14580, landing #14033, rewrote its prose — link creation is bypassed, redemption is not — and moved its fiveshare-link-service.tsanchors). The resolution takes each side's own row; neither is discarded.Proven by the gate, not by inspection. The merge was committed first — never regenerate in MERGE state — then
pnpm gen:system-context-censusran on the merged tree and rewrote 0 anchors, i.e. the hand resolution already agreed with the merged tree, andcheck:system-context-censusreportsOK — 106 elevation read sites in 20 packages across 45 files, all anchored; 140 anchors resolve, 27 declared non-read. Both sides' content survives on the merged page: main's row-37 sentence about redemption, and this branch's row-30 rewrite plus rough edge 2.Verification on the final head
e9b612a7aEvery command ran through
scripts/pm/os-verify-lock.sh, each exit code captured before any pipe; the lines quoted are the gates' own verdicts.pnpm --filter @objectstack/plugin-sharing test—Test Files 30 passed (30),Tests 729 passed (729). The delta from round 1's 718 is entirely main's: this round's diff touches no test file at all, and main's fix(plugin-sharing): holdpublicSharing.enabledat redemption, not only at mint (#14033) #14580 added a net 11 cases acrossshare-link-eligibility.test.tsandshare-link-service.test.ts(15it(lines added, 4 removed). 718 + 11 = 729.pnpm --filter @objectstack/plugin-sharing typecheck— clean, and the test layer is genuinely covered: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 in two files this PR does not touch.pnpm check:system-context-census—check-system-context-census --self-test: all cases passed, thencheck-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— clean on all four legs: 388 files with no bare metadata literals, 41 published skill files with no internal issue-id references, 14500 customer-facing strings across 710 spec sources clean, and 831 pinned sibling-prose sites across 231 files with "no growth, no burn-down unrecorded".node scripts/check-changeset-no-major.mjs— "This diff introduces nomajorbump."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 pinned row, this round added none.)node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands— 69 commands over 10 paths (round 1: the same count over 9 paths; the tenth isbu-tree-recompute.ts, which brought no additional command). Run in seven foreground batches under the container's cap: 64 green, and the same five NOT MEASURED as round 1, each declaring its own unmet prerequisite rather than a finding —check-test-completeness(exit 3, "Nothing was measured"),check:skill-examples("packages/client-react/distholds 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"). CI builds the closure before those steps.Clause ② stays
yes— this round adds and removes no export — andneeds:contract-reviewremains hung on this PR and on #13533 for the seat to clear.🤖 Generated with Claude Code
https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
Generated by Claude Code
Generated by Claude Code