Skip to content

Commit ddd6650

Browse files
feat(lint): gate a sharing-rule condition the runtime can only skip (#4698) (#4985)
#4698 asks for a "declared but never read" check. The general form is not a lint question: "is this key read?" is only decidable from authored metadata when the consumer's decision procedure is in hand, and a repo-wide grep for a reader is famously not evidence of absence (#4604, #4914). A sharing rule's `condition` is the case where it IS decidable. Its one runtime consumer, `bootstrapDeclaredSharingRules`, does exactly one thing with the key: `compileCelToFilter(condition, { variables: {} })`. A condition that does not lower is not degraded or partially applied — the rule is SKIPPED, never reaches `sys_sharing_rule`, and grants nothing, with one boot WARN as the only trace. So the new rule does not model the consumer. It calls the consumer's own compiler, from the same package, on the same input, with the same options, and is pinned in both directions against a shared corpus. Two ids, because the two authoring mistakes have different fixes: - `sharing-rule-unlowerable-condition` — outside the pushdown subset (`has(...)`, `size(...)`, arithmetic, ternary, cross-object path). This is the issue's measured instance: `has()` is right in an object validation, which is interpreted, and wrong here, where the condition is compiled. - `sharing-rule-runtime-variable-condition` — reads `current_user.*`. Criteria rules are materialised, so there is no current user at compile time; the fix is RLS, a different mechanism, not a respelling. Both `error`, on all three commands, for the ADR-0078 reason SharingRuleSchema's own docblock states: the whole authorable surface is enforced, and this was the one field where that sentence was not true. CEL syntax stays with `expression-invalid` — one field, one complaint. Measured before shipping: every sharing-rule condition and RLS predicate declared anywhere in this repo lowers cleanly, so nothing that works today goes red. The "before" half of the proof is in the test rather than in prose — the full authoring registry runs over the offending stack and every OTHER rule must stay silent, which keeps holding as rules are added. Deliberately out of scope, filed instead of guessed: - RLS `using`/`check` (#4983) — same class, and ADR-0056 D4 explicitly asks for the gate, but its decision procedure needs `sqlPredicateToCel`, which lives in a runtime `@objectstack/lint` must not import. Hoisting the bridge into `@objectstack/formula` comes first; copying it would fork the predicate. - `validateOrgAxisRedLines` reads sharing-rule keys the spec rejects (#4984). - The tenant-scoped declared unique index (issue instance 2) is a driver-sql / objectql fix; tenancy is kernel-injected, not authored, so lint cannot judge it — the existing rule says so in its own comment. Claude-Session: https://claude.ai/code/session_018iARDqtrhQgz6fVHDeDkbQ Co-authored-by: Claude <noreply@anthropic.com>
1 parent c87ef70 commit ddd6650

5 files changed

Lines changed: 586 additions & 0 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
"@objectstack/lint": minor
3+
---
4+
5+
feat(lint): reject a sharing-rule condition the runtime can only skip (#4698)
6+
7+
#4698 reported the same failure shape three times in one app in one day: a key
8+
that is authored, is schema-valid, reads as meaningful — and is never consumed
9+
by the runtime. Every check verifies that what is declared is *well-formed*,
10+
never that it is *read*. The issue's third measured instance is a sharing rule
11+
whose CEL `condition` uses `has(...)`: the seeder cannot lower it, skips the
12+
rule, and the only signal is one WARN line at boot. The rule exists in
13+
metadata, is absent from `sys_sharing_rule`, and grants nothing.
14+
15+
**New rules, both `error`, on all three authoring commands:**
16+
17+
- **`sharing-rule-unlowerable-condition`** — the condition is outside the
18+
pushdown subset: a function call (`has(...)`, `size(...)`), arithmetic, a
19+
ternary, or a cross-object path (`record.account.region`).
20+
- **`sharing-rule-runtime-variable-condition`** — the condition reads
21+
`current_user.*`. Criteria sharing rules are materialised (one static
22+
`criteria_json` per rule, from which grants are written), so there is no
23+
"current user" at compile time. The fix is a different mechanism, not a
24+
different spelling, which is why it has its own id.
25+
26+
Fix each by rewriting the predicate inside the lowerable subset — `==` `!=`
27+
`>` `<` `>=` `<=`, `in`, `&&` `||` `!`, `== null` / `!= null`, and
28+
`startsWith` / `endsWith` / `contains` over single-column `record.<field>`
29+
paths (ADR-0058 D2). Two specific migrations: `has(record.x)` → `record.x !=
30+
null` (`has()` is correct in an object *validation* rule, which is
31+
interpreted, and wrong here, where the condition is compiled); and a related
32+
record's field → denormalise it onto this object (formula/rollup) and test
33+
that column, or share the related object instead. For per-user access, use an
34+
RLS policy (`rowLevelSecurity[].using`), where `current_user.*` *is* resolved.
35+
36+
**Why this one surface and not "unread keys" in general.** "Is this key read?"
37+
is only a lint question when the answer is computable from the authored
38+
metadata alone, and usually it is not — a repo-wide grep for a reader is not
39+
evidence of absence, and a consumer may live in another package, another repo,
40+
or an uninstalled plugin. A sharing rule's `condition` is the case where the
41+
predicate is exact: its one runtime consumer
42+
(`bootstrapDeclaredSharingRules`) does exactly one thing with the key —
43+
`compileCelToFilter(condition, { variables: {} })` — and a condition that does
44+
not lower means the rule is skipped outright. So the lint calls that same
45+
compiler, from the same package, with the same options, instead of modelling
46+
the consumer; the verdict is identical to the seeder's by construction and is
47+
pinned in both directions by a test over a shared corpus.
48+
49+
`error` rather than advisory, per the ADR-0078 claim `SharingRuleSchema`'s own
50+
docblock makes ("the whole authorable surface is enforced — nothing here
51+
validates and then silently does nothing"): there is no reading under which an
52+
unlowerable condition does what it says. It fails closed, which is why it was
53+
survivable, not why it was acceptable. Measured before shipping: every
54+
sharing-rule condition declared anywhere in this repo lowers cleanly, so the
55+
gate turns nothing red that works today.
56+
57+
CEL *syntax* errors are deliberately left to `expression-invalid`, which
58+
already gates this same field with a message written about syntax.

packages/lint/src/authoring-rules.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ import { validateSeedStateMachine } from './validate-seed-state-machine.js';
120120
import { validateVisibilityPredicates } from './validate-visibility-predicates.js';
121121
import { validateSecurityPosture } from './validate-security-posture.js';
122122
import { validateOrgAxisRedLines } from './validate-org-axis-red-lines.js';
123+
import { validateSharingRuleEnforceability } from './validate-sharing-rule-enforceability.js';
123124
import { validateActionLocations } from './validate-action-locations.js';
124125
import { lintFlowPatterns } from './lint-flow-patterns.js';
125126
import { lintLivenessProperties } from './lint-liveness-properties.js';
@@ -795,6 +796,31 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
795796
surfaceReason: RUNTIME_NEEDS_FULL_SNAPSHOT,
796797
run: (stack) => validateOrgAxisRedLines(stack),
797798
},
799+
// #4698 — the "declared but never read" gate, for the one surface where the
800+
// predicate is EXACT rather than inferred. A sharing rule's `condition` has a
801+
// single runtime consumer (`bootstrapDeclaredSharingRules`) whose only use of
802+
// the key is `compileCelToFilter(condition, { variables: {} })`; a condition
803+
// that does not lower means the rule is SKIPPED at boot, so the grant is
804+
// declared and does not exist. The lint calls that same compiler, from the
805+
// same package, with the same options — the verdict cannot drift from the
806+
// consumer's. Gating for the ADR-0078 reason `SharingRuleSchema`'s own
807+
// docblock states: the whole authorable surface is enforced, and this was the
808+
// one field where that sentence was not yet true.
809+
{
810+
name: 'validateSharingRuleEnforceability',
811+
tier: 'gating',
812+
input: 'parsed',
813+
commands: ALL,
814+
source: 'packages/lint/src/validate-sharing-rule-enforceability.ts',
815+
surfaces: CLI_ONLY,
816+
surfaceReason:
817+
'P2 (#4463): a sharing rule is not a `flow`, and P1 gates `flow` alone. The rule itself is '
818+
+ 'snapshot-safe — it reads ONLY `stack.sharingRules[].condition` and needs no other collection — '
819+
+ 'so widening it here is a `runtimeTypes: [\'sharing_rule\']` edit once the gate accepts that type, '
820+
+ 'not new wiring. Recorded as pending rather than done, because a rule that has never run at a '
821+
+ 'door should not claim it.',
822+
run: (stack) => validateSharingRuleEnforceability(stack),
823+
},
798824
];
799825

800826
// ─── Runner ─────────────────────────────────────────────────────────

packages/lint/src/index.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,20 @@ export {
195195
} from './validate-org-axis-red-lines.js';
196196
export type { OrgAxisFinding, OrgAxisSeverity } from './validate-org-axis-red-lines.js';
197197

198+
// #4698 — "a key that nothing reads should not validate clean", for the one
199+
// surface where "is it read?" is decidable: a sharing rule's `condition` is
200+
// read ONLY through `compileCelToFilter`, so the lint calls that same compiler
201+
// rather than modelling the consumer.
202+
export {
203+
validateSharingRuleEnforceability,
204+
SHARING_RULE_UNLOWERABLE_CONDITION,
205+
SHARING_RULE_RUNTIME_VARIABLE_CONDITION,
206+
} from './validate-sharing-rule-enforceability.js';
207+
export type {
208+
SharingRuleEnforceabilityFinding,
209+
SharingRuleEnforceabilitySeverity,
210+
} from './validate-sharing-rule-enforceability.js';
211+
198212
export {
199213
validateDashboardActionRefs,
200214
DASHBOARD_ACTION_TARGET_UNDEFINED,
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { compileCelToFilter } from '@objectstack/formula';
5+
6+
import {
7+
validateSharingRuleEnforceability,
8+
SHARING_RULE_UNLOWERABLE_CONDITION,
9+
SHARING_RULE_RUNTIME_VARIABLE_CONDITION,
10+
} from './validate-sharing-rule-enforceability.js';
11+
import { AUTHORING_RULES, runAuthoringRules } from './authoring-rules.js';
12+
13+
const ids = (stack: unknown) => validateSharingRuleEnforceability(stack).map((f) => f.rule);
14+
15+
/** A complete, spec-shaped sharing rule with the condition swapped in. */
16+
const ruleWith = (condition: unknown) => ({
17+
sharingRules: [
18+
{
19+
name: 'high_value_opps',
20+
type: 'criteria',
21+
object: 'opportunity',
22+
accessLevel: 'read',
23+
sharedWith: { type: 'team', value: 'deal_desk' },
24+
condition,
25+
},
26+
],
27+
});
28+
29+
// ── Red: declared, schema-valid, and never read ──────────────────────
30+
//
31+
// Every source below parses as CEL and passes `SharingRuleSchema`. The seeder
32+
// still drops the rule on the floor, which is the whole defect (#4698).
33+
34+
describe('validateSharingRuleEnforceability — the declared-but-never-read cases go RED', () => {
35+
it('flags `has(...)` — the measured instance from the issue (hotcrm#621/#633)', () => {
36+
const findings = validateSharingRuleEnforceability(
37+
ruleWith("has(record.owner_id) && record.stage == 'closed_won'"),
38+
);
39+
expect(findings).toHaveLength(1);
40+
expect(findings[0]).toMatchObject({
41+
severity: 'error',
42+
rule: SHARING_RULE_UNLOWERABLE_CONDITION,
43+
// The declaration site is named, not just the rule.
44+
path: 'sharingRules[0].condition',
45+
where: 'sharing rule "high_value_opps" on object "opportunity"',
46+
});
47+
// It must say what actually happens at boot, not merely "unsupported".
48+
expect(findings[0].message).toMatch(/SKIPS the rule at boot/);
49+
expect(findings[0].message).toMatch(/never written to `sys_sharing_rule`/);
50+
// …and prescribe the fix that works on THIS surface.
51+
expect(findings[0].hint).toMatch(/record\.x != null/);
52+
expect(findings[0].hint).toMatch(/INTERPRETED/);
53+
});
54+
55+
it.each([
56+
['a bare function call', 'size(record.tags) > 0'],
57+
['arithmetic', 'record.amount * 2 > 100'],
58+
['a cross-object path', "record.account.region == 'EU'"],
59+
['a ternary', "record.stage == 'won' ? true : false"],
60+
])('flags %s', (_label, source) => {
61+
expect(ids(ruleWith(source))).toEqual([SHARING_RULE_UNLOWERABLE_CONDITION]);
62+
});
63+
64+
it('gives `current_user.*` its own id — the fix is a different mechanism, not a respelling', () => {
65+
const findings = validateSharingRuleEnforceability(ruleWith('record.owner_id == current_user.id'));
66+
expect(findings).toHaveLength(1);
67+
expect(findings[0]).toMatchObject({
68+
severity: 'error',
69+
rule: SHARING_RULE_RUNTIME_VARIABLE_CONDITION,
70+
path: 'sharingRules[0].condition',
71+
});
72+
expect(findings[0].message).toMatch(/current_user\.id/);
73+
// Points at the surface where `current_user.*` IS resolved.
74+
expect(findings[0].hint).toMatch(/rowLevelSecurity\[\]\.using/);
75+
});
76+
77+
it('reports the parsed tier identically — the envelope `ExpressionInputSchema` produces', () => {
78+
expect(ids(ruleWith({ dialect: 'cel', source: 'size(record.tags) > 0' })))
79+
.toEqual([SHARING_RULE_UNLOWERABLE_CONDITION]);
80+
});
81+
82+
it('names each offending rule separately, with its own index', () => {
83+
const findings = validateSharingRuleEnforceability({
84+
sharingRules: [
85+
{ name: 'ok', object: 'a', condition: "record.stage == 'won'" },
86+
{ name: 'fn', object: 'b', condition: 'has(record.x)' },
87+
{ name: 'var', object: 'c', condition: 'record.owner == current_user.id' },
88+
],
89+
});
90+
expect(findings.map((f) => [f.rule, f.path])).toEqual([
91+
[SHARING_RULE_UNLOWERABLE_CONDITION, 'sharingRules[1].condition'],
92+
[SHARING_RULE_RUNTIME_VARIABLE_CONDITION, 'sharingRules[2].condition'],
93+
]);
94+
});
95+
});
96+
97+
// ── Green: conditions that really are read ───────────────────────────
98+
//
99+
// A false positive here is worse than the gap the rule closes: it rejects
100+
// security metadata that enforces correctly today and hands the author a
101+
// "correction" that would break it.
102+
103+
describe('validateSharingRuleEnforceability — conditions the runtime DOES read stay green', () => {
104+
it.each([
105+
["record.health == 'red'"],
106+
["record.health == 'red' && record.budget > 100000"],
107+
['record.done == false'],
108+
["record.stage in ['closed_won', 'closed_lost']"],
109+
['record.closed_at == null'],
110+
["record.name.startsWith('ACME')"],
111+
["!(record.stage in ['draft']) || record.amount >= 1000"],
112+
])('accepts %s', (source) => {
113+
expect(validateSharingRuleEnforceability(ruleWith(source))).toEqual([]);
114+
});
115+
116+
it('accepts every sharing-rule condition the bundled examples declare', () => {
117+
// Lifted verbatim from examples/app-showcase/src/security/sharing-rules.ts
118+
// and examples/app-crm. The gate must not turn shipped apps red.
119+
const shipped = [
120+
"record.health == 'red'",
121+
"record.health == 'red' && record.budget > 100000",
122+
"record.status == 'new'",
123+
'record.done == false',
124+
];
125+
for (const source of shipped) {
126+
expect(validateSharingRuleEnforceability(ruleWith(source))).toEqual([]);
127+
}
128+
});
129+
130+
it('leaves CEL SYNTAX errors to validateStackExpressions — no double report', () => {
131+
// Parses nowhere, but this rule stays silent: `expression-invalid` already
132+
// gates the same field with a message written about syntax.
133+
expect(compileCelToFilter('record.stage ==', { variables: {} })).toMatchObject({ reason: 'parse-error' });
134+
expect(validateSharingRuleEnforceability(ruleWith('record.stage =='))).toEqual([]);
135+
});
136+
137+
it('ignores shapes Zod owns rather than inventing a second complaint', () => {
138+
expect(ids(ruleWith(undefined))).toEqual([]);
139+
expect(ids(ruleWith(''))).toEqual([]);
140+
expect(ids(ruleWith(' '))).toEqual([]);
141+
expect(ids(ruleWith(42))).toEqual([]);
142+
expect(ids(ruleWith({ dialect: 'cel' }))).toEqual([]);
143+
});
144+
145+
it('is a no-op on a stack that declares no sharing rules', () => {
146+
expect(validateSharingRuleEnforceability({})).toEqual([]);
147+
expect(validateSharingRuleEnforceability(undefined)).toEqual([]);
148+
expect(validateSharingRuleEnforceability({ sharingRules: [] })).toEqual([]);
149+
});
150+
151+
it('checks inactive rules too — the seeder compiles the condition regardless of `active`', () => {
152+
// `bootstrapDeclaredSharingRules` carries `active` through to `defineRule`;
153+
// it does not skip the compile. A rule that is off today and unlowerable is
154+
// still a rule that will grant nothing the day someone switches it on.
155+
expect(ids({ sharingRules: [{ name: 'r', object: 'o', active: false, condition: 'has(record.x)' }] }))
156+
.toEqual([SHARING_RULE_UNLOWERABLE_CONDITION]);
157+
});
158+
});
159+
160+
// ── The predicate is the consumer's, not a model of it ───────────────
161+
162+
describe('validateSharingRuleEnforceability — the verdict IS the seeder\'s verdict', () => {
163+
const corpus = [
164+
"record.health == 'red'",
165+
"record.health == 'red' && record.budget > 100000",
166+
'record.done == false',
167+
"record.stage in ['a', 'b']",
168+
'record.closed_at != null',
169+
'has(record.owner_id)',
170+
'size(record.tags) > 0',
171+
'record.amount * 2 > 100',
172+
"record.account.region == 'EU'",
173+
'record.owner_id == current_user.id',
174+
];
175+
176+
it('agrees with `compileCelToFilter({ variables: {} })` on every source, in both directions', () => {
177+
for (const source of corpus) {
178+
// This is exactly the call `bootstrap-declared-sharing-rules.ts` makes.
179+
const seederWouldSeed = compileCelToFilter(source, { variables: {} }).ok;
180+
const lintIsClean = validateSharingRuleEnforceability(ruleWith(source)).length === 0;
181+
expect({ source, lintIsClean }).toEqual({ source, lintIsClean: seederWouldSeed });
182+
}
183+
});
184+
185+
/**
186+
* The "before" half of the proof, kept mechanical rather than asserted in
187+
* prose. #4698's complaint is that the offending stack passes the WHOLE
188+
* toolchain, so it is not enough to show the new rule goes red — it must also
189+
* be shown that nothing else ever did. Running the full author-time registry
190+
* over the fixture and demanding that every OTHER rule stays silent is that
191+
* statement, and unlike a comment it keeps holding: if some future rule grows
192+
* to cover this shape, this test fails and someone has to decide which of the
193+
* two owns it, instead of the stack quietly acquiring a duplicate diagnostic.
194+
*/
195+
it('no OTHER author-time rule sees this — which is exactly why the gate was missing', () => {
196+
const offending = {
197+
objects: [
198+
{
199+
name: 'opportunity',
200+
label: 'Opportunity',
201+
sharingModel: 'private',
202+
fields: {
203+
name: { type: 'text', label: 'Name' },
204+
owner_id: { type: 'text', label: 'Owner' },
205+
stage: { type: 'text', label: 'Stage' },
206+
},
207+
},
208+
],
209+
sharingRules: [
210+
{
211+
name: 'closed_won_to_deal_desk',
212+
type: 'criteria',
213+
object: 'opportunity',
214+
accessLevel: 'read',
215+
sharedWith: { type: 'team', value: 'deal_desk' },
216+
condition: "has(record.owner_id) && record.stage == 'closed_won'",
217+
},
218+
],
219+
};
220+
221+
const findings = runAuthoringRules('validate', { normalized: offending, parsed: offending });
222+
expect(findings.map((f) => f.rule)).toEqual([SHARING_RULE_UNLOWERABLE_CONDITION]);
223+
224+
// And the fixture really did travel through every rule — a registry that
225+
// silently stopped running would satisfy the assertion above vacuously.
226+
expect(AUTHORING_RULES.filter((r) => r.commands.includes('validate')).length).toBeGreaterThan(20);
227+
});
228+
229+
it('a match-all filter is unreachable from a lowering condition (so lint need not re-check it)', () => {
230+
// The seeder's SECOND guard is `isMatchAllCriteria(f)`, which lives in
231+
// plugin-sharing — a runtime `@objectstack/lint` must not import. This
232+
// pins the claim in the module docblock that duplicating it would be dead
233+
// code: every condition the compiler lowers yields a concrete predicate.
234+
for (const source of corpus) {
235+
const result = compileCelToFilter(source, { variables: {} });
236+
if (!result.ok) continue;
237+
expect(Object.keys(result.filter as Record<string, unknown>).length).toBeGreaterThan(0);
238+
}
239+
});
240+
});

0 commit comments

Comments
 (0)