Skip to content

Commit 9753e50

Browse files
committed
feat(lint): gate a sharing rule anchored where sharing has nothing to 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
1 parent 55d2993 commit 9753e50

10 files changed

Lines changed: 554 additions & 114 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
"@objectstack/lint": minor
3+
---
4+
5+
feat(lint): a sharing rule anchored where sharing has nothing to widen is now an authoring-time error (#9698)
6+
7+
`validateSharingRuleEnforceability` gains its second arm. It already judged a
8+
sharing rule's `condition` against the compiler that lowers it; it now judges
9+
the rule's `object` against the verdict that decides whether the grant can
10+
exist at all.
11+
12+
Two new `error` ids, both decidable from authored metadata before anything
13+
boots, and both mirroring `SharingService.inertGrantReason` (ADR-0111 D7)
14+
rather than modelling it:
15+
16+
- **`sharing-rule-object-not-shareable`** — the anchor object's effective
17+
sharing model is `public` (an explicit `sharingModel: 'public_read_write'`,
18+
or no `sharingModel` on a system object, which ADR-0090 D1 resolves to
19+
public). Sharing only ever WIDENS an OWD baseline, so on the widest baseline
20+
there is nothing to widen.
21+
- **`sharing-rule-object-controlled-by-parent`** — the anchor is a
22+
master-detail detail, whose visibility is derived from its master
23+
(ADR-0055). It gets its own id and its own fix-it ("share the master
24+
record instead"), because `effectiveSharingModel` collapses it onto the same
25+
`public` verdict while the correct repair is completely different.
26+
27+
Both were previously accepted by `SharingRuleSchema`, accepted by `defineRule`,
28+
seeded into `sys_sharing_rule`, and only then refused — once per boot, as a
29+
WARN line inside the boot diagnostics block. That WARN is not a sufficient
30+
diagnostic, and the reason is measured rather than argued: a rule whose criteria
31+
match no seeded row never reaches `grant`, so it never throws and warns nothing
32+
while being exactly as dead. The WARN is a function of the DATA; the defect is a
33+
property of the DECLARATION.
34+
35+
**Blast radius, measured through `objectstack build` before deciding the
36+
severity:** 5 sharing rules are declared in this repo. 3 fire, all of them in
37+
`examples/app-crm` — `share_high_value_opps_with_managers`,
38+
`share_active_leads_with_manager` and `share_won_deal_activities`, anchored on
39+
`crm_opportunity`, `crm_lead` and `crm_activity`, every one of them
40+
`sharingModel: 'public_read_write'`. They have been failing their boot backfill
41+
on every boot of that app since they were written, and they are removed here
42+
under ADR-0049 enforce-or-remove — the same call #9237 made for the two
43+
equivalent rules in `app-showcase`. The other 2 (app-showcase's, both on
44+
`private` objects) stay silent, which is the direction that had to be proven
45+
rather than hoped for.
46+
47+
The CRM's smoke test used to assert that these rules existed and were of the
48+
enforced `criteria` type. Both assertions passed while all three rules enforced
49+
nothing, so the assertion is replaced by the property their greenness hid: no
50+
declared rule may be anchored where sharing has nothing to widen.
51+
52+
Deliberately NOT judged, because they are not decidable from authored metadata:
53+
the `owner_id` arm (`owner_id` is injected by the schema registry, so asserting
54+
it would fail every object that correctly does not declare it by hand), the
55+
`bypassObjects` arm (plugin configuration, not stack metadata), and the
56+
federated phantom-anchor arm (a provenance test over that same injected column).

‎content/docs/permissions/index.mdx‎

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,22 +25,28 @@ is reserved (D3) — if you knew the v1 model, start at
2525
[Positions](/docs/permissions/positions).
2626

2727
Access rules are metadata like everything else — this is a real sharing rule from
28-
the CRM example app:
28+
the showcase example app:
2929

3030
```typescript
31-
export const HighValueOpportunitySharingRule = defineSharingRule({
31+
export const KeyAccountQualifiedContactRule = defineSharingRule({
3232
type: 'criteria',
33-
name: 'share_high_value_opps_with_managers',
34-
label: 'High-Value Deals → Sales Managers',
35-
description: 'Automatically share opportunities over $100,000 with all Sales Managers.',
36-
object: 'crm_opportunity',
37-
condition: 'record.amount > 100000',
38-
accessLevel: 'edit',
39-
sharedWith: { type: 'position', value: 'sales_manager' },
33+
name: 'share_key_account_qualified_contacts_with_managers',
34+
label: 'Key-Account Qualified Contacts → Managers',
35+
description: 'Share qualified contacts at the key account with managers.',
36+
object: 'showcase_contact',
37+
condition: "record.stage == 'qualified' && record.company == 'Northwind'",
38+
accessLevel: 'read',
39+
sharedWith: { type: 'position', value: 'manager' },
4040
active: true,
4141
});
4242
```
4343

44+
Note the object it is anchored on. `showcase_contact` is OWD `private`, which is
45+
what makes this grant one a read gate actually consults — sharing only ever
46+
WIDENS a baseline, so a rule on an object that is already `public_read_write`
47+
grants nothing and is refused at boot. That is an authoring-time build error
48+
(`sharing-rule-object-not-shareable`), not a runtime surprise.
49+
4450
Because AI agents act through the same permission-aware surface, these rules bound
4551
agent access exactly as they bound users ([Actions as Tools](/docs/ai/actions-as-tools)).
4652

‎examples/app-crm/objectstack.config.ts‎

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,6 @@ import {
1717
FinanceApproverPosition,
1818
SalesUserPermissionSet,
1919
GuestPortalProfile,
20-
HighValueOpportunitySharingRule,
21-
RepLeadSharingRule,
22-
WonDealActivitySharingRule,
2320
} from './src/security/index.js';
2421
import { registerCrmPositionBindings } from './src/security/bind-position-sets.js';
2522
import { CrmSeedData } from './src/data/index.js';
@@ -102,11 +99,21 @@ export default defineStack({
10299
// Security
103100
positions: [SalesRepPosition, SalesManagerPosition, FinanceApproverPosition],
104101
permissions: [SalesUserPermissionSet, GuestPortalProfile],
105-
sharingRules: [
106-
HighValueOpportunitySharingRule,
107-
RepLeadSharingRule,
108-
WonDealActivitySharingRule,
109-
],
102+
// No `sharingRules`. The three this app used to declare
103+
// (`share_high_value_opps_with_managers`, `share_active_leads_with_manager`,
104+
// `share_won_deal_activities`) were anchored on `crm_opportunity`,
105+
// `crm_lead` and `crm_activity` — all three `sharingModel:
106+
// 'public_read_write'`. Sharing only ever WIDENS an OWD baseline, so on the
107+
// widest baseline there is nothing to widen: `assertNotInertGrant` refused
108+
// every grant with SHARING_NOT_ENABLED and the boot backfill failed for each
109+
// rule, on every boot, since they were written. They granted nothing and
110+
// were removed under ADR-0049 enforce-or-remove (#9698), the same call
111+
// #9237 made for app-showcase's two.
112+
//
113+
// ⛔ Do not re-add one on a public object — `sharing-rule-object-not-shareable`
114+
// now fails the build, and its message states the two honest fixes. Giving
115+
// this app a LIVE sharing demonstration means giving it a `private` object
116+
// first; that is an access-matrix change (ADR-0090 D6 review), not a rider.
110117

111118
// Seed data
112119
data: CrmSeedData,

‎examples/app-crm/src/security/index.ts‎

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,3 @@ export {
88
GuestPortalProfile,
99
} from './sales-positions.js';
1010

11-
export {
12-
HighValueOpportunitySharingRule,
13-
RepLeadSharingRule,
14-
WonDealActivitySharingRule,
15-
} from './sharing-rules.js';
16-

‎examples/app-crm/src/security/sharing-rules.ts‎

Lines changed: 0 additions & 65 deletions
This file was deleted.

‎examples/app-crm/test/smoke.test.ts‎

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -93,11 +93,34 @@ describe('app-crm minimal metadata bundle', () => {
9393
expect(stack.i18n!.supportedLocales).toContain('zh-CN');
9494
});
9595

96-
it('has criteria sharing rules (the enforced form — owner-type was retired)', () => {
97-
const rules = stack.sharingRules ?? [];
98-
expect(rules.length).toBeGreaterThanOrEqual(2);
99-
// `type: 'owner'` no longer parses (never enforced; ADR-0078): every
100-
// declared rule is the enforced criteria form.
96+
// #9698 — this used to assert `rules.length >= 2` and `every(type ===
97+
// 'criteria')`. Both passed, and neither was the property that mattered:
98+
// all three declared rules were anchored on `public_read_write` objects, so
99+
// `assertNotInertGrant` refused every grant and the boot backfill failed for
100+
// each one, on every boot. A test can assert the enforced FORM and stay
101+
// green while the rule enforces nothing. The three were removed (ADR-0049
102+
// enforce-or-remove); what replaces the assertion is the property their
103+
// greenness hid, so re-adding one on a public object goes red HERE as well
104+
// as at `objectstack build`.
105+
it('no declared sharing rule is anchored where sharing has nothing to widen (#9698)', () => {
106+
const rules = (stack.sharingRules ?? []) as Array<{ name?: string; object?: string; type?: string }>;
107+
const owdOf = new Map(
108+
((stack.objects ?? []) as Array<{ name?: string; sharingModel?: string }>)
109+
.map((o) => [String(o.name), o.sharingModel]),
110+
);
111+
// `effectiveSharingModel` maps BOTH to 'public'; `controlled_by_parent`
112+
// earns its own runtime refusal ("share the master record instead").
113+
const INERT_OWD = new Set(['public_read_write', 'controlled_by_parent']);
114+
const offenders = rules
115+
.filter((r) => INERT_OWD.has(String(owdOf.get(String(r.object)))))
116+
.map((r) => `${r.name} → ${r.object} (sharingModel '${owdOf.get(String(r.object))}')`);
117+
expect(
118+
offenders,
119+
`sharing rule(s) whose grant no gate would consult — the boot backfill refuses these with `
120+
+ `SHARING_NOT_ENABLED: ${offenders.join(', ')}`,
121+
).toEqual([]);
122+
// `type: 'owner'` no longer parses (never enforced; ADR-0078): whatever is
123+
// declared is the enforced criteria form.
101124
expect(rules.every((r) => r.type === 'criteria')).toBe(true);
102125
});
103126

‎packages/lint/src/authoring-rules.ts‎

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1306,16 +1306,26 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
13061306
surfaceReason: RUNTIME_NEEDS_FULL_SNAPSHOT,
13071307
run: (stack) => validateOrgAxisRedLines(stack),
13081308
},
1309-
// #4698 — the "declared but never read" gate, for the one surface where the
1310-
// predicate is EXACT rather than inferred. A sharing rule's `condition` has a
1311-
// single runtime consumer (`bootstrapDeclaredSharingRules`) whose only use of
1312-
// the key is `compileCelToFilter(condition, { variables: {} })`; a condition
1313-
// that does not lower means the rule is SKIPPED at boot, so the grant is
1314-
// declared and does not exist. The lint calls that same compiler, from the
1315-
// same package, with the same options — the verdict cannot drift from the
1316-
// consumer's. Gating for the ADR-0078 reason `SharingRuleSchema`'s own
1317-
// docblock states: the whole authorable surface is enforced, and this was the
1318-
// one field where that sentence was not yet true.
1309+
// #4698 / #9698 — the "declared but never read" gate, for the two fields of a
1310+
// sharing rule where the predicate is EXACT rather than inferred.
1311+
//
1312+
// - `condition` has a single runtime consumer
1313+
// (`bootstrapDeclaredSharingRules`) whose only use of the key is
1314+
// `compileCelToFilter(condition, { variables: {} })`; a condition that
1315+
// does not lower means the rule is SKIPPED at boot.
1316+
// - `object` decides whether the grant is refused outright: reconcile hands
1317+
// each row to `SharingService.grant`, whose ADR-0111 D7 pre-flight THROWS
1318+
// `SHARING_NOT_ENABLED` when the anchor's effective sharing model is
1319+
// `public` or it is a `controlled_by_parent` detail. Both are decidable
1320+
// from authored metadata; the other arms of that verdict (`owner_id`, the
1321+
// bypass set, federated anchors) are not, and are excluded by name.
1322+
//
1323+
// Either way the grant is declared and does not exist. The lint calls the
1324+
// same compiler and mirrors the same verdict function, from the same inputs
1325+
// — the verdict cannot drift from the consumers'. Gating for the ADR-0078
1326+
// reason `SharingRuleSchema`'s own docblock states: the whole authorable
1327+
// surface is enforced, and these were the fields where that sentence was not
1328+
// yet true.
13191329
{
13201330
name: 'validateSharingRuleEnforceability',
13211331
tier: 'gating',
@@ -1324,11 +1334,16 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
13241334
source: 'packages/lint/src/validate-sharing-rule-enforceability.ts',
13251335
surfaces: CLI_ONLY,
13261336
surfaceReason:
1327-
'P2 (#4463): a sharing rule is not a `flow`, and P1 gates `flow` alone. The rule itself is '
1328-
+ 'snapshot-safe — it reads ONLY `stack.sharingRules[].condition` and needs no other collection — '
1329-
+ 'so widening it here is a `runtimeTypes: [\'sharing_rule\']` edit once the gate accepts that type, '
1330-
+ 'not new wiring. Recorded as pending rather than done, because a rule that has never run at a '
1331-
+ 'door should not claim it.',
1337+
'P2 (#4463): a sharing rule is not a `flow`, and P1 gates `flow` alone. This entry used to add '
1338+
+ 'that the rule reads ONLY `stack.sharingRules[].condition` and needs no other collection, so '
1339+
+ 'crossing was a lone `runtimeTypes` edit. #9698 FALSIFIED that: the anchor arm resolves '
1340+
+ '`sharingRules[].object` against `stack.objects` to read the anchor\'s OWD, so the rule is now '
1341+
+ 'cross-collection. `objects` IS carried by the per-write snapshot (`CONTEXT_STACK_KEYS`, #8309), '
1342+
+ 'so the remaining gap is unchanged in SHAPE — the gate must accept a `sharing_rule` type and the '
1343+
+ 'snapshot must carry `sharingRules`, which it does not — but it is now TWO collections, not one. '
1344+
+ 'Crossing with `sharingRules` uncarried would enforce this id for zero of its inputs while the '
1345+
+ 'entry claimed the door (#7220). Recorded as pending rather than done, because a rule that has '
1346+
+ 'never run at a door should not claim it.',
13321347
run: (stack) => validateSharingRuleEnforceability(stack),
13331348
},
13341349
// #4983 — the sibling surface of the rule above, and ADR-0056 D4's gate,

‎packages/lint/src/index.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -261,14 +261,18 @@ export {
261261
} from './validate-org-axis-red-lines.js';
262262
export type { OrgAxisFinding, OrgAxisSeverity } from './validate-org-axis-red-lines.js';
263263

264-
// #4698 — "a key that nothing reads should not validate clean", for the one
265-
// surface where "is it read?" is decidable: a sharing rule's `condition` is
266-
// read ONLY through `compileCelToFilter`, so the lint calls that same compiler
267-
// rather than modelling the consumer.
264+
// #4698 / #9698 — "a key that nothing reads should not validate clean", for the
265+
// two fields of a sharing rule where "is it read?" is decidable. The
266+
// `condition` is read ONLY through `compileCelToFilter`, and the `object`
267+
// decides whether `assertNotInertGrant` would refuse the grant outright — so
268+
// the lint calls the same compiler and mirrors the same verdict function,
269+
// rather than modelling either consumer.
268270
export {
269271
validateSharingRuleEnforceability,
270272
SHARING_RULE_UNLOWERABLE_CONDITION,
271273
SHARING_RULE_RUNTIME_VARIABLE_CONDITION,
274+
SHARING_RULE_OBJECT_NOT_SHAREABLE,
275+
SHARING_RULE_OBJECT_CONTROLLED_BY_PARENT,
272276
} from './validate-sharing-rule-enforceability.js';
273277
export type {
274278
SharingRuleEnforceabilityFinding,

0 commit comments

Comments
 (0)