Skip to content

Commit 308e63e

Browse files
committed
feat(spec): sharing/approval/report enforcement takes the full ExecutionContext (#6523)
Apply the #6206 ruling default (converge on the full envelope, keep no per-site subset contracts) to its fourth and widest twin. 36 signatures across ISharingService / ISharingRuleService / IApprovalService / IReportService now declare their context parameter as ExecutionContext instead of the six-field SharingExecutionContext, which omitted accessible_org_ids (ADR-0105 D2 Layer 0 wall), org_user_ids, posture (ADR-0095 D2) and tabPermissions. The mirror direction of #6430: nothing trimmed the VALUE here — the engine middleware passes its whole context down — the declared TYPE was narrow, so implementations cast out of their own contract to read it (plugin-approvals isOverrideActor: `(context as any).posture`). SharingExecutionContext is retained, unchanged in shape, documented as migration residue. Contract-only: no runtime change, no acceptance-surface change (TS interfaces, not Zod). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PiRUoQkTSBBmpyXBY3cVn2
1 parent 08863dd commit 308e63e

5 files changed

Lines changed: 347 additions & 39 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): sharing / approval / report enforcement takes the full `ExecutionContext`; the six-field context is migration residue (#6523, #6206 ruling default)
6+
7+
`ISharingService`, `ISharingRuleService`, `IApprovalService` and
8+
`IReportService` now declare their context parameter as the complete
9+
`ExecutionContext` envelope instead of the six-field
10+
`SharingExecutionContext` — 36 signatures across the three contract files.
11+
Every one of those methods ADJUDICATES access (the read-filter contribution,
12+
both write gates and their tri-state forms, share management, rule definition
13+
and evaluation, approval decisions and recalls, report runs and schedules), so
14+
each needs the whole `resolveAuthzContext` result: `accessible_org_ids` (the
15+
`group`-posture Layer 0 wall, ADR-0105 D2), `org_user_ids`, `systemPermissions`,
16+
`posture` (ADR-0095 D2 — resolved once, carried, never re-derived at the
17+
enforcement site) and `tabPermissions` included.
18+
19+
`SharingExecutionContext` was the fourth and widest twin of the family #6206
20+
ruled on (converge to the full envelope, keep no per-site subset contracts);
21+
that ruling's sweep had reached only the share-link site (#6430).
22+
23+
The damage ran in the MIRROR direction of the share-link case, which is worth
24+
stating because it is the direction a reviewer does not expect. Nothing here
25+
trimmed a value: `plugin-sharing`'s engine middleware passes its whole
26+
execution context down (`buildReadFilter(ctx.object, exec ?? {})`), so the
27+
values always arrived complete. It was the declared TYPE that was narrow, so
28+
the receiving implementation could not read what it had been handed without
29+
casting out of its own contract — measurably, `plugin-approvals`'
30+
privileged-override gate reaching for the resolved posture as
31+
`(context as any).posture`.
32+
33+
`SharingExecutionContext` is retained and unchanged in shape, now documented as
34+
migration residue: nothing in `packages/spec` takes it any more, and the three
35+
plugin implementations that still annotate their own parameters with it are the
36+
consumer half, separated exactly as #6430's contract and plugin halves were.
37+
Widening it field by field is explicitly refused — that would rebuild the
38+
per-site subset the ruling removed.
39+
40+
Contract-only, no runtime behaviour change and no acceptance-surface change
41+
(these are TypeScript interfaces, not Zod schemas — nothing authorable moves).
42+
Existing implementations keep compiling: the two types are mutually assignable
43+
(all fields optional, all six present in the wider type), so neither direction
44+
of the parameter change breaks them.

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

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,14 @@
1616
* authoring type, submit, or step machinery anymore.
1717
*/
1818

19-
import type { SharingExecutionContext } from './sharing-service.js';
19+
// [#6523 / #6206 ruling default] Every method below ADJUDICATES access, so each
20+
// takes the complete `resolveAuthzContext` envelope rather than the six-field
21+
// `SharingExecutionContext` this contract used to borrow from `sharing-service`.
22+
// That narrow type omitted `accessible_org_ids` (the `group`-posture Layer 0
23+
// wall, ADR-0105 D2), `org_user_ids`, `posture` (ADR-0095 D2) and
24+
// `tabPermissions` — see `SharingExecutionContext` in `./sharing-service.js`
25+
// for the boundary and the measured consequence.
26+
import type { ExecutionContext } from '../kernel/execution-context.zod.js';
2027

2128
/**
2229
* Lifecycle states of an approval request, in the order the
@@ -515,7 +522,7 @@ export interface IApprovalService {
515522
limit?: number;
516523
offset?: number;
517524
} | undefined,
518-
context: SharingExecutionContext,
525+
context: ExecutionContext,
519526
): Promise<ApprovalRequestRow[]>;
520527

521528
/**
@@ -524,17 +531,17 @@ export interface IApprovalService {
524531
*/
525532
countRequests(
526533
filter: Parameters<IApprovalService['listRequests']>[0],
527-
context: SharingExecutionContext,
534+
context: ExecutionContext,
528535
): Promise<number>;
529536

530-
getRequest(requestId: string, context: SharingExecutionContext): Promise<ApprovalRequestRow | null>;
537+
getRequest(requestId: string, context: ExecutionContext): Promise<ApprovalRequestRow | null>;
531538

532539
/**
533540
* Record a decision on a node-driven request. Honours the node's
534541
* `unanimous` behaviour, finalises the request when satisfied, and resumes
535542
* the owning flow run down the matching `approve` / `reject` edge.
536543
*/
537-
decide(requestId: string, input: ApprovalDecisionInput, context: SharingExecutionContext): Promise<ApprovalDecisionResult>;
544+
decide(requestId: string, input: ApprovalDecisionInput, context: ExecutionContext): Promise<ApprovalDecisionResult>;
538545

539546
/**
540547
* Withdraw a pending request. Only the submitter (or a system context) may
@@ -546,7 +553,7 @@ export interface IApprovalService {
546553
* request flips `returned → recalled` and the run resumes down `reject` the
547554
* same way.
548555
*/
549-
recall(requestId: string, input: ApprovalRecallInput, context: SharingExecutionContext): Promise<ApprovalRecallResult>;
556+
recall(requestId: string, input: ApprovalRecallInput, context: ExecutionContext): Promise<ApprovalRecallResult>;
550557

551558
/**
552559
* ADR-0044 send back for revision. Finalises the pending request as
@@ -559,7 +566,7 @@ export interface IApprovalService {
559566
sendBack(
560567
requestId: string,
561568
input: ApprovalSendBackInput,
562-
context: SharingExecutionContext,
569+
context: ExecutionContext,
563570
): Promise<ApprovalSendBackResult>;
564571

565572
/**
@@ -572,7 +579,7 @@ export interface IApprovalService {
572579
resubmit(
573580
requestId: string,
574581
input: ApprovalResubmitInput,
575-
context: SharingExecutionContext,
582+
context: ExecutionContext,
576583
): Promise<ApprovalResubmitResult>;
577584

578585
/**
@@ -584,7 +591,7 @@ export interface IApprovalService {
584591
reassign(
585592
requestId: string,
586593
input: { actorId: string; to: string; from?: string; comment?: string },
587-
context: SharingExecutionContext,
594+
context: ExecutionContext,
588595
): Promise<{ request: ApprovalRequestRow }>;
589596

590597
/**
@@ -595,7 +602,7 @@ export interface IApprovalService {
595602
remind(
596603
requestId: string,
597604
input: { actorId: string; comment?: string },
598-
context: SharingExecutionContext,
605+
context: ExecutionContext,
599606
): Promise<{ request: ApprovalRequestRow; notified: number }>;
600607

601608
/**
@@ -606,7 +613,7 @@ export interface IApprovalService {
606613
requestInfo(
607614
requestId: string,
608615
input: { actorId: string; comment: string },
609-
context: SharingExecutionContext,
616+
context: ExecutionContext,
610617
): Promise<{ request: ApprovalRequestRow }>;
611618

612619
/**
@@ -616,9 +623,9 @@ export interface IApprovalService {
616623
comment(
617624
requestId: string,
618625
input: { actorId: string; comment: string },
619-
context: SharingExecutionContext,
626+
context: ExecutionContext,
620627
): Promise<{ request: ApprovalRequestRow }>;
621628

622629
/** Audit trail for a request. */
623-
listActions(requestId: string, context: SharingExecutionContext): Promise<ApprovalActionRow[]>;
630+
listActions(requestId: string, context: ExecutionContext): Promise<ApprovalActionRow[]>;
624631
}

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

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,13 @@
1313
* pivots, charts) layers on top of these primitives.
1414
*/
1515

