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
|
The dependency I wrote on this PR has resolved — and it resolved to "the fix does not work". Correcting it, and replacing the rule that produced it.What I committed to, and why it is now voidAt 23:48Z ( #14715 merged at 03:52:24Z, closing #14648. The trigger fired. But the thing it was supposed to deliver did not arrive, and the 05:59Z ejection is the proof:
That second message is #14715's own wording. So this build contained the fix, ran the test against the raised 180 s ceiling, and the test failed anyway — the child now burns the full 180 000 ms where it used to burn 40 000. Measured elsewhere on the same signature with the raised ceiling in force: the child ran 180 105 ms against a sibling's 6 125 ms on the same runner, and again 180 097 ms against 8 199 ms. The child stalls; it is not slow under load. No constant ceiling closes a stall, which is why raising it changed only the sentence. So my dependency did not become satisfied — it became indefinite, which is the exact property I promised it did not have. The rule I am replacing, and why the old one was wrongThe old rule — this PR gets one manual re-queue, then it waits — made a finished PR's ability to land depend on somebody else's fix arriving. Auto-merge is cleared by every dequeue (measured on this PR at 23:02Z), so "waiting" here does not mean sitting in a queue: it means sitting outside the queue forever, with nothing armed to put it back. A green, ruled, conflict-free PR that can never enter the queue is not waiting, it is abandoned. That is what the rule actually produced, and it is worse than the queue slot it was protecting. The replacement rule, which I can hold consistently: while this PR is green, mergeable and unarmed, it gets armed. An ejection caused by a repo-wide signature is the queue's defect, tracked on its own card — it is not a reason to leave my own PR unable to land. This PR's presence in a batch does not make the flake more likely to fire; it only decides whether this PR is present when a batch does succeed, and batches are succeeding — #14804, #14790 and #14799 merged at 04:51:09Z, and this seat's own #14813 and #14815 merged at 05:34:30Z, all through the same queue on the same night. ⛔ No merge round is owed. The reason I named for merging Where the defect now lives#14822 is reopened, by this seat, at 06:0xZ. It had been closed as a duplicate at 04:05:26Z — thirteen minutes after #14648 closed, when #14715 still looked like the fix — and the issue it was marked duplicate of is closed, so this signature had no open home while it went on ejecting. The merge-queue-triage workflow states in this PR's own 05:59Z comment that it will not file a replacement and names reopening #14822 as the route. The full evidence, including the untried axis (why the child never exits, rather than how long we wait for it), is at 14822#issuecomment-5521277456.
Auto-merge re-armed. Generated by Claude Code |
Not re-arming. My "this failure is not this PR's" reasoning has a hole in it, and 7/7 is the reason to test it instead of rolling again.The arming at 06:0xZ worked — the PR enqueued, built as 33721319707, and was dequeued at 06:19:44Z with The anomaly I have been explaining awayThis PR is now 7 for 7: every queue build it has ever entered has failed on this one test. Meanwhile the same queue merged #14804, #14790 and #14799 at 04:51:09Z and this seat's own #14813 and #14815 at 05:34:30Z — through the identical full suite on the same night. So the flake does not fire in every batch. A break that fires often enough to take this PR seven consecutive times, while other batches sail through, is not well explained by "unlucky bystander". I have asserted three times on this PR that the failure is not this PR's. The argument each time was package-level: the failing test is in The hypothesis, stated as a hypothesis
So there is a plausible mechanism by which this PR makes that child slower to reach its exit, and the failing case is precisely "gives up and exits instead of waiting forever" — a case that asserts the child is not still alive at the ceiling.
What happens instead of another queue rollA measurement round is dispatched on this branch. It runs the failing test file repeatedly against ⛔ It stops at the measurement. It changes no test, ports no fix, touches no file under ⛔ No re-arm until that number exists. This is not the old "wait for someone else's fix" rule that I replaced two hours ago — that rule was wrong because it made this PR's liveness depend on work I was not doing. This is the opposite: the work is mine, it is dispatched, and it is dated by a running agent rather than by a queue lottery. The repo-wide anchor stays #14822 (reopened 06:0xZ; the triage workflow has already re-adopted it as this signature's aggregate). Nothing about this PR's content, its adopted A ruling, or its cleared Clause-② carriers is reopened by any of this. Generated by Claude Code |
The number exists, and it exonerates this PR. My hypothesis was wrong, and it was wrong at the root.At 06:2xZ I stopped re-arming this PR and dispatched a measurement round, on the hypothesis that its own change was making the child slower and losing it the race — 7 ejections out of 7 looked anomalous for a shared flake. The measurement is done (full report on the card, 13533#issuecomment-5522251046) and the verdict is REFUTED. The premise I handed the dev was false. I wrote that this test "boots a dev server in an unbuilt workspace, and a dev boot seeds", and reasoned from there that this PR's seed-time sharing materialisation could reach it. The case-5 child is And the change is not reachable from that child at all. V8 coverage on the real case-5 child reports The 7-for-7 was not evidence about this PR. 74 interleaved out-of-band children across two trees differing by exactly this PR's 10 files — the A/B used the merge's own second parent
The real mechanism — measured, and now on #14822 (14822#issuecomment-5522294431): with a cold tsx cache, an inherited-stderr esbuild service process leaves fd 2 in blocking mode ( What this changes hereAuto-merge is re-armed. The condition I set at 06:2xZ was "no re-arm until that number exists"; it exists, and it says this PR is a bystander. Ejections on this signature are the queue's defect, tracked on #14822 with a named mechanism and a recommended fix that belongs to I am also not repeating the arming argument on this PR again — the rule and its bound are stated, and a fourth comment about re-queue policy would be volume rather than record. Nothing about this PR's content moved: head stays 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