Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/29297-explain-access-request-record-ids.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@objectstack/spec': patch
---

feat(spec): `ExplainAccessRequest` declares `recordIds`, the batch selector the explain wire schema already carries

Clause-②: yes (widening)
Metadata-protocol: none

The `security` service contract's `explain(request)` selector, `ExplainAccessRequest`, now declares the optional `recordIds` member. The wire schema `ExplainRequestSchema` already declared it, and both the REST explain route and the reference security service already honour it. A TypeScript caller handing `explain` a batch no longer needs a cast to get past the contract type.

- **The type is the wire schema's own.** `recordIds` is typed as `ExplainRequest['recordIds']` (a list of ids), so the contract and the schema cannot drift; a type-level test holds the two equal.
- **What it means.** The same record-grained question as `recordId`, for several records of one `(object, operation)` pair: mutually exclusive with `recordId`, at least one and at most `EXPLAIN_BATCH_MAX_RECORD_IDS` ids, answered by `ExplainDecision.records` in the same order.
- **Nothing is required of an implementation.** A security service may ignore `recordIds` and answer only the object-level decision, with no `records`; the REST explain route then asks each id singularly, as it already does.
- **No runtime change.** Only the declared type moves. Every request that compiled before still compiles.
55 changes: 54 additions & 1 deletion packages/spec/src/contracts/security-service.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { describe, it, expect, expectTypeOf } from 'vitest';
import type { z } from 'zod';
import type {
ISecurityService,
AuthoredRowWriteVerdict,
Expand All @@ -9,7 +10,36 @@ import type {
ControlledByParentWriteOutcome,
ControlledByParentWriteDenialLeg,
ControlledByParentWriteUnresolvedReason,
ExplainAccessRequest,
} from './security-service';
import type { ExplainRequestSchema } from '../security/explain.zod';

// ---------------------------------------------------------------------------
// `ExplainAccessRequest.recordIds` — the service contract's batch selector.
//
// The wire schema (`ExplainRequestSchema`) declared `recordIds` and both the
// REST explain route and the reference security service honour it, while the
// service contract the route calls did not declare it. These pins hold the two
// declarations to one type: the member is OPTIONAL (every singular and
// object-level caller omits it) and EQUAL to the wire schema's input member, so
// a retype on either side turns this file red under `check:test-typecheck`.
//
// The optionality pin is a type alias, not `expectTypeOf`: `toEqualTypeOf`
// cannot tell `recordIds?: string[]` from a REQUIRED `recordIds: string[] |
// undefined`, while `{} extends Pick<…>` can. Exported so TS6196 does not
// flag an alias nothing reads.
// ---------------------------------------------------------------------------

type Eq<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
type Assert<T extends true> = T;

/** Optional: a request may omit it. A required member turns this alias red. */
export type ExplainRecordIdsIsOptional = Assert<{} extends Pick<ExplainAccessRequest, 'recordIds'> ? true : false>;

/** The wire schema's own input type for the member — neither side may drift. */
export type ExplainRecordIdsMatchesWire = Assert<
Eq<ExplainAccessRequest['recordIds'], z.input<typeof ExplainRequestSchema>['recordIds']>
>;

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

it('explain accepts a batch request: recordIds is the wire schema\'s member, typed as a list of ids', async () => {
expectTypeOf<ExplainAccessRequest['recordIds']>()
.toEqualTypeOf<z.input<typeof ExplainRequestSchema>['recordIds']>();
expectTypeOf<ExplainAccessRequest['recordIds']>().toEqualTypeOf<string[] | undefined>();

const seen: ExplainAccessRequest[] = [];
const service = makeService({
explain: async (request) => { seen.push(request); return {} as any; },
});
// The literal compiling is the point: before the contract declared the
// member, this call was an excess-property error.
await service.explain(
{ object: 'deal', operation: 'read', recordIds: ['r1', 'r2', 'r1'] },
{ userId: 'admin' },
);
expect(seen[0]?.recordIds).toEqual(['r1', 'r2', 'r1']);

// A bare id is the singular selector's job, not a one-element batch.
// @ts-expect-error — `recordIds` is a list, never a lone string.
const bare: ExplainAccessRequest = { object: 'deal', operation: 'read', recordIds: 'r1' };
expect(bare.object).toBe('deal');
});

it('contributeOwnershipFloorAlternates is OPTIONAL — absence is typed, and an alternate names exactly one floor limb', () => {
// A security service without the seam still satisfies the contract.
// Absence leaves the floor in force, which is the fail-closed direction:
Expand Down
21 changes: 20 additions & 1 deletion packages/spec/src/contracts/security-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
import type { GetRecordFieldAccessResponse } from '../api/protocol.zod.js';
import type { FilterCondition } from '../data/filter.zod.js';
import type { ExecutionContext } from '../kernel/execution-context.zod.js';
import type { ExplainDecision, ExplainOperation } from '../security/explain.zod.js';
import type { EXPLAIN_BATCH_MAX_RECORD_IDS, ExplainDecision, ExplainOperation, ExplainRequest } from '../security/explain.zod.js';
import type { EffectiveObjectPermission, ObjectAccessScope, PermissionSet } from '../security/permission.zod.js';

/**
Expand Down Expand Up @@ -166,6 +166,25 @@ export interface ExplainAccessRequest {
userId?: string;
/** Narrow the explanation to a single record (adds record-level layers). */
recordId?: string;
/**
* Batch form of `recordId`: the same record-grained question for several
* records of one `(object, operation)` pair, in one call. The type is the
* wire schema's own member ({@link ExplainRequest}), so the two cannot drift.
*
* - Mutually exclusive with `recordId`. The wire schema refuses a request
* carrying both at the REST explain route, and the reference security
* service refuses it too; an implementation that ignores `recordIds` does
* not. A direct caller of `explain` sends one spelling, never both.
* - At least one and at most {@link EXPLAIN_BATCH_MAX_RECORD_IDS} ids; the
* wire schema refuses an empty or a longer array.
* - Answered by `ExplainDecision.records` in the same order: `records[i]`
* answers `recordIds[i]`, same length, duplicates answered per position.
* - An implementation may ignore it and answer only the object-level
* decision, with no `records`. A caller that needs the per-record verdicts
* then asks each id singularly through `recordId`, as the REST explain
* route does.
*/
recordIds?: ExplainRequest['recordIds'];
}

/** Filter accepted by the audience-binding suggestion list. */
Expand Down
Loading