16-
import type { SharingExecutionContext } from './sharing-service.js';
16+
// [#6523 / #6206 ruling default] Reports are read UNDER the caller's context —
17+
// row visibility, the saved-report gate and the schedule owner check all read
18+
// it — so every method takes the complete `resolveAuthzContext` envelope rather
19+
// than the six-field `SharingExecutionContext` this contract used to borrow
20+
// from `sharing-service`. See `SharingExecutionContext` in
21+
// `./sharing-service.js` for the boundary.
22+
import type { ExecutionContext } from '../kernel/execution-context.zod.js';
1723

1824
/** Render format supported by `IReportService.run()`. */
1925
export type ReportFormat = 'csv' | 'json' | 'html_table';
@@ -117,36 +123,36 @@ export interface ScheduleReportInput {
117123
*/
118124
export interface IReportService {
119125
/** Execute a report by id. */
120-
run(reportId: string, context: SharingExecutionContext): Promise<ReportRunResult>;
126+
run(reportId: string, context: ExecutionContext): Promise<ReportRunResult>;
121127

122128
/** Execute an ad-hoc report from an in-memory definition. */
123-
runAdHoc(input: SaveReportInput, context: SharingExecutionContext): Promise<ReportRunResult>;
129+
runAdHoc(input: SaveReportInput, context: ExecutionContext): Promise<ReportRunResult>;
124130

125131
/** Upsert a saved report. Returns the persisted row. */
126-
saveReport(input: SaveReportInput, context: SharingExecutionContext): Promise<SavedReport>;
132+
saveReport(input: SaveReportInput, context: ExecutionContext): Promise<SavedReport>;
127133

128134
/** List saved reports — optionally filtered by object. */
129135
listReports(
130136
filter: { object?: string; ownerId?: string } | undefined,
131-
context: SharingExecutionContext,
137+
context: ExecutionContext,
132138
): Promise<SavedReport[]>;
133139

134140
/** Get a saved report by id. */
135-
getReport(reportId: string, context: SharingExecutionContext): Promise<SavedReport | null>;
141+
getReport(reportId: string, context: ExecutionContext): Promise<SavedReport | null>;
136142

137143
/** Delete a saved report by id (and any attached schedules). */
138-
deleteReport(reportId: string, context: SharingExecutionContext): Promise<void>;
144+
deleteReport(reportId: string, context: ExecutionContext): Promise<void>;
139145

140146
/** Create or update a schedule. */
141-
scheduleReport(input: ScheduleReportInput, context: SharingExecutionContext): Promise<ReportSchedule>;
147+
scheduleReport(input: ScheduleReportInput, context: ExecutionContext): Promise<ReportSchedule>;
142148

143149
/** Remove a schedule by id. */
144-
unscheduleReport(scheduleId: string, context: SharingExecutionContext): Promise<void>;
150+
unscheduleReport(scheduleId: string, context: ExecutionContext): Promise<void>;
145151

146152
/** List schedules — optionally filtered by report. */
147153
listSchedules(
148154
filter: { reportId?: string } | undefined,
149-
context: SharingExecutionContext,
155+
context: ExecutionContext,
150156
): Promise<ReportSchedule[]>;
151157

152158
/**

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

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,27 @@
33
import { describe, it, expect } from 'vitest';
44
import type {
55
HierarchyScopeContext,
6+
ISharingRuleService,
67
ISharingService,
78
RecordShareRecipientType,
9+
SharingExecutionContext,
810
SharingRuleRecipientType,
911
SharingWriteVerdict,
1012
} from './sharing-service';
13+
import type { IApprovalService } from './approval-service';
14+
import type { IReportService } from './report-service';
15+
import type { ExecutionContext } from '../kernel/execution-context.zod';
1116
import { ShareRecipientType } from '../security/sharing.zod';
1217

18+
/** Type-level identity: true iff A and B are the same type. */
19+
type Eq<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2)
20+
? true
21+
: false;
22+
/** Compile error when the argument is not `true`. */
23+
type Assert<T extends true> = T;
24+
/** Compile error when the argument is not `false`. */
25+
type Refute<T extends false> = T;
26+
1327
/**
1428
* [#4539] `RecordShareRecipientType` (né `ShareRecipientType`) pins.
1529
*
@@ -481,3 +495,151 @@ describe('[#6428] ISharingService tri-state write verdict', () => {
481495
expect(docOf.get('buildReadFilter')).not.toContain('abstain');
482496
});
483497
});
498+
499+
/**
500+
* [#6523 / #6206 ruling default] The shared enforcement context is the FULL
501+
* envelope — the fourth and widest narrow twin, converged.
502+
*
503+
* ## What the ruling decided, and what this card applied it to
504+
*
505+
* #6206 (maintainer, 2026-08-07) set the governance default: enforcement
506+
* converges on the complete `resolveAuthzContext` envelope and keeps NO
507+
* per-site subset contracts. Its sweep reached one site — share-link (#6430 /
508+
* PR #6511). `SharingExecutionContext` was the fourth and by far the widest
509+
* twin: six declared fields serving **36 signatures across three contracts**
510+
* (`ISharingService` + `ISharingRuleService` here, `IApprovalService`,
511+
* `IReportService`), every one of them adjudicating access, with
512+
* `accessible_org_ids` / `org_user_ids` / `posture` / `tabPermissions` absent.
513+
*
514+
* ## The MIRROR direction — why this twin cost something different
515+
*
516+
* At the share-link site the caller trimmed the VALUE before enforcement saw
517+
* it. Here nothing was trimmed: `plugin-sharing`'s engine middleware hands the
518+
* whole execution context down (`buildReadFilter(ctx.object, exec ?? {})`), so
519+
* the values always arrived complete — it was the declared TYPE that was
520+
* narrow, so an implementation could not read what it had been given without
521+
* casting out of its own contract. The specimen on `main` when this card was
522+
* written, in `plugin-approvals`' privileged-override gate:
523+
*
524+
* const posture = (context as any).posture; // isOverrideActor()
525+
*
526+
* ## What is pinned here, and what deliberately is NOT
527+
*
528+
* PINNED: (1) the context parameter of every adjudicating method across the
529+
* three contracts is `ExecutionContext`, BY TYPE IDENTITY, so re-narrowing it
530+
* to anything — the old type included — goes red; (2) the SHAPE WITNESS: an
531+
* implementation typed by the contract reads `accessible_org_ids` / `posture`
532+
* / `org_user_ids` / `tabPermissions` with **no `as any`**, which under the old
533+
* signature was TS2339 on each field — that is this file's before-red
534+
* direction, and it is the mirror of PR #6511's, which was TS2353 at a call
535+
* site; (3) the narrow type survives UNCHANGED IN SHAPE, so the convergence
536+
* cannot be undone by widening it back one field at a time.
537+
*
538+
* NOT PINNED, on purpose, and for exactly the reason PR #6511 recorded: there
539+
* is no `@ts-expect-error` asserting that a `SharingExecutionContext` is
540+
* REJECTED where an `ExecutionContext` is expected, because it is not.
541+
* Structural subtyping accepts it — all six fields exist in the wider type
542+
* with compatible types, and nothing there is required. A pin shaped like
543+
* compiler enforcement where only a declaration exists would read as verified
544+
* and be worse than saying so.
545+
*/
546+
describe('[#6523] sharing / approval / report enforcement takes the full ExecutionContext', () => {
547+
it('declares the full envelope on every adjudicating signature, by type identity', () => {
548+
// Type-level assertions are the substance of this case; the runtime
549+
// expectation below only keeps vitest from reporting an empty test. tsc
550+
// compiles this file (tsconfig.test.json, #5286), so these are checked.
551+
type SharingCtx = Parameters<ISharingService['buildReadFilter']>[1];
552+
type EditCtx = Parameters<ISharingService['checkEdit']>[2];
553+
type GrantCtx = Parameters<ISharingService['grant']>[1];
554+
type RuleCtx = Parameters<ISharingRuleService['evaluateRule']>[1];
555+
type ApprovalCtx = Parameters<IApprovalService['decide']>[2];
556+
type ReportCtx = Parameters<IReportService['run']>[1];
557+
558+
type _Pins = [
559+
Assert<Eq<SharingCtx, ExecutionContext>>,
560+
Assert<Eq<EditCtx, ExecutionContext>>,
561+
Assert<Eq<GrantCtx, ExecutionContext>>,
562+
Assert<Eq<RuleCtx, ExecutionContext>>,
563+
Assert<Eq<ApprovalCtx, ExecutionContext>>,
564+
Assert<Eq<ReportCtx, ExecutionContext>>,
565+
// …and none of them is the six-field twin any more.
566+
Refute<Eq<SharingCtx, SharingExecutionContext>>,
567+
Refute<Eq<ApprovalCtx, SharingExecutionContext>>,
568+
Refute<Eq<ReportCtx, SharingExecutionContext>>,
569+
];
570+
const pinned: _Pins = [true, true, true, true, true, true, false, false, false];
571+
expect(pinned).toHaveLength(9);
572+
});
573+
574+
it('lets an implementation READ the envelope it is handed — no `as any` (shape witness)', async () => {
575+
// The witness for the mirror direction. `context` is typed BY THE CONTRACT
576+
// — `Parameters<ISharingService['buildReadFilter']>[1]`, not by a local
577+
// annotation — so if the contract re-narrows, the four reads below stop
578+
// compiling (TS2339: "Property 'accessible_org_ids' does not exist on type
579+
// 'SharingExecutionContext'"). That is precisely the wall
580+
// `plugin-approvals` climbed with `(context as any).posture`.
581+
const seen: Array<Record<string, unknown>> = [];
582+
const buildReadFilter: ISharingService['buildReadFilter'] = async (object, context) => {
583+
seen.push({
584+
object,
585+
// ADR-0105 D2 — under the `group` posture this set IS the Layer 0 wall.
586+
accessible_org_ids: context.accessible_org_ids,
587+
// ADR-0095 D2 — resolved once upstream and carried, never re-derived here.
588+
posture: context.posture,
589+
org_user_ids: context.org_user_ids,
590+
tabPermissions: context.tabPermissions,
591+
});
592+
return null;
593+
};
594+
595+
// The call site, spelled the way `plugin-sharing`'s engine middleware
596+
// spells it: the whole resolved envelope, handed straight down. Written as
597+
// an object LITERAL on purpose — excess-property checking applies to
598+
// literals, so under the old parameter type each of the four keys below
599+
// was additionally a TS2353 error.
600+
const envelope: ExecutionContext = {
601+
userId: 'usr_1',
602+
tenantId: 'org_plant_a',
603+
positions: ['sales'],
604+
permissions: ['standard_user'],
605+
systemPermissions: ['manage_sharing'],
606+
accessible_org_ids: ['org_plant_a', 'org_plant_b'],
607+
org_user_ids: ['usr_1', 'usr_2'],
608+
posture: 'MEMBER',
609+
tabPermissions: { crm: 'visible' },
610+
};
611+
expect(await buildReadFilter('account', envelope)).toBeNull();
612+
613+
// Anti-vacuity: the values really travelled, and were really readable.
614+
expect(seen).toHaveLength(1);
615+
expect(seen[0]).toEqual({
616+
object: 'account',
617+
accessible_org_ids: ['org_plant_a', 'org_plant_b'],
618+
posture: 'MEMBER',
619+
org_user_ids: ['usr_1', 'usr_2'],
620+
tabPermissions: { crm: 'visible' },
621+
});
622+
});
623+
624+
it('keeps the narrow twin unchanged in shape — it is residue, not a shortcut', () => {
625+
// Widening `SharingExecutionContext` instead of replacing it would rebuild
626+
// the per-site subset the ruling removed, one field at a time. PR #6511
627+
// pinned the same refusal for the share-link twin.
628+
type NarrowKeys = keyof SharingExecutionContext;
629+
type _ShapeUnchanged = Assert<
630+
Eq<NarrowKeys, 'userId' | 'tenantId' | 'positions' | 'permissions' | 'systemPermissions' | 'isSystem'>
631+
>;
632+
const shapeUnchanged: _ShapeUnchanged = true;
633+
634+
// The honest half, exactly as PR #6511 recorded it for its own twin: this
635+
// assignment is LEGAL and compiles. Six optional fields, all present in the
636+
// wider type — so the boundary is held by the declared parameter type and
637+
// the caller's obligation, never by tsc. An `@ts-expect-error` here would
638+
// be unsatisfied and fail the build.
639+
const residue: SharingExecutionContext = { userId: 'usr_1', isSystem: false };
640+
const widened: ExecutionContext = residue;
641+
expect(shapeUnchanged).toBe(true);
642+
expect(widened.userId).toBe('usr_1');
643+
expect(widened.accessible_org_ids).toBeUndefined();
644+
});
645+
});

0 commit comments

Comments
 (0)