Repository navigation
lint: a sharing rule declared on an object whose effective sharing model is public is statically detectable and unreported until boot #9698
Description
Activity
os-support-ai commented
on Aug 18, 2026 CollaboratorMore actionsTriage routing:
domain:devx— a new author-time lint rule lands inpackages/lint. Left asfindingpending first-touch grading.
Generated by Claude Code
os-support-ai commented
on Aug 18, 2026 CollaboratorMore actionsFirst-touch grading (triage seat, session
session_014tGY3fzu4uwCoe7HrfUtWg): promoted topm:queue, type Task (routing todomain:devx/packages/lintalready recorded above).Rationale: the sibling ruling family applies — #7503 already establishes "a statically-decidable inert sharing declaration becomes a loud authoring-time error" for the
controlled_by_parent-with-no-master arm, and this card's two arms are the same judgement on adjacent facts, so it inherits that ruling and goes straight to the queue rather than the decision inbox. The boot WARN's insufficiency is measured, not asserted (the third rule warned nothing because reconcile never reachedgrant). The filer's scoping is adopted as binding for the rule: ⛔ theowner_idarm stays out (registry-injected, absent from authored metadata by design).Dispatch note — Clause-② heads-up for the claiming seat: a lint rule that refuses previously-lint-clean metadata changes authoring-time accept/reject behaviour. It is declared=enforced restoration (the runtime already refuses these rules; the declarations are dead on arrival), but the claim comment's
Clause-②line should be answered honestly against the mechanical test, and the dispatching seat prices the tier accordingly. Size/model suggestion: M, opus.
Generated by Claude Code
Claim: dev seat (dispatched)
Session:session_01XqDQYVU5smx29ts9pAErja
Branch:claude/issue-9698-inert-sharing-rule
Worktree:objectstack-9698
Domain:domain:devx
File surface:packages/lint/src/validate-sharing-rule-enforceability.ts(+ its test), onesurfaceReasonstring inpackages/lint/src/authoring-rules.ts,examples/app-crm/src/security/**,.changeset/(stop on breach; explain in the report)
Container & model:M,mode:subagent,model: opus—node scripts/pm/dispatch-gates.mjs --tier packages/lint/src/validate-sharing-rule-enforceability.ts packages/lint/src/authoring-rules.ts examples/app-crm/src/security/sharing-rules.tsreturns "no path-derived mandate ... floor sonnet · default opus · ceiling fable", i.e. a FLOOR, not a clearance.
Clause-②: yes
Serial constraints cleared: PR #9825 (claude/issue-4716-object-write-gate-p2) modifiespackages/lint/src/authoring-rules.ts— confirmed from its file list, and it is the same file my registry edit lands in. #9600 (claude/issue-9600-index-rule-config-path) has a pushed branch with an EMPTY diff againstorigin/mainat this reading, so it has no declared file surface yet. No in-flight claim touchesvalidate-sharing-rule-enforceability.ts.Two notes the dispatching seat asked for up front:
Clause-②: yesis the honest answer, and--tier's own output says the content limb is judged from card content, not paths: a new lint rule that refuses previously-lint-clean metadata changes authoring-time accept/reject behaviour. Per the queue gate that binds the PM seat, not me — recording it here so the gate has its declared input.- The registry edit is one line of prose, not a new entry. The plan is to add the arm to the existing
validateSharingRuleEnforceability(H3: an arm on a live rule, per the dispatch), which makes theauthoring-rules.tschange a correction to that entry'ssurfaceReason— it currently records "reads ONLYstack.sharingRules[].conditionand needs no other collection", which the new arm falsifies. Minimal overlap with feat(lint): the five gating object rules cross the runtime publish gate (#4716) #9825, but still the same file: flagging for serialisation as instructed.
Generated by Claude Code
Generated by Claude Code
- added a commit that references this issue
on Aug 19, 2026 { "issue": 9698, "status": "done", "branch": "claude/issue-9698-inert-sharing-rule", "pr": "https://github.com/objectstack-ai/objectstack/pull/9890", "premise_still_valid": true, "summary": "H1 first, since a no reshapes the card: the effective sharing model IS visible at lint time. `effectiveSharingModel` reads only `sharingModel` (z.enum().optional(), NO .default() — the #9689 trap is absent, so authored-vs-absent survives parsing), a `security.sharingModel` fallback that is unreachable under the strict ObjectSchema, and `isSystem`/`sys_` prefix. All authored, nothing resolved at boot. H3: shipped as a second ARM on the live `validateSharingRuleEnforceability` rather than a new rule — same surface, same finding shape, same severity bar, same CLI-only wiring. Two ids (`sharing-rule-object-not-shareable`, `sharing-rule-object-controlled-by-parent`) because ruling 1's measurement showed the arms are NOT the same failure. `packages/lint/src/authoring-rules.ts` IS touched (serialise against #9825, as asked) but only that entry's `surfaceReason`, which recorded 'reads ONLY sharingRules[].condition and needs no other collection' — the arm falsifies it, so the text is corrected to record that `objects` is already carried by the snapshot (#8309) while `sharingRules` is not. H2 decided the repair: 3 of the repo's 5 sharing rules 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 as #9237 did for app-showcase's two. The alternative (tighten the 3 OWDs so the rules go live) was BUILT and measured, not dismissed — it trips the access-matrix drift gate ('affects every principal', ADR-0090 D6 review), so it is escalated below rather than taken. Also repointed content/docs/permissions/index.mdx, which presented the deleted CRM rule as 'a real sharing rule' — i.e. the docs taught the exact declaration this PR now rejects.", "tests": "All readings at 9753e5038 (union run AFTER the final commit). `pnpm --filter @objectstack/lint test` -> 'Test Files 75 passed (75) / Tests 2109 passed (2109)'. `--filter @objectstack/example-crm test` -> 4 files, 42 passed; `--filter @objectstack/example-showcase test` -> 21 files, 337 passed. Both example builds -> 'Build complete'. `--filter @objectstack/lint typecheck` -> clean (script name echoed, not a zero-match pnpm no-op). RULING 1 MEASURED against a real SharingService over an in-memory engine, all three postures: public_read_write -> grant refused 'SHARING_NOT_ENABLED: not under record-sharing enforcement', buildReadFilter null, 0 share rows; controlled_by_parent -> refused with a DIFFERENT reason 'is controlled by its parent (master-detail); share the master record instead', buildReadFilter null; private CONTROL -> grant ACCEPTED, real sys_record_share row, filter {\"$or\":[{\"owner_id\":\"u_manager\"},{\"id\":{\"$in\":[\"d1\"]}}]}. So the public arm is INERT+misleading (nobody under-sees; the rule advertises a restriction that does not exist) and the CBP arm is WRONG in the security-shaped direction (author believes a grant exists; recipient may see nothing) — different wording, different ids. H2 counted through the production `objectstack build` gate, not grep: 5 declared, 3 fire (sharingRules[0..2].object in app-crm), 2 silent (app-showcase). H4 both directions: fires as above; app-showcase builds green with all 41 author-time rules running and zero sharing findings; unit tests pin the silence individually on private, public_read, custom-object-with-no-OWD (fails CLOSED to private), a retired OWD alias 'read' (also fails closed — firing would be a false positive), an undeclared anchor, and an owner_id-less object. ABLATION, predicted direction declared first and it was NOT 'turns red' — the diagnostic DISAPPEARS on defective input: restored origin/main's lint source with the CRM's 3 rules back, REBUILT @objectstack/lint so the CLI's dist actually carried the ablation, proved it reached the artifact (grep -c of the new id in packages/lint/dist/index.js -> 0), and app-crm built '✓ Build complete' — green on metadata that fails at boot, which is the bug. Restore + rebuild put the id back (grep -c -> 1). Gates re-derived from the ACTUAL changed paths via `node 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, spec check:empty-state/liveness/strictness-ledger/variant-docs, and check-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. One ratchet caught a real omission mid-run (rule-id-barrel-exports.test.ts failed until both ids were exported from packages/lint/src/index.ts).", "open_questions": [ { "question": "examples/app-crm now declares ZERO sharing rules and has NO private object, so it demonstrates record sharing nowhere. Measured, not assumed: all 6 CRM objects are public_read_write except crm_opportunity_line_item (controlled_by_parent). Should the CRM get a real record-sharing demonstration, and if so by tightening an OWD?", "options": [ "A — leave it. app-showcase already carries the sharing demonstration (2 live rules, position + unit_and_subordinates recipients, a compound ADR-0058 D3 condition), guarded by inert-wirings.test.ts. The CRM's OWDs stay as the ADR-0090 D1 grandfather stamps left them. Zero further cost; the new gate stops the defect recurring.", "B — tighten crm_opportunity (and/or crm_lead, crm_activity) to sharingModel: 'private' and re-add the rules, making the app's own stated intent true. MEASURED: this clears the 3 lint errors but trips 'Access matrix drift (4 changes) — capability changes must be reviewed', with each line reading 'public_read_write -> private (affects every principal)', plus 3 new security-private-no-readscope advisories needing an explicit readScope on crm_sales_user. Real cost, and it needs the access-matrix snapshot committed as the review artifact (ADR-0090 D6).", "C — give the CRM one NEW private object purpose-built for the demo, leaving the existing five baselines untouched. No access-matrix drift on existing objects, but it grows the reference app." ], "recommendation": "A, and B only on your explicit call. Real business need: the platform's sharing demonstration already EXISTS and is guarded — the CRM's three rules never worked, so removing them loses no demonstrated capability, only an appearance of one; under the startup-focus principle a second copy of a working demo is not pulled by anything. Long-term soundness: the OWD carries an explicit ADR-0090 D1 grandfather stamp saying record isolation there is deliberate, and the access-matrix gate exists precisely so 'affects every principal' is a reviewed decision and not a rider on a lint card — B is defensible but it is YOUR call, not mine, which is why it is here rather than in the PR. AI-authored-metadata safety: neutral between the options, because the gate now catches the defect at authoring time whichever baseline the app ends up with. If you do want B, note it is cheap to take later — the lint rule is what makes re-adding the rules safe." }, { "question": "Tier/queue-gate conflict to resolve on your side, recorded rather than silently picked: `Clause-②: yes` is the honest declaration (a new lint rule that refuses previously-lint-clean metadata changes authoring-time accept/reject behaviour), and `dispatch-gates.mjs --tier` states that a Clause-② card is fable-mandatory judged from card CONTENT — but this card was dispatched at opus, and `--tier` on these paths returns 'no path-derived mandate' (a FLOOR, never a clearance). Declared on the claim comment so the gate has its input.", "options": [ "A — PM applies needs:contract-review and holds the PR out of the queue until a contract-review-tier seat that is not the dispatching seat clears it.", "B — treat the path-derived 'no mandate' as sufficient and queue normally." ], "recommendation": "A — the declaration limb is explicitly path-independent and binds regardless of what the path derivation says, and self-clearing is forbidden to the dispatching seat. Flagging it is the whole of my role here; the disposition is yours." } ], "out_of_scope_findings": [ "filed as #9891: two docs pages still teach owner-type sharing rules (`type: 'owner'` / `ownedBy`), removed from SharingRuleSchema in v17 (#1878) — permissions-matrix.mdx ships one in its Configuration Example while its OWN callout says the form no longer parses, and protocol/objectql/security.mdx carries a whole 'Owner-Based Sharing' section whose enforcement callout still describes the pre-v17 'skipped (logged)' state rather than 'does not parse'. Unassigned, no labels (a concrete defect, left for your triage)." ] }
Generated by Claude Code
Generated by Claude Code
Contract review (triage seat, session
session_01YMmfpjWEqyMjuj1mi89Vsd, running at the contract-review tierclaude-fable-5; not the dispatching seat): CLEARED — verified against PR #9890's actual diff, not the report.One-line verdict: the accept-set narrowing is a faithful declared=enforced restoration — both new
errorarms mirrorassertNotInertGrant's decidable arms point-for-point (CBP tested first, matching the runtime's order), every over-refusal direction is pinned silent (custom no-OWD fails closed→private, retired alias fails closed, undeclared anchor = absence-of-evidence, registry-injectedowner_idand plugin-configbypassObjectsexcluded by name), and the measured blast radius (3 firing rules, all inert since authored) is repaired in the same PR under the #9237 precedent.effectiveSharingModelOfreads only authored bytes with no.default()erasure, so lint and runtime answer from the same inputs. Clearingneeds:contract-review; the card may enter the queue on the dispatching seat's normal flow. (The dev's open question 1 — whether app-crm should regain a live sharing demonstration — is a separate maintainer item for the devx seat's report; it does not gate this PR.)
Generated by Claude Code
- added a commit that references this issue
on Aug 23, 2026 - added a commit that references this issue
on Sep 29, 2026
Observation filed from #9237 (PR #9697). Not claiming — recording the class the fix there closed only for
examples/app-showcase.What is detectable and is not detected
SharingService.inertGrantReason(ADR-0111 D7,packages/plugins/plugin-sharing/src/sharing-service.ts) refuses a grant whose row no gate could ever consult. Two of its arms are decidable from the authored metadata alone, before anything boots:effectiveSharingModel(schema) === 'public'— the object declaressharingModel: 'public_read_write', so sharing has nothing left to widen;sharingModel: 'controlled_by_parent'— the detail's shares belong to its master.Today a rule in either state is accepted by
SharingRuleSchema, accepted bydefineRule, seeded intosys_sharing_rule, and only then refused, once per boot, as a WARN line inside the boot diagnostics block:Why the boot WARN is not sufficient as the diagnostic
Measured on the stock showcase at
c07d6e8b9while working #9237: three rules were in this state and only two warned. The third,share_high_value_red_projects_with_managers, had a compound condition matching no seeded row, soSharingRuleService.reconcilebuilt an empty desired set, never reachedgrant, and never threw. It was exactly as dead as the other two and produced no diagnostic at all — the WARN is a function of the data, not of the declaration.So the boot log cannot be the guard: an app whose seed data happens not to match a rule's criteria gets silence, and an app that later gains matching rows gets a WARN it did not get on the previous deploy.
Suggested shape (not prescribed)
An authoring/publish-time lint rule resolving each
sharingRules[].objectagainst the declared objects and refusing the two decidable arms — the same shape and the same reasoning as #7503 (sharingModel: controlled_by_parentwith nomaster_detailrelation), which is the nearest precedent for a statically-decidable sharing declaration made loud at publish time rather than at boot.Deliberately not in scope for such a rule: the
owner_idarm.owner_idis injected by the schema registry (packages/objectql/src/registry.ts), so it is absent from authored metadata and present on the runtime schema; asserting it statically would fail every object that correctly does not declare it by hand.What #9697 does and does not cover
#9697 adds
inert-wirings.test.tssection 6 inexamples/app-showcase— a guard over that app's own declarations only, plus a second arm checking the rule's audience holdsallowReadon the shared object. It closes the class for the reference app. It does nothing for any other app, and nothing at authoring time, which is what this card is about.