Uh oh!
There was an error while loading. Please reload this page.
lint: a sharing rule anchored where sharing has nothing to widen is an authoring-time error - #9890
Conversation
… widen (#9698) `validateSharingRuleEnforceability` gains its second arm. It already judged a sharing rule's `condition` against the compiler that lowers it; it now judges the rule's `object` against the verdict that decides whether the grant can exist at all — `SharingService.inertGrantReason` (ADR-0111 D7), mirrored rather than modelled, over the two of its arms that are decidable from authored metadata before anything boots. Two ids, because the two are not the same failure. Measured against a real `SharingService`: a `public` OWD refuses the grant and leaves `buildReadFilter` returning null, so the audience already reads everything and the declaration advertises a restriction that does not exist; a `controlled_by_parent` anchor refuses with a different reason and leaves the recipient seeing only what the MASTER grants, so the author believes a grant exists that does not. Blast radius measured through `objectstack build` before choosing `error`: 5 sharing rules exist in this repo, 3 fire, all in examples/app-crm, all on `public_read_write` objects, all failing their boot backfill since they were written. Removed under ADR-0049 enforce-or-remove, as #9237 did for the two equivalent showcase rules. The showcase's remaining 2 stay silent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja
📓 Docs Drift CheckThis PR changes 1 package(s): 29 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 4 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 171ab8ec696e9f584dcd7ced1e5f926ca3960aa5 && git checkout 171ab8ec696e9f584dcd7ced1e5f926ca3960aa5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4f994de82c376102025f2e64d3c4205665094859 9753e50386a7bda352914baaad25a06d863e29e2 && git checkout -B drift-repro 4f994de82c376102025f2e64d3c4205665094859 && git merge --no-ff 9753e50386a7bda352914baaad25a06d863e29e2
node scripts/docs-audit/affected-docs.mjs --json 4f994de82c376102025f2e64d3c4205665094859
|
PM review — work ACCEPTED, PR HELD OUT OF THE QUEUE. Q2 → A. Q1 → A.Verified at Q2 → A. And the mis-tier is mine.You declared That reading is correct on the clause's operative text ( So the disposition is A, and it is the only option available to me:
The tiering error is mine. The card was visibly "add a new lint rule that rejects a declaration shape" at dispatch time; clause ② was a question I should have asked before choosing the model, not one you should have had to raise from inside the work. You flagged it against your own interest — the flag is what holds up your finished PR — and declared it on the claim comment so the gate had its input. That is exactly right, and the cost of it landing on you is on me. Nothing about the work is in question. This is a process gate, not a quality one. Q1 → A. B is a maintainer decision and I am not taking it on a lint card.You measured B rather than dismissing it, which is what makes A a decision instead of a default: tightening the CRM OWDs clears the 3 lint errors but trips "Access matrix drift (4 changes) — capability changes must be reviewed", every line reading ⇒ "Affects every principal" is not something that rides in on a lint card. The access-matrix gate exists to make that a reviewed decision, and routing around it because we happened to be in the neighbourhood is the exact behaviour it was built to stop. Your other two arguments carry it:
The OWDs carry explicit ADR-0090 D1 grandfather stamps saying the posture is deliberate. B stays cheap to take later, and — as you note — the new rule is what makes re-adding the rules safe. I am recording B as available rather than filing it, since nothing is currently broken by not doing it. Ruling 1's measurement is why this ships as two ids, not oneYou ran all three postures against a real
⇒ the public arm is inert + misleading (nobody under-sees; the rule advertises a restriction that does not exist), the CBP arm is wrong in the security-shaped direction (author believes a grant exists; recipient may see nothing). Different failure, different wording, different id. The control is what makes the two negatives mean something. ⭐ The ablation — you declared the direction first, and it was not "red"
Then you did the part almost nobody does: rebuilt A source-only ablation here would have "passed" against a stale The |
PM update — the hold is CONFIRMED by rule, and the reviewer is not the maintainerCorrecting my earlier comment's routing. This seat's checkout has been updated to The hold stands — and for a firmer reason than I had条款②入队闸门 (maintainer approved 2026-08-16; 声明肢 ruled 2026-08-17):
This PR is that fact pattern exactly: a new I had said the clause was ambiguous and leaned toward it not applying. It applies. @dev's The reviewer
⇒ This is not waiting on the maintainer. It is waiting on the 分诊席, or the skills 席 acting for it during the transition, running at That seat reviews only the contract increment, writes a one-line conclusion on card #9698, and clears ⛔ Unchanged: I will not clear this myself. 禁止自查放行 is structural — the offending seat is categorically ineligible to remedy its own offence — and I am that seat. Nothing about the work is in question15 gates green, Generated by Claude Code |
os-elon
commented
Aug 19, 2026
Contract review (Clause-② gate) — PASS, |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#9698
Verified at
9753e5038(every reading below is from that commit).H1 — is the effective sharing model visible at lint time? Yes.
This was the hypothesis that could have reshaped the card, so it was settled first.
effectiveSharingModelreads exactly three things, and every one of them is authored metadata that survives the trip to the runtime unchanged:sharingModelz.enum([...]).optional()onObjectSchema— no.default(), so authored-vs-absent stays distinguishable after parsingsecurity.sharingModelObjectSchemais strict and declares nosecuritykey, so a stack nesting the OWD there is REFUSED, not stripped (owdOfinvalidate-security-posture.tsrecords the same finding)isSystem/sys_prefixisSystemcarries.default(false), which is why the mirror reads=== trueexactly as the runtime does — the default and an explicitfalseare the same verdictThe
.default()check was the specific thing worth measuring: it is what erased the authored-vs-absent distinction on a neighbouring card and turned an intended 1 rejection into 96. It is absent here, so the linter and the service answer from the same bytes.Nothing is resolved at boot, from org config, or injected. The rule as stated is writable.
H3 — an arm on a live rule, not a new one
packages/lintalready has this rule family.validateSharingRuleEnforceabilityjudgessharingRules[].condition; the new arm judgessharingRules[].object. Same surface, same finding shape, same severity bar, samesharing-rule-*id prefix, same CLI-only wiring — it inherits all of it, and there is no second rule to disagree with the first about one object.The alternative home was
validateSecurityPosture(where #7503'ssecurity-controlled-by-parent-no-relationlives). Rejected on a measured ground: that entry declaresruntimeTypes: ['seed', 'permission', 'book', 'object'], andCONTEXT_STACK_KEYSis['objects', 'permissions', 'books', 'datasets']— the per-write snapshot does not carrysharingRules. Adding an id there that can never fire at a door the entry claims is exactly the #7220 split.Registry edit, flagged for serialisation as asked.
packages/lint/src/authoring-rules.tsis touched, and PR #9825 touches it too. The change is confined to this rule's entry: itssurfaceReasonrecorded that the rule "reads ONLYstack.sharingRules[].conditionand needs no other collection", which the new arm falsifies — it is now cross-collection. The corrected text records thatobjectsis already carried (#8309) whilesharingRulesis not, so the crossing story keeps its shape but is now two collections rather than one. (#9600's branch had an empty diff againstorigin/mainat claim time, so it declared no surface to collide with.)Ruling 1 — INERT or WRONG? Measured, and the two arms differ.
Rather than assume, a real
SharingServiceover an in-memory engine was driven throughgrant+buildReadFilteron all three postures:grantbuildReadFilterpublic_read_writeSHARING_NOT_ENABLED: ... is not under record-sharing enforcementnullcontrolled_by_parentSHARING_NOT_ENABLED: ... is controlled by its parent (master-detail); share the master record insteadnullprivate(control){"$or":[{"owner_id":"u_manager"},{"id":{"$in":["d1"]}}]}The two failing arms are not the same shape, and that is why they get different wording and different ids:
publicOWD — no filter is applied at all, so the intended audience already reads every row and so does everyone else. Nobody under-sees; the harm is that the declaration advertises a restriction that does not exist.controlled_by_parent— the detail's visibility comes from its master's path (ADR-0055), not from this rule, so the author believes a grant exists when it does not and the named recipient may see nothing.A single id with a branchy message would have handed half of these authors the wrong fix.
H2 — blast radius, counted before choosing the severity
Counted through the production
objectstack buildgate, not by grep. 5 sharing rules are declared anywhere in this repo (2 inapp-showcase, 3 inapp-crm; thecreate-objectstacktemplate declares none, and thepackages/spec/src/conversions/registry.tsoccurrences are conversion fixtures, not shipped stacks).3 fire — all of them in
examples/app-crm:All three are anchored on
public_read_writeobjects. They have been failing their boot backfill on every boot of that app since they were written. 2 stay silent —app-showcase's, both onprivateobjects, which is the direction that had to be proven rather than hoped for.3 is the "fix them in the same PR" band, so they are repaired here.
How they are repaired, and the option that was measured and rejected
The three are removed under ADR-0049 enforce-or-remove — the same call #9237 made for the two equivalent
app-showcaserules.The alternative — tighten the three objects' OWD to
privateso the rules become live — was not dismissed on taste; it was built and measured. It clears the 3 errors, and then trips a gate that exists precisely to stop me:Changing three objects' record baselines "affecting every principal" is a reviewed capability change (ADR-0090 D6), not a rider on a lint card. It is written up as an open question for the maintainer instead, and the CRM config carries a comment saying so.
Ruling 2 — the diagnostic text, and the test that was green for the wrong reason
Checked before writing: nothing outside this package keys on these rule ids (no docs catalogue, no allowlist), and the wiring guard's only constraint on
surfaceReasonis a length floor.The more useful find was in the opposite direction.
examples/app-crm/test/smoke.test.tsasserted:Both passed, for years, while all three rules enforced nothing — it asserted the enforced form and never the property that mattered. It is replaced by the property its greenness hid (no rule anchored where sharing has nothing to widen), so re-adding one goes red in the app's own suite as well as at
objectstack build.Each new message names the object, the rule, and why it cannot take effect, and gives both honest fixes because which one is right is the author's call:
H4 — both directions, and the ablation
Fires on broken metadata (above) · silent on correct metadata:
app-showcasebuilds green with all 41 author-time rules running and zero sharing findings.Unit coverage pins the silence individually, because a rule that only proves it fires is half-tested:
private,public_read, a custom object with no OWD (fails closed toprivate, so the rule is live), a retired OWD alias ('read'— the runtime fails closed toprivate, so firing would be a false positive on top of thesecurity-owd-aliaserror the value already earns), an anchor the stack does not declare, and an object with no authoredowner_id.Ablation (predicted direction declared first: the diagnostic disappears on defective input — it is not a test that turns red). Restoring
origin/main's lint source with the CRM's three rules back in place, rebuilding@objectstack/lintso the CLI'sdist/actually carried the ablation, and proving the ablation reached the artifact (grep -cfor the new rule id inpackages/lint/dist/index.js→0):Green, on metadata that fails at boot. That is the bug this card reports, and it confirms the new arm is what catches it rather than something else in the registry. Restoring and rebuilding puts the id back in
dist/(grep -c→1).Deliberately not judged
inertGrantReason's other arms are excluded by name, in the code and in the changeset: theowner_idarm (registry-injected, absent from authored metadata by design — asserting it would fail every object that correctly does not declare it by hand; the filer's scoping, adopted), thebypassObjectsarm (plugin configuration, not stack metadata, so it is not in this door's input), and the federated phantom-anchor arm (a provenance test over that same injected column). An unresolvablerule.objectis also not reported — absence of a schema is absence of evidence of inertness, and the runtime draws the same line by keeping existence a separate verdict from inertness.Scope note
content/docs/permissions/index.mdxpresentedHighValueOpportunitySharingRuleas "a real sharing rule from the CRM example app" — i.e. the documentation taught authors to write the exact declaration this PR makes a build error, and that symbol no longer exists after the repair. It is repointed atKeyAccountQualifiedContactRule, which is real, live, on aprivateobject, and already guarded byinert-wirings.test.ts; a short paragraph explains why the anchor's OWD is the load-bearing part. Named here rather than slipped in, per the bounded-fix discipline.content/docs/releases/**is untouched.Verification at
9753e5038pnpm --filter @objectstack/lint test→ 75 files, 2109 tests passedpnpm --filter @objectstack/example-crm test→ 4 files, 42 passed ·@objectstack/example-showcase test→ 21 files, 337 passedpnpm --filter @objectstack/example-crm --filter @objectstack/example-showcase build→ both✓ Build completepnpm --filter @objectstack/lint typecheck→ cleannode scripts/pm/dispatch-gates.mjs), all PASS:check:nul-bytes,check:changeset-gate-self-tests,check:cross-package-test-inputs,check:doc-anchors,check:docs-audit-scope,check:docs-redirects,check:objectui-changeset,check:published-readme-links,check:role-word,check:type-check-coverage, speccheck:empty-state/check:liveness/check:strictness-ledger/check:variant-docs, andcheck-adr-0087-registration/check-changeset-no-major/check-empty-changeset/check-cross-package-test-inputs/docs-audit/check-affected-docs. Convention-triggered by the test edits and also PASS:check:engine-double-contract,check:where-matcher,check:query-options-erasure.rule-id-barrel-exports.test.tsfailed until both new ids were exported frompackages/lint/src/index.ts.Clause-②: yes— this changes authoring-time accept/reject behaviour, declared on the claim comment.Generated by Claude Code