Skip to content

Commit 87f0ccc

Browse files
claude[bot]claude
andauthored
feat(spec): SharingRuleEvaluationResult declares grantsRefused?: number, the optional seventh key the evaluate route already answers (#15714)
`POST /api/v1/sharing/rules/:idOrName/evaluate` passes the service's return value through unfiltered, and plugin-sharing counts refused grants on its own subtype, so the wire carried `grantsRefused` while the declared client type could not name it. The key is OPTIONAL: required would break every other ISharingRuleService implementer; optional composes with the plugin-local covariant narrowing. Absent means "this implementation does not report refusals", never 0 — the JSDoc says so and the contracts pin test reads it. Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf Co-authored-by: os-dev <noreply@anthropic.com>
1 parent 1847594 commit 87f0ccc

3 files changed

Lines changed: 253 additions & 1 deletion

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec): `SharingRuleEvaluationResult` declares `grantsRefused?: number` — the optional seventh key the sharing-rule evaluate route already answers (#14969)
6+
7+
`minor`, derived: a new key on a published contract interface is additive public
8+
API (semver "backwards-compatible functionality"), and not `major` because the
9+
key is **optional** — every existing `ISharingRuleService` implementer, in-tree
10+
and out, keeps compiling unchanged, and every consumer typed against the six
11+
counts keeps reading them.
12+
13+
`POST /api/v1/sharing/rules/:idOrName/evaluate` (ledgered `sdk`,
14+
`shares.rules.evaluate`) passes the service's return value through unfiltered,
15+
and `@objectstack/plugin-sharing` has counted refused grants on its own subtype
16+
since #14754 — so the wire carried `grantsRefused` while the declared client
17+
type (`client.shares.rules.evaluate`, typed `Promise<SharingRuleEvaluationResult>`)
18+
could not name it without a cast. The client gains the key through its spec
19+
import with no edit of its own.
20+
21+
What the key means, and what its absence means: it counts the grants the
22+
engine **refused** during the pass (`ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` on
23+
an organization-less insert into a tenant-scoped `sys_record_share`); the pass
24+
continues past a refusal, so `grantsRefused > 0` is not a failed pass. The key
25+
is **absent — not `0`** — from any implementation that does not count
26+
refusals. A consumer branching on it must read "unset" as "this implementation
27+
does not report refusals", never as "no grant was refused"; only a present `0`
28+
says the latter. Do not `?? 0` it.
29+
30+
Optional in the spec composes with the plugin-local narrowing: an
31+
implementation that counts refusals may require the key on its own subtype
32+
(`SharingRuleReconcilePassResult extends SharingRuleEvaluationResult`), a legal
33+
covariant narrowing that still satisfies `ISharingRuleService`.
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#14969] `SharingRuleEvaluationResult.grantsRefused?: number` — the OPTIONAL
5+
* seventh key, lifted into the contract because the wire already carried it:
6+
* `POST /api/v1/sharing/rules/:idOrName/evaluate` answers the service's return
7+
* value unfiltered (ledgered `sdk` / `shares.rules.evaluate`), so the declared
8+
* client type lagged the route by exactly this key and the count could not be
9+
* read without a cast.
10+
*
11+
* Three things are pinned, because each drifts on its own:
12+
*
13+
* 1. **Optionality, in both directions, at the type level.** The six counts
14+
* stay REQUIRED and `grantsRefused` is the ONE optional key. Making it
15+
* required would break every other `ISharingRuleService` implementer,
16+
* in-tree and out; a second optional key, or a drift of the value type
17+
* away from `number`, turns the exported aliases red under
18+
* `check:test-typecheck`, which compiles this file under
19+
* `tsconfig.test.json`.
20+
* 2. **The covariant narrowing composes.** A subtype that REQUIRES the key
21+
* (`@objectstack/plugin-sharing`'s `SharingRuleReconcilePassResult`) is
22+
* still a legal `evaluateRule` return type, and a six-key implementation
23+
* keeps compiling untouched — the two facts the card's "optional, not
24+
* required" rests on.
25+
* 3. **The JSDoc carries the absent-is-not-zero rule.** "Unset" means "this
26+
* implementation does not report refusals", never "no grant was refused".
27+
* Prose is unassertable except by reading it, so the contract source is
28+
* read and the doc block above the key is required to say so, and to name
29+
* what a refusal IS (`ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` on an
30+
* organization-less insert into a tenant-scoped `sys_record_share`).
31+
*
32+
* ⛔ Not pinned, deliberately: whether any implementation COUNTS refusals.
33+
* That is the services half (`@objectstack/plugin-sharing`'s own
34+
* `reconcile-refused-grant-continues.test.ts`); this file pins the contract.
35+
*/
36+
37+
import { readFileSync } from 'node:fs';
38+
import { fileURLToPath } from 'node:url';
39+
40+
import { describe, it, expect } from 'vitest';
41+
42+
import type { ISharingRuleService, SharingRuleEvaluationResult } from './sharing-service';
43+
44+
/** Type-level identity: true iff A and B are the same type. */
45+
type Eq<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
46+
/** Compile error when the argument is not `true`. */
47+
type Assert<T extends true> = T;
48+
49+
/**
50+
* `-?` strips optionality, then `object extends Pick<T, K>` is true exactly
51+
* when K was optional — so the union is the mandatory keys (the
52+
* `sharing-service.test.ts` #5858 idiom).
53+
*/
54+
type RequiredKeys<T> = { [K in keyof T]-?: object extends Pick<T, K> ? never : K }[keyof T];
55+
/** The complement: the keys a literal of T may omit. */
56+
type OptionalKeys<T> = Exclude<keyof T, RequiredKeys<T>>;
57+
58+
/**
59+
* The six counts every implementation reports, spelled once. `satisfies`
60+
* proves each is a key; the `Eq` below proves the mandatory set is exactly
61+
* these and nothing else.
62+
*/
63+
export const SHARING_RULE_EVALUATION_REQUIRED_KEYS = [
64+
'ruleId',
65+
'matchedRecords',
66+
'expandedUsers',
67+
'grantsCreated',
68+
'grantsUpdated',
69+
'grantsRevoked',
70+
] as const satisfies readonly (keyof SharingRuleEvaluationResult)[];
71+
72+
/**
73+
* Exported deliberately — an unread alias inside a test body is TS6196, and a
74+
* pin no program compiles is no pin at all.
75+
*/
76+
export type SixCountsStayRequired = Assert<
77+
Eq<RequiredKeys<SharingRuleEvaluationResult>, (typeof SHARING_RULE_EVALUATION_REQUIRED_KEYS)[number]>
78+
>;
79+
/** `grantsRefused` is the ONE optional key — a second one fails here by name. */
80+
export type GrantsRefusedIsTheOnlyOptionalKey = Assert<Eq<OptionalKeys<SharingRuleEvaluationResult>, 'grantsRefused'>>;
81+
/** …and it is a number when present, exactly `number | undefined` as read. */
82+
export type GrantsRefusedIsANumberWhenPresent = Assert<Eq<SharingRuleEvaluationResult['grantsRefused'], number | undefined>>;
83+
84+
/**
85+
* The plugin-local narrowing, re-declared here under its own name so the
86+
* composition is pinned against the SHAPE, not against an import of
87+
* `@objectstack/plugin-sharing` (spec must not depend on a plugin).
88+
*/
89+
interface RequiresTheCount extends SharingRuleEvaluationResult {
90+
grantsRefused: number;
91+
}
92+
93+
describe('[#14969] SharingRuleEvaluationResult.grantsRefused is optional, and absent is not zero', () => {
94+
it('reads a non-empty required set (anti-vacuity)', () => {
95+
expect(SHARING_RULE_EVALUATION_REQUIRED_KEYS).toHaveLength(6);
96+
const pinned: [SixCountsStayRequired, GrantsRefusedIsTheOnlyOptionalKey, GrantsRefusedIsANumberWhenPresent] = [true, true, true];
97+
expect(pinned).toEqual([true, true, true]);
98+
});
99+
100+
it('a six-key result and a seven-key result are both members (compile-time)', () => {
101+
// An implementation that does not count refusals: the key is ABSENT.
102+
const silent: SharingRuleEvaluationResult = {
103+
ruleId: 'rule_1',
104+
matchedRecords: 3,
105+
expandedUsers: 2,
106+
grantsCreated: 2,
107+
grantsUpdated: 0,
108+
grantsRevoked: 1,
109+
};
110+
// An implementation that does, and refused nothing this pass: a PRESENT 0.
111+
const counted: SharingRuleEvaluationResult = { ...silent, grantsRefused: 0 };
112+
// …and one that refused two grants and CONTINUED — not a failed pass.
113+
const refused: SharingRuleEvaluationResult = { ...silent, grantsRefused: 2 };
114+
115+
// @ts-expect-error `grantsRefused` is a count — a string is not a member (#14969)
116+
const notACount: SharingRuleEvaluationResult = { ...silent, grantsRefused: 'x' };
117+
118+
// The runtime shape of the distinction the JSDoc draws: `'grantsRefused' in`
119+
// separates "does not report" from "reported 0"; a `?? 0` consumer would
120+
// collapse exactly this and is the reading the contract forbids.
121+
expect('grantsRefused' in silent).toBe(false);
122+
expect(silent.grantsRefused).toBeUndefined();
123+
expect('grantsRefused' in counted).toBe(true);
124+
expect(counted.grantsRefused).toBe(0);
125+
expect(refused.grantsRefused).toBe(2);
126+
expect(notACount.ruleId).toBe('rule_1');
127+
});
128+
129+
it('the required-narrowing subtype composes with ISharingRuleService (compile-time)', async () => {
130+
// A subtype that REQUIRES the key is still a member of the contract type…
131+
const narrowed: RequiresTheCount = {
132+
ruleId: 'rule_1',
133+
matchedRecords: 1,
134+
expandedUsers: 1,
135+
grantsCreated: 0,
136+
grantsUpdated: 0,
137+
grantsRevoked: 0,
138+
grantsRefused: 1,
139+
};
140+
const widened: SharingRuleEvaluationResult = narrowed;
141+
142+
// …and a service whose `evaluateRule` returns the narrowed type is still an
143+
// `ISharingRuleService['evaluateRule']` — the covariant return the card
144+
// names as the reason the key must be optional in the spec.
145+
const evaluateNarrowed = async (): Promise<RequiresTheCount> => narrowed;
146+
const evaluateRule: ISharingRuleService['evaluateRule'] = evaluateNarrowed;
147+
148+
// The mirror: a six-key implementation keeps compiling untouched, which is
149+
// exactly what a REQUIRED key would break (in-tree and out).
150+
const evaluateSilent: ISharingRuleService['evaluateRule'] = async (idOrName) => ({
151+
ruleId: idOrName,
152+
matchedRecords: 0,
153+
expandedUsers: 0,
154+
grantsCreated: 0,
155+
grantsUpdated: 0,
156+
grantsRevoked: 0,
157+
});
158+
159+
// @ts-expect-error the narrowed subtype cannot OMIT the key it requires (#14969)
160+
const narrowedWithoutCount: RequiresTheCount = { ...widened, grantsRefused: undefined };
161+
162+
expect(widened.grantsRefused).toBe(1);
163+
expect((await evaluateRule('rule_1', { userId: 'usr_1' })).grantsRefused).toBe(1);
164+
expect((await evaluateSilent('rule_1', { userId: 'usr_1' })).grantsRefused).toBeUndefined();
165+
expect(narrowedWithoutCount.ruleId).toBe('rule_1');
166+
});
167+
168+
it('the contract JSDoc states absent-is-not-zero beside the key', () => {
169+
const source = readFileSync(fileURLToPath(new URL('./sharing-service.ts', import.meta.url)), 'utf8');
170+
const declaration = 'grantsRefused?: number;';
171+
const at = source.indexOf(declaration);
172+
expect(at).toBeGreaterThan(-1);
173+
// Exactly one declaration — a second spelling of the key is drift.
174+
expect(source.indexOf(declaration, at + 1)).toBe(-1);
175+
// The doc block immediately above the declaration — from its last `/**`,
176+
// unwrapped: each continuation line's ` * ` prefix becomes one space, so a
177+
// sentence the author re-wraps is still read as one sentence.
178+
const docStart = source.lastIndexOf('/**', at);
179+
const doc = source.slice(docStart, at).replace(/\s*\n\s*\*\s?/g, ' ');
180+
// What a refusal IS, in the card's own terms.
181+
expect(doc).toContain('`ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` on an organization-less insert');
182+
expect(doc).toContain('tenant-scoped `sys_record_share`');
183+
// The rule the optionality carries: absent, not 0; unset = not reported.
184+
expect(doc).toContain('ABSENT — not `0` — from any implementation that does not count refusals');
185+
expect(doc).toContain('read "unset" as "this implementation does not report refusals"');
186+
expect(doc).toContain('never as "no grant was refused"');
187+
// A refused grant is not a failed pass.
188+
expect(doc).toContain('NOT "the pass failed"');
189+
});
190+
});

packages/spec/src/contracts/sharing-service.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -511,14 +511,43 @@ export interface DefineSharingRuleInput {
511511
managedBy?: 'platform' | 'package' | 'admin';
512512
}
513513

514-
/** Result of a rule evaluation pass. */
514+
/**
515+
* Result of a rule evaluation pass.
516+
*
517+
* The six counts are what every implementation reports. `grantsRefused` is
518+
* the one OPTIONAL key, and its absence is a statement about the
519+
* implementation, not about the pass — read its doc before branching on it.
520+
*/
515521
export interface SharingRuleEvaluationResult {
516522
ruleId: string;
517523
matchedRecords: number;
518524
expandedUsers: number;
519525
grantsCreated: number;
520526
grantsUpdated: number;
521527
grantsRevoked: number;
528+
/**
529+
* [#14969] Grants the engine REFUSED during the pass — each one an
530+
* `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` on an organization-less insert
531+
* into a tenant-scoped `sys_record_share`. The pass continues past a refused
532+
* grant (the remaining grants and the stale-row revocations still run), so
533+
* `grantsRefused > 0` is NOT "the pass failed": it is the pass reporting
534+
* what it could not grant on.
535+
*
536+
* Optional by design, and the optionality carries meaning. A required key
537+
* would break every other `ISharingRuleService` implementer, in-tree and
538+
* out, while an implementation that refuses grants may still narrow it to
539+
* required on its own subtype (a legal covariant narrowing —
540+
* `@objectstack/plugin-sharing` does exactly that). The key is ABSENT — not
541+
* `0` — from any implementation that does not count refusals. A consumer
542+
* branching on it must read "unset" as "this implementation does not report
543+
* refusals", never as "no grant was refused"; only a present `0` says the
544+
* latter.
545+
*
546+
* The wire already carried it: `POST /api/v1/sharing/rules/:idOrName/evaluate`
547+
* answers the service's return value unfiltered, so this key lifts the
548+
* declared client type (`shares.rules.evaluate`) up to what the route sends.
549+
*/
550+
grantsRefused?: number;
522551
}
523552

524553
/**

0 commit comments

Comments
 (0)