Skip to content

Commit feabbdd

Browse files
committed
feat(spec): ExplainAccessRequest declares recordIds, the wire schema's batch selector
The service contract the REST explain route calls now declares the optional batch selector the wire schema (ExplainRequestSchema) and the reference security service already honour. The member's type is the wire schema's own, and a type-level pin holds the two equal. Claude-Session: https://claude.ai/code/session_01ADHkmQS1wrCba8FkVowBLy Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3291213 commit feabbdd

2 files changed

Lines changed: 71 additions & 2 deletions

File tree

‎packages/spec/src/contracts/security-service.test.ts‎

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
import { describe, it, expect } from 'vitest';
3+
import { describe, it, expect, expectTypeOf } from 'vitest';
4+
import type { z } from 'zod';
45
import type {
56
ISecurityService,
67
AuthoredRowWriteVerdict,
@@ -9,7 +10,36 @@ import type {
910
ControlledByParentWriteOutcome,
1011
ControlledByParentWriteDenialLeg,
1112
ControlledByParentWriteUnresolvedReason,
13+
ExplainAccessRequest,
1214
} from './security-service';
15+
import type { ExplainRequestSchema } from '../security/explain.zod';
16+
17+
// ---------------------------------------------------------------------------
18+
// `ExplainAccessRequest.recordIds` — the service contract's batch selector.
19+
//
20+
// The wire schema (`ExplainRequestSchema`) declared `recordIds` and both the
21+
// REST explain route and the reference security service honour it, while the
22+
// service contract the route calls did not declare it. These pins hold the two
23+
// declarations to one type: the member is OPTIONAL (every singular and
24+
// object-level caller omits it) and EQUAL to the wire schema's input member, so
25+
// a retype on either side turns this file red under `check:test-typecheck`.
26+
//
27+
// The optionality pin is a type alias, not `expectTypeOf`: `toEqualTypeOf`
28+
// cannot tell `recordIds?: string[]` from a REQUIRED `recordIds: string[] |
29+
// undefined`, while `{} extends Pick<…>` can. Exported so TS6196 does not
30+
// flag an alias nothing reads.
31+
// ---------------------------------------------------------------------------
32+
33+
type Eq<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
34+
type Assert<T extends true> = T;
35+
36+
/** Optional: a request may omit it. A required member turns this alias red. */
37+
export type ExplainRecordIdsIsOptional = Assert<{} extends Pick<ExplainAccessRequest, 'recordIds'> ? true : false>;
38+
39+
/** The wire schema's own input type for the member — neither side may drift. */
40+
export type ExplainRecordIdsMatchesWire = Assert<
41+
Eq<ExplainAccessRequest['recordIds'], z.input<typeof ExplainRequestSchema>['recordIds']>
42+
>;
1343

1444
/**
1545
* These tests pin the two things a consumer of the `security` service reasons
@@ -563,6 +593,29 @@ describe('Security Service Contract', () => {
563593
expect(seen[0]).toEqual({ object: 'deal', operation: 'update', userId: 'u2', recordId: 'r1' });
564594
});
565595

596+
it('explain accepts a batch request: recordIds is the wire schema\'s member, typed as a list of ids', async () => {
597+
expectTypeOf<ExplainAccessRequest['recordIds']>()
598+
.toEqualTypeOf<z.input<typeof ExplainRequestSchema>['recordIds']>();
599+
expectTypeOf<ExplainAccessRequest['recordIds']>().toEqualTypeOf<string[] | undefined>();
600+
601+
const seen: ExplainAccessRequest[] = [];
602+
const service = makeService({
603+
explain: async (request) => { seen.push(request); return {} as any; },
604+
});
605+
// The literal compiling is the point: before the contract declared the
606+
// member, this call was an excess-property error.
607+
await service.explain(
608+
{ object: 'deal', operation: 'read', recordIds: ['r1', 'r2', 'r1'] },
609+
{ userId: 'admin' },
610+
);
611+
expect(seen[0]?.recordIds).toEqual(['r1', 'r2', 'r1']);
612+
613+
// A bare id is the singular selector's job, not a one-element batch.
614+
// @ts-expect-error — `recordIds` is a list, never a lone string.
615+
const bare: ExplainAccessRequest = { object: 'deal', operation: 'read', recordIds: 'r1' };
616+
expect(bare.object).toBe('deal');
617+
});
618+
566619
it('contributeOwnershipFloorAlternates is OPTIONAL — absence is typed, and an alternate names exactly one floor limb', () => {
567620
// A security service without the seam still satisfies the contract.
568621
// Absence leaves the floor in force, which is the fail-closed direction:

‎packages/spec/src/contracts/security-service.ts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@
6464
import type { GetRecordFieldAccessResponse } from '../api/protocol.zod.js';
6565
import type { FilterCondition } from '../data/filter.zod.js';
6666
import type { ExecutionContext } from '../kernel/execution-context.zod.js';
67-
import type { ExplainDecision, ExplainOperation } from '../security/explain.zod.js';
67+
import type { EXPLAIN_BATCH_MAX_RECORD_IDS, ExplainDecision, ExplainOperation, ExplainRequest } from '../security/explain.zod.js';
6868
import type { EffectiveObjectPermission, ObjectAccessScope, PermissionSet } from '../security/permission.zod.js';
6969

7070
/**
@@ -166,6 +166,22 @@ export interface ExplainAccessRequest {
166166
userId?: string;
167167
/** Narrow the explanation to a single record (adds record-level layers). */
168168
recordId?: string;
169+
/**
170+
* Batch form of `recordId`: the same record-grained question for several
171+
* records of one `(object, operation)` pair, in one call. The type is the
172+
* wire schema's own member ({@link ExplainRequest}), so the two cannot drift.
173+
*
174+
* - Mutually exclusive with `recordId`: a request carrying both is refused.
175+
* - At least one and at most {@link EXPLAIN_BATCH_MAX_RECORD_IDS} ids; the
176+
* wire schema refuses an empty or a longer array.
177+
* - Answered by `ExplainDecision.records` in the same order: `records[i]`
178+
* answers `recordIds[i]`, same length, duplicates answered per position.
179+
* - An implementation may ignore it and answer only the object-level
180+
* decision, with no `records`. A caller that needs the per-record verdicts
181+
* then asks each id singularly through `recordId`, as the REST explain
182+
* route does.
183+
*/
184+
recordIds?: ExplainRequest['recordIds'];
169185
}
170186

171187
/** Filter accepted by the audience-binding suggestion list. */

0 commit comments

Comments
 (0)