Skip to content

Commit 8d06347

Browse files
claude[bot]claude
andauthored
feat(plugin-sharing): carry compileCelToFilter's reason and detail into the seeder's skip WARN (#14136)
The sharing-rule seeder collapsed the compiler's discriminated refusal { ok: false, reason, detail } to null one line before the WARN that needed it. celToFilterOutcome keeps the cause (the rls-compiler compileExpressionOutcome shape, one seam over); celToFilter stays at its published signature and delegates. The skip decision is unchanged (ADR-0049: never seeded as match-all). Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6426b86 commit 8d06347

3 files changed

Lines changed: 178 additions & 6 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@objectstack/plugin-sharing': patch
3+
---
4+
5+
The sharing-rule seeder's skip WARN now names WHY a declared rule's CEL `condition` did not translate: `compileCelToFilter`'s `reason` (the aggregatable category) and `detail` (the concrete refused shape, variable path, or parse bound) are carried into the log meta instead of being collapsed to `null` one line before the log that needed them. `celToFilter` keeps its published `Record | null` signature and delegates to the new `celToFilterOutcome` sibling (the `plugin-security` rls-compiler shape from #13942, one seam over). Skip semantics are unchanged — an unlowerable or match-all condition is still never seeded as a permissive match-all rule (ADR-0049).

packages/plugins/plugin-sharing/src/bootstrap-declared-sharing-rules.ts

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
import type { SharingRuleService } from './sharing-rule-service.js';
5252
import type { SharingRuleRecipientType, ShareAccessLevel } from '@objectstack/spec/contracts';
5353
import { compileCelToFilter } from '@objectstack/formula';
54+
import type { CelFilterFailReason } from '@objectstack/formula';
5455
import { isMatchAllCriteria } from './rule-criteria.js';
5556

5657
const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const;
@@ -129,8 +130,50 @@ function mapRecipientType(t: unknown): SharingRuleRecipientType | null {
129130
* never seeding a permissive match-all (ADR-0049).
130131
*/
131132
export function celToFilter(cel: unknown): Record<string, unknown> | null {
133+
return celToFilterOutcome(cel).filter;
134+
}
135+
136+
/**
137+
* Why a declared rule's `condition` produced no criteria — the compiler's OWN
138+
* answer, carried instead of discarded. [#13943]
139+
*
140+
* `compileCelToFilter` already returns `{ reason, detail }` on every refusal;
141+
* `celToFilter` used to consume `!ok` and collapse the rest to `null` one line
142+
* before the only WARN that could surface it — so an operator whose declared
143+
* rule was silently not granting got the fact ("skipped") and the source text
144+
* back, but not WHICH shape the compiler refused or why. The extra member is
145+
* this FILE's own drop (the ADR-0049 match-all guard at the call site), which
146+
* the compiler reports as a success — same skip, same silence, so it joins the
147+
* same vocabulary rather than staying unnamed (the `empty-membership`
148+
* precedent in `plugin-security/src/rls-compiler.ts`, #13639).
149+
*/
150+
type SharingSkipReason = CelFilterFailReason | 'match-all-criteria';
151+
152+
/** A skipped rule's cause: the compiler's `reason` (the aggregatable category) plus its human `detail` (the concrete fault). */
153+
interface SharingSkipCause {
154+
reason: SharingSkipReason;
155+
detail: string;
156+
}
157+
158+
/** {@link celToFilterOutcome}'s answer: the filter, or why there is none. */
159+
type CelToFilterOutcome =
160+
| { filter: Record<string, unknown>; cause?: undefined }
161+
| { filter: null; cause: SharingSkipCause };
162+
163+
/**
164+
* [#13943] {@link celToFilter}'s answer WITH the reason it refused.
165+
*
166+
* Same compile, same decision, same returned filter — the only difference is
167+
* that the compiler's `{ reason, detail }` survives to the caller instead of
168+
* being collapsed into `null` at the `!result.ok` line. `celToFilter` stays
169+
* exactly as published (`Record | null`) and delegates here — the
170+
* `compileExpressionOutcome` shape from `plugin-security/src/rls-compiler.ts`
171+
* (#13942), one seam over.
172+
*/
173+
export function celToFilterOutcome(cel: unknown): CelToFilterOutcome {
132174
const result = compileCelToFilter(cel as string | { source?: string }, { variables: {} });
133-
return result.ok ? (result.filter as Record<string, unknown>) : null;
175+
if (!result.ok) return { filter: null, cause: { reason: result.reason, detail: result.detail } };
176+
return { filter: result.filter as Record<string, unknown> };
134177
}
135178

136179
/**
@@ -197,12 +240,29 @@ export async function bootstrapDeclaredSharingRules(
197240
// schema requires `condition`, so reaching here means a hand-crafted
198241
// `{ dialect, source: '' }` envelope or a stale pre-built package, and
199242
// neither earns a match-all.
200-
const f = celToFilter(r.condition);
201-
if (!f || isMatchAllCriteria(f)) {
202-
logger?.warn?.('[sharing-rule] skipped (missing or untranslatable CEL condition — never seeded as match-all) [experimental]', { rule: r.name, condition: r.condition });
243+
const outcome = celToFilterOutcome(r.condition);
244+
if (outcome.filter === null || isMatchAllCriteria(outcome.filter)) {
245+
// [#13943] The skip keeps its REASON. `reason` + `detail` are what the
246+
// compiler already computed — the shape it refused, the variable path,
247+
// the parse bound that was overrun — and discarding them here is what
248+
// left an operator with a skipped rule, its source text, and no why.
249+
// The skip decision itself is byte-identical to before (ADR-0049: an
250+
// unlowerable condition is never seeded as a permissive match-all).
251+
const cause: SharingSkipCause = outcome.filter === null
252+
? outcome.cause
253+
: {
254+
// The compiler answered `ok`, so there is no compiler detail to
255+
// carry — this drop is THIS file's match-all guard, and it names
256+
// itself rather than being reported as untranslatable.
257+
reason: 'match-all-criteria',
258+
detail:
259+
`the condition lowered to ${JSON.stringify(outcome.filter)}, which constrains nothing — ` +
260+
'seeding it would share every record of the object (ADR-0049)',
261+
};
262+
logger?.warn?.('[sharing-rule] skipped (missing or untranslatable CEL condition — never seeded as match-all) [experimental]', { rule: r.name, condition: r.condition, reason: cause.reason, detail: cause.detail });
203263
skipped += 1; continue;
204264
}
205-
const criteria: Record<string, unknown> = f;
265+
const criteria: Record<string, unknown> = outcome.filter;
206266
try {
207267
await ruleService.defineRule({
208268
name: r.name,

packages/plugins/plugin-sharing/src/sharing-rule.test.ts

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { BusinessUnitGraphService } from './business-unit-graph.js';
1313
// ruling can be pinned in the same suite as the positive half: the filter
1414
// belongs to the sharing CALL SITE, never to the expansion helper.
1515
import { PositionGraphService } from './position-graph.js';
16-
import { celToFilter } from './bootstrap-declared-sharing-rules.js';
16+
import { celToFilter, celToFilterOutcome, bootstrapDeclaredSharingRules } from './bootstrap-declared-sharing-rules.js';
1717
import { isMatchAllCriteria } from './rule-criteria.js';
1818
import { bindRuleCriteriaGuard } from './rule-hooks.js';
1919

@@ -617,6 +617,113 @@ describe('#1887 — compound sharing condition compiled + enforced (ADR-0058 D3)
617617
});
618618
});
619619

620+
// ---------------------------------------------------------------------------
621+
// #13943 — the seeder's skip WARN carries the compiler's reason AND detail
622+
//
623+
// `compileCelToFilter` returns `{ ok: false, reason, detail }` on every
624+
// refusal; `celToFilter` used to collapse the whole thing to `null` one line
625+
// before the only WARN that could surface it, so the operator learned THAT
626+
// the condition did not translate but never WHY. `celToFilterOutcome` is the
627+
// sibling that keeps the cause (`compileExpressionOutcome` in
628+
// plugin-security's rls-compiler, #13942, is the same shape one seam over);
629+
// `celToFilter` stays exactly as published (`Record | null`) and delegates.
630+
// The skip DECISION is unchanged either way — an unlowerable condition is
631+
// never seeded as a permissive match-all (ADR-0049).
632+
// ---------------------------------------------------------------------------
633+
describe('#13943 — sharing-rule seeder skip WARN names the compiler’s reason and detail', () => {
634+
const SKIP_WARN =
635+
'[sharing-rule] skipped (missing or untranslatable CEL condition — never seeded as match-all) [experimental]';
636+
637+
/** Registry-backed seeder harness: declared rules in, defineRule + log lines out. */
638+
function seedHarness(declared: any[]) {
639+
const engine = { _registry: { listItems: (type: string) => (type === 'sharing_rule' ? declared : []) } };
640+
const defineRule = vi.fn(async (input: any) => ({ id: `id_${input.name}` }));
641+
const warns: Array<{ msg: string; meta: any }> = [];
642+
const logger = {
643+
warn: (msg: string, meta?: any) => { warns.push({ msg, meta }); },
644+
info: () => {},
645+
};
646+
return { engine, ruleService: { defineRule } as any, logger, warns, defineRule };
647+
}
648+
649+
const RULE_BASE = {
650+
object: 'opportunity',
651+
sharedWith: { type: 'user', value: 'alice' },
652+
accessLevel: 'read',
653+
};
654+
655+
it('celToFilterOutcome carries the compiler’s { reason, detail } instead of collapsing to null', () => {
656+
// Refusal: a function call is not pushdown-able — the cause survives.
657+
const refused = celToFilterOutcome('size(record.tags) > 0');
658+
expect(refused.filter).toBeNull();
659+
expect(refused.cause?.reason).toBe('unsupported');
660+
expect(refused.cause?.detail).toEqual(expect.any(String));
661+
expect(refused.cause?.detail.length).toBeGreaterThan(0);
662+
// Missing / empty condition: the compiler's own empty-expression refusal.
663+
const missing = celToFilterOutcome(undefined);
664+
expect(missing).toEqual({ filter: null, cause: { reason: 'parse-error', detail: 'empty expression' } });
665+
// Success: same filter celToFilter returns, and no cause at all.
666+
const ok = celToFilterOutcome('record.amount >= 100000');
667+
expect(ok.filter).toEqual({ amount: { $gte: 100000 } });
668+
expect(ok.cause).toBeUndefined();
669+
// The published wrapper delegates: byte-identical answers on both paths.
670+
expect(celToFilter('size(record.tags) > 0')).toBeNull();
671+
expect(celToFilter('record.amount >= 100000')).toEqual(ok.filter);
672+
});
673+
674+
it('an untranslatable condition is skipped WITH the why: reason + detail land in the WARN meta', async () => {
675+
const { engine, ruleService, logger, warns, defineRule } = seedHarness([
676+
{ ...RULE_BASE, name: 'r_unsupported', condition: 'size(record.tags) > 0' },
677+
]);
678+
const res = await bootstrapDeclaredSharingRules(ruleService, null, engine, logger);
679+
expect(res).toEqual({ seeded: 0, skipped: 1 });
680+
expect(defineRule).not.toHaveBeenCalled();
681+
const skips = warns.filter((w) => w.msg === SKIP_WARN);
682+
expect(skips).toHaveLength(1);
683+
// The fact (rule, condition) is still there; the why (reason, detail) now is too.
684+
expect(skips[0].meta).toMatchObject({
685+
rule: 'r_unsupported',
686+
condition: 'size(record.tags) > 0',
687+
reason: 'unsupported',
688+
});
689+
expect(skips[0].meta.detail).toEqual(expect.any(String));
690+
expect(skips[0].meta.detail.length).toBeGreaterThan(0);
691+
});
692+
693+
it('NEGATIVE: a rule whose condition lowers cleanly emits no skip line and seeds as before', async () => {
694+
const { engine, ruleService, logger, warns, defineRule } = seedHarness([
695+
{ ...RULE_BASE, name: 'r_clean', condition: 'record.amount >= 100000' },
696+
]);
697+
const res = await bootstrapDeclaredSharingRules(ruleService, null, engine, logger);
698+
expect(res).toEqual({ seeded: 1, skipped: 0 });
699+
// No new log line of any kind on the clean path — not just "no skip WARN".
700+
expect(warns).toHaveLength(0);
701+
expect(defineRule).toHaveBeenCalledTimes(1);
702+
expect(defineRule.mock.calls[0][0]).toMatchObject({
703+
name: 'r_clean',
704+
criteria: { amount: { $gte: 100000 } },
705+
});
706+
});
707+
708+
it('NEGATIVE: a MISSING condition takes the path it takes today — skipped via the same WARN, never seeded', async () => {
709+
const { engine, ruleService, logger, warns, defineRule } = seedHarness([
710+
{ ...RULE_BASE, name: 'r_missing' /* no condition at all */ },
711+
]);
712+
const res = await bootstrapDeclaredSharingRules(ruleService, null, engine, logger);
713+
expect(res).toEqual({ seeded: 0, skipped: 1 });
714+
expect(defineRule).not.toHaveBeenCalled();
715+
const skips = warns.filter((w) => w.msg === SKIP_WARN);
716+
expect(skips).toHaveLength(1);
717+
// Same branch, same message string as before; the cause names the
718+
// compiler's own empty-expression refusal rather than being absent.
719+
expect(skips[0].meta).toMatchObject({
720+
rule: 'r_missing',
721+
reason: 'parse-error',
722+
detail: 'empty expression',
723+
});
724+
});
725+
});
726+
620727
// ---------------------------------------------------------------------------
621728
// #3896 — a rule with no criteria must share NOTHING, never everything
622729
//

0 commit comments

Comments
 (0)