diff --git a/.changeset/sharing-execution-context-full-envelope.md b/.changeset/sharing-execution-context-full-envelope.md new file mode 100644 index 0000000000..310b1d7338 --- /dev/null +++ b/.changeset/sharing-execution-context-full-envelope.md @@ -0,0 +1,44 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): sharing / approval / report enforcement takes the full `ExecutionContext`; the six-field context is migration residue (#6523, #6206 ruling default) + +`ISharingService`, `ISharingRuleService`, `IApprovalService` and +`IReportService` now declare their context parameter as the complete +`ExecutionContext` envelope instead of the six-field +`SharingExecutionContext` — 36 signatures across the three contract files. +Every one of those methods ADJUDICATES access (the read-filter contribution, +both write gates and their tri-state forms, share management, rule definition +and evaluation, approval decisions and recalls, report runs and schedules), so +each needs the whole `resolveAuthzContext` result: `accessible_org_ids` (the +`group`-posture Layer 0 wall, ADR-0105 D2), `org_user_ids`, `systemPermissions`, +`posture` (ADR-0095 D2 — resolved once, carried, never re-derived at the +enforcement site) and `tabPermissions` included. + +`SharingExecutionContext` was the fourth and widest twin of the family #6206 +ruled on (converge to the full envelope, keep no per-site subset contracts); +that ruling's sweep had reached only the share-link site (#6430). + +The damage ran in the MIRROR direction of the share-link case, which is worth +stating because it is the direction a reviewer does not expect. Nothing here +trimmed a value: `plugin-sharing`'s engine middleware passes its whole +execution context down (`buildReadFilter(ctx.object, exec ?? {})`), so the +values always arrived complete. It was the declared TYPE that was narrow, so +the receiving implementation could not read what it had been handed without +casting out of its own contract — measurably, `plugin-approvals`' +privileged-override gate reaching for the resolved posture as +`(context as any).posture`. + +`SharingExecutionContext` is retained and unchanged in shape, now documented as +migration residue: nothing in `packages/spec` takes it any more, and the three +plugin implementations that still annotate their own parameters with it are the +consumer half, separated exactly as #6430's contract and plugin halves were. +Widening it field by field is explicitly refused — that would rebuild the +per-site subset the ruling removed. + +Contract-only, no runtime behaviour change and no acceptance-surface change +(these are TypeScript interfaces, not Zod schemas — nothing authorable moves). +Existing implementations keep compiling: the two types are mutually assignable +(all fields optional, all six present in the wider type), so neither direction +of the parameter change breaks them. diff --git a/packages/spec/src/contracts/approval-service.ts b/packages/spec/src/contracts/approval-service.ts index 86af4b308a..0fdefb28ed 100644 --- a/packages/spec/src/contracts/approval-service.ts +++ b/packages/spec/src/contracts/approval-service.ts @@ -16,7 +16,14 @@ * authoring type, submit, or step machinery anymore. */ -import type { SharingExecutionContext } from './sharing-service.js'; +// [#6523 / #6206 ruling default] Every method below ADJUDICATES access, so each +// takes the complete `resolveAuthzContext` envelope rather than the six-field +// `SharingExecutionContext` this contract used to borrow from `sharing-service`. +// That narrow type omitted `accessible_org_ids` (the `group`-posture Layer 0 +// wall, ADR-0105 D2), `org_user_ids`, `posture` (ADR-0095 D2) and +// `tabPermissions` — see `SharingExecutionContext` in `./sharing-service.js` +// for the boundary and the measured consequence. +import type { ExecutionContext } from '../kernel/execution-context.zod.js'; /** * Lifecycle states of an approval request, in the order the @@ -515,7 +522,7 @@ export interface IApprovalService { limit?: number; offset?: number; } | undefined, - context: SharingExecutionContext, + context: ExecutionContext, ): Promise; /** @@ -524,17 +531,17 @@ export interface IApprovalService { */ countRequests( filter: Parameters[0], - context: SharingExecutionContext, + context: ExecutionContext, ): Promise; - getRequest(requestId: string, context: SharingExecutionContext): Promise; + getRequest(requestId: string, context: ExecutionContext): Promise; /** * Record a decision on a node-driven request. Honours the node's * `unanimous` behaviour, finalises the request when satisfied, and resumes * the owning flow run down the matching `approve` / `reject` edge. */ - decide(requestId: string, input: ApprovalDecisionInput, context: SharingExecutionContext): Promise; + decide(requestId: string, input: ApprovalDecisionInput, context: ExecutionContext): Promise; /** * Withdraw a pending request. Only the submitter (or a system context) may @@ -546,7 +553,7 @@ export interface IApprovalService { * request flips `returned → recalled` and the run resumes down `reject` the * same way. */ - recall(requestId: string, input: ApprovalRecallInput, context: SharingExecutionContext): Promise; + recall(requestId: string, input: ApprovalRecallInput, context: ExecutionContext): Promise; /** * ADR-0044 send back for revision. Finalises the pending request as @@ -559,7 +566,7 @@ export interface IApprovalService { sendBack( requestId: string, input: ApprovalSendBackInput, - context: SharingExecutionContext, + context: ExecutionContext, ): Promise; /** @@ -572,7 +579,7 @@ export interface IApprovalService { resubmit( requestId: string, input: ApprovalResubmitInput, - context: SharingExecutionContext, + context: ExecutionContext, ): Promise; /** @@ -584,7 +591,7 @@ export interface IApprovalService { reassign( requestId: string, input: { actorId: string; to: string; from?: string; comment?: string }, - context: SharingExecutionContext, + context: ExecutionContext, ): Promise<{ request: ApprovalRequestRow }>; /** @@ -595,7 +602,7 @@ export interface IApprovalService { remind( requestId: string, input: { actorId: string; comment?: string }, - context: SharingExecutionContext, + context: ExecutionContext, ): Promise<{ request: ApprovalRequestRow; notified: number }>; /** @@ -606,7 +613,7 @@ export interface IApprovalService { requestInfo( requestId: string, input: { actorId: string; comment: string }, - context: SharingExecutionContext, + context: ExecutionContext, ): Promise<{ request: ApprovalRequestRow }>; /** @@ -616,9 +623,9 @@ export interface IApprovalService { comment( requestId: string, input: { actorId: string; comment: string }, - context: SharingExecutionContext, + context: ExecutionContext, ): Promise<{ request: ApprovalRequestRow }>; /** Audit trail for a request. */ - listActions(requestId: string, context: SharingExecutionContext): Promise; + listActions(requestId: string, context: ExecutionContext): Promise; } diff --git a/packages/spec/src/contracts/report-service.ts b/packages/spec/src/contracts/report-service.ts index 28defc5f92..ff21ec204d 100644 --- a/packages/spec/src/contracts/report-service.ts +++ b/packages/spec/src/contracts/report-service.ts @@ -13,7 +13,13 @@ * pivots, charts) layers on top of these primitives. */ -import type { SharingExecutionContext } from './sharing-service.js'; +// [#6523 / #6206 ruling default] Reports are read UNDER the caller's context — +// row visibility, the saved-report gate and the schedule owner check all read +// it — so every method takes the complete `resolveAuthzContext` envelope rather +// than the six-field `SharingExecutionContext` this contract used to borrow +// from `sharing-service`. See `SharingExecutionContext` in +// `./sharing-service.js` for the boundary. +import type { ExecutionContext } from '../kernel/execution-context.zod.js'; /** Render format supported by `IReportService.run()`. */ export type ReportFormat = 'csv' | 'json' | 'html_table'; @@ -117,36 +123,36 @@ export interface ScheduleReportInput { */ export interface IReportService { /** Execute a report by id. */ - run(reportId: string, context: SharingExecutionContext): Promise; + run(reportId: string, context: ExecutionContext): Promise; /** Execute an ad-hoc report from an in-memory definition. */ - runAdHoc(input: SaveReportInput, context: SharingExecutionContext): Promise; + runAdHoc(input: SaveReportInput, context: ExecutionContext): Promise; /** Upsert a saved report. Returns the persisted row. */ - saveReport(input: SaveReportInput, context: SharingExecutionContext): Promise; + saveReport(input: SaveReportInput, context: ExecutionContext): Promise; /** List saved reports — optionally filtered by object. */ listReports( filter: { object?: string; ownerId?: string } | undefined, - context: SharingExecutionContext, + context: ExecutionContext, ): Promise; /** Get a saved report by id. */ - getReport(reportId: string, context: SharingExecutionContext): Promise; + getReport(reportId: string, context: ExecutionContext): Promise; /** Delete a saved report by id (and any attached schedules). */ - deleteReport(reportId: string, context: SharingExecutionContext): Promise; + deleteReport(reportId: string, context: ExecutionContext): Promise; /** Create or update a schedule. */ - scheduleReport(input: ScheduleReportInput, context: SharingExecutionContext): Promise; + scheduleReport(input: ScheduleReportInput, context: ExecutionContext): Promise; /** Remove a schedule by id. */ - unscheduleReport(scheduleId: string, context: SharingExecutionContext): Promise; + unscheduleReport(scheduleId: string, context: ExecutionContext): Promise; /** List schedules — optionally filtered by report. */ listSchedules( filter: { reportId?: string } | undefined, - context: SharingExecutionContext, + context: ExecutionContext, ): Promise; /** diff --git a/packages/spec/src/contracts/sharing-service.test.ts b/packages/spec/src/contracts/sharing-service.test.ts index 00ccf77381..bfc02060ea 100644 --- a/packages/spec/src/contracts/sharing-service.test.ts +++ b/packages/spec/src/contracts/sharing-service.test.ts @@ -3,13 +3,27 @@ import { describe, it, expect } from 'vitest'; import type { HierarchyScopeContext, + ISharingRuleService, ISharingService, RecordShareRecipientType, + SharingExecutionContext, SharingRuleRecipientType, SharingWriteVerdict, } from './sharing-service'; +import type { IApprovalService } from './approval-service'; +import type { IReportService } from './report-service'; +import type { ExecutionContext } from '../kernel/execution-context.zod'; import { ShareRecipientType } from '../security/sharing.zod'; +/** Type-level identity: true iff A and B are the same type. */ +type Eq = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) + ? true + : false; +/** Compile error when the argument is not `true`. */ +type Assert = T; +/** Compile error when the argument is not `false`. */ +type Refute = T; + /** * [#4539] `RecordShareRecipientType` (né `ShareRecipientType`) pins. * @@ -481,3 +495,158 @@ describe('[#6428] ISharingService tri-state write verdict', () => { expect(docOf.get('buildReadFilter')).not.toContain('abstain'); }); }); + +/** + * [#6523 / #6206 ruling default] The shared enforcement context is the FULL + * envelope — the fourth and widest narrow twin, converged. + * + * ## What the ruling decided, and what this card applied it to + * + * #6206 (maintainer, 2026-08-07) set the governance default: enforcement + * converges on the complete `resolveAuthzContext` envelope and keeps NO + * per-site subset contracts. Its sweep reached one site — share-link (#6430 / + * PR #6511). `SharingExecutionContext` was the fourth and by far the widest + * twin: six declared fields serving **36 signatures across three contracts** + * (`ISharingService` + `ISharingRuleService` here, `IApprovalService`, + * `IReportService`), every one of them adjudicating access, with + * `accessible_org_ids` / `org_user_ids` / `posture` / `tabPermissions` absent. + * + * ## The MIRROR direction — why this twin cost something different + * + * At the share-link site the caller trimmed the VALUE before enforcement saw + * it. Here nothing was trimmed: `plugin-sharing`'s engine middleware hands the + * whole execution context down (`buildReadFilter(ctx.object, exec ?? {})`), so + * the values always arrived complete — it was the declared TYPE that was + * narrow, so an implementation could not read what it had been given without + * casting out of its own contract. The specimen on `main` when this card was + * written, in `plugin-approvals`' privileged-override gate: + * + * const posture = (context as any).posture; // isOverrideActor() + * + * ## What is pinned here, and what deliberately is NOT + * + * PINNED: (1) the context parameter of every adjudicating method across the + * three contracts is `ExecutionContext`, BY TYPE IDENTITY, so re-narrowing it + * to anything — the old type included — goes red; (2) the SHAPE WITNESS: an + * implementation typed by the contract reads `accessible_org_ids` / `posture` + * / `org_user_ids` / `tabPermissions` with **no `as any`**, which under the old + * signature was TS2339 on each field (TS2551 on `tabPermissions` — tsc + * suggests `permissions`, the very near-miss the narrow type invited) — that + * is this file's before-red direction, and it is the MIRROR of PR #6511's, + * which was TS2353 at a call site stating a trimmed envelope; (3) the narrow + * type survives UNCHANGED IN SHAPE, so the convergence cannot be undone by + * widening it back one field at a time. + * + * NOT PINNED, on purpose, and for exactly the reason PR #6511 recorded: there + * is no `@ts-expect-error` asserting that a `SharingExecutionContext` is + * REJECTED where an `ExecutionContext` is expected, because it is not. + * Structural subtyping accepts it — all six fields exist in the wider type + * with compatible types, and nothing there is required. A pin shaped like + * compiler enforcement where only a declaration exists would read as verified + * and be worse than saying so. + */ +describe('[#6523] sharing / approval / report enforcement takes the full ExecutionContext', () => { + it('declares the full envelope on every adjudicating signature, by type identity', () => { + // Type-level assertions are the substance of this case; the runtime + // expectation below only keeps vitest from reporting an empty test. tsc + // compiles this file (tsconfig.test.json, #5286), so these are checked. + type SharingCtx = Parameters[1]; + type EditCtx = Parameters[2]; + type GrantCtx = Parameters[1]; + type RuleCtx = Parameters[1]; + type ApprovalCtx = Parameters[2]; + type ReportCtx = Parameters[1]; + + type _Pins = [ + Assert>, + Assert>, + Assert>, + Assert>, + Assert>, + Assert>, + // …and none of them is the six-field twin any more. + Refute>, + Refute>, + Refute>, + ]; + const pinned: _Pins = [true, true, true, true, true, true, false, false, false]; + expect(pinned).toHaveLength(9); + }); + + it('lets an implementation READ the envelope it is handed — no `as any` (shape witness)', async () => { + // The witness for the mirror direction. `context` is typed BY THE CONTRACT + // — `Parameters[1]`, not by a local + // annotation — so if the contract re-narrows, the four reads below stop + // compiling (TS2339: "Property 'accessible_org_ids' does not exist on type + // 'SharingExecutionContext'"). That is precisely the wall + // `plugin-approvals` climbed with `(context as any).posture`. + const seen: Array> = []; + const buildReadFilter: ISharingService['buildReadFilter'] = async (object, context) => { + seen.push({ + object, + // ADR-0105 D2 — under the `group` posture this set IS the Layer 0 wall. + accessible_org_ids: context.accessible_org_ids, + // ADR-0095 D2 — resolved once upstream and carried, never re-derived here. + posture: context.posture, + org_user_ids: context.org_user_ids, + tabPermissions: context.tabPermissions, + }); + return null; + }; + + // The call site, spelled the way `plugin-sharing`'s engine middleware + // spells it: the whole resolved envelope, resolved once and handed straight + // down as a VARIABLE — not as an inline literal. That is deliberate, and it + // is why reverting this card produces no TS2353 here: excess-property + // checking would fire only on an inline literal, and inline-literal damage + // is PR #6511's direction (a caller stating a trimmed envelope), not this + // one. Here the value was always whole and always assignable; only the + // READ above was blocked. Measured on the revert: 22 errors on this file, + // TS2339/TS2551 on the four reads and TS2344/TS2322 on the identity pins, + // and zero TS2353. + const envelope: ExecutionContext = { + userId: 'usr_1', + tenantId: 'org_plant_a', + positions: ['sales'], + permissions: ['standard_user'], + systemPermissions: ['manage_sharing'], + accessible_org_ids: ['org_plant_a', 'org_plant_b'], + org_user_ids: ['usr_1', 'usr_2'], + posture: 'MEMBER', + tabPermissions: { crm: 'visible' }, + }; + expect(await buildReadFilter('account', envelope)).toBeNull(); + + // Anti-vacuity: the values really travelled, and were really readable. + expect(seen).toHaveLength(1); + expect(seen[0]).toEqual({ + object: 'account', + accessible_org_ids: ['org_plant_a', 'org_plant_b'], + posture: 'MEMBER', + org_user_ids: ['usr_1', 'usr_2'], + tabPermissions: { crm: 'visible' }, + }); + }); + + it('keeps the narrow twin unchanged in shape — it is residue, not a shortcut', () => { + // Widening `SharingExecutionContext` instead of replacing it would rebuild + // the per-site subset the ruling removed, one field at a time. PR #6511 + // pinned the same refusal for the share-link twin. + type NarrowKeys = keyof SharingExecutionContext; + type _ShapeUnchanged = Assert< + Eq + >; + const shapeUnchanged: _ShapeUnchanged = true; + + // The honest half, exactly as PR #6511 recorded it for its own twin: this + // assignment is LEGAL and compiles. Six optional fields, all present in the + // wider type — so the boundary is held by the declared parameter type and + // the caller's obligation, never by tsc. An `@ts-expect-error` here would + // be unsatisfied and fail the build. + const residue: SharingExecutionContext = { userId: 'usr_1', isSystem: false }; + const widened: ExecutionContext = residue; + expect(shapeUnchanged).toBe(true); + expect(widened.userId).toBe('usr_1'); + expect(widened.accessible_org_ids).toBeUndefined(); + }); +}); diff --git a/packages/spec/src/contracts/sharing-service.ts b/packages/spec/src/contracts/sharing-service.ts index 9be3ae38f1..34f721b68e 100644 --- a/packages/spec/src/contracts/sharing-service.ts +++ b/packages/spec/src/contracts/sharing-service.ts @@ -34,6 +34,14 @@ * {@link SharingWriteVerdict} for the measured fail-open that * collapsing the two into one `true` produced (#5492 E2). * + * 3. **Enforcement runs on the FULL envelope.** Every method on both + * interfaces below adjudicates access, so each takes a complete + * {@link ExecutionContext} — the whole `resolveAuthzContext` result, + * threaded through unchanged — rather than a per-site subset (#6523, + * applying the #6206 ruling). {@link SharingExecutionContext}, the + * six-field shape those parameters used to name, carries the boundary + * and the measured consequence of the narrow spelling. + * * Manual share CRUD is exposed via `grant()`, `revoke()`, and * `listShares()`. The REST layer wires these to * `/data/:object/:id/shares`. @@ -46,6 +54,12 @@ // drift apart (#6139). Erased at compile time, so this module stays the pure // type-declaration surface it has always been — no runtime coupling added. import type { TenancyPosture } from '../security/tenancy-posture'; +// Type-only: the full `resolveAuthzContext` envelope. Every enforcement method +// on {@link ISharingService} and {@link ISharingRuleService} declares its +// context parameter as this type (#6523, applying the #6206 ruling default — +// no per-site subset contracts). See {@link SharingExecutionContext} for the +// boundary that draws and why. +import type { ExecutionContext } from '../kernel/execution-context.zod.js'; /** * Recipient categories a `sys_record_share` ROW may carry — mirrors the @@ -113,7 +127,67 @@ export interface GrantShareInput { reason?: string; } -/** Minimal execution-context shape the service needs from callers. */ +/** + * ⛔ NOT an enforcement context type (#6523, applying the #6206 ruling of + * 2026-08-07: converge on the full envelope, keep no per-site subset). + * + * ## What this used to be, and what it cost + * + * This was the declared context parameter of **36 signatures across three + * contracts** — `ISharingService` / `ISharingRuleService` here, + * `IApprovalService` (12) and `IReportService` (9) — every one of which + * ADJUDICATES access. It names six of the `resolveAuthzContext` envelope's + * fields and drops the four the ruling called out by name: + * `accessible_org_ids` (under the `group` tenancy posture this IS the Layer 0 + * wall, ADR-0105 D2), `org_user_ids`, `posture` (ADR-0095 D2: resolved once, + * carried, never re-derived at the enforcement site) and `tabPermissions`. + * + * The damage ran in the MIRROR direction of the share-link case (#6206 / + * #6430), and that direction is worth stating precisely because it is the one + * a reviewer does not expect. Nothing here trimmed a value: the engine + * middleware passes its whole execution context down + * (`plugin-sharing/src/sharing-plugin.ts` — `buildReadFilter(ctx.object, exec + * ?? {})`), so the VALUES arrived complete. It was the declared TYPE that was + * narrow, so the receiving implementation could not READ what it had been + * handed without casting its way out of its own contract. The measured + * specimen, on `main` at the time of writing: + * + * ``` + * // plugin-approvals/src/approval-service.ts — isOverrideActor() + * const posture = (context as any).posture; + * ``` + * + * — a privileged-override gate reaching for ADR-0095's resolved posture + * through `as any`, because the contract said the field was not there. An + * `as any` on an enforcement input is not a style blemish: it turns off + * checking for the whole expression, so the next field read through it is + * unverified too, and it makes the honest reading ("this gate consults the + * posture") indistinguishable from a typo. + * + * ## What it is now + * + * Nothing in `packages/spec/src/contracts` takes this type any more. It stays + * EXPORTED and UNCHANGED IN SHAPE for one reason: the three implementations + * (`plugin-sharing`, `plugin-approvals`, `plugin-reports`) still annotate + * their own method parameters with it, and re-typing them is the consumer + * half of this convergence — a separate change with its own review, exactly as + * #6430's contract half and its plugin half were separated. It is therefore + * MIGRATION RESIDUE, not a vocabulary to reach for. ⛔ Do not add a new use; + * ⛔ do not widen it field-by-field, which would rebuild the per-site subset + * the ruling removed, one field at a time. + * + * ## What holds the line (and what cannot) + * + * TypeScript cannot police this. Structural subtyping makes a value of this + * type assignable to {@link ExecutionContext} — all six fields exist there + * with compatible types and nothing in the wider type is required — so passing + * a narrowed context into an enforcement path still COMPILES, and an + * `@ts-expect-error` asserting otherwise would be unsatisfied and fail the + * build. What the contract can do, and now does, is declare the enforcement + * parameter as the full envelope so that any narrowing is visible AT THE CALL + * SITE, and so that an implementation may read the whole envelope it is + * already being handed — without `as any`. + */ export interface SharingExecutionContext { userId?: string; tenantId?: string; @@ -173,6 +247,16 @@ export type SharingWriteVerdict = 'allow' | 'abstain' | 'deny'; * complete bypass (no filter, every write gate answers `allow` / `true`) so * that platform-internal writers (audit, migrations, the sharing plugin * itself) cannot deadlock on their own enforcement. + * + * ## The context every method takes (#6523, #6206 ruling) + * + * `context` is the complete {@link ExecutionContext} — the caller's whole + * `resolveAuthzContext` envelope, passed through unchanged. Callers MUST NOT + * rebuild a subset of it, and implementations may read ALL of it: + * `accessible_org_ids` (the `group`-posture Layer 0 wall, ADR-0105 D2), + * `org_user_ids`, `systemPermissions`, `posture` (ADR-0095 D2) and + * `tabPermissions` included. Which of those a deployment makes load-bearing + * depends on its tenancy posture, which the caller cannot know. */ export interface ISharingService { /** @@ -182,7 +266,7 @@ export interface ISharingService { */ buildReadFilter( object: string, - context: SharingExecutionContext, + context: ExecutionContext, ): Promise; /** @@ -214,7 +298,7 @@ export interface ISharingService { checkEdit( object: string, recordId: string, - context: SharingExecutionContext, + context: ExecutionContext, ): Promise; /** @@ -236,7 +320,7 @@ export interface ISharingService { canEdit( object: string, recordId: string, - context: SharingExecutionContext, + context: ExecutionContext, ): Promise; /** @@ -255,7 +339,7 @@ export interface ISharingService { checkDelete( object: string, recordId: string, - context: SharingExecutionContext, + context: ExecutionContext, ): Promise; /** @@ -277,7 +361,7 @@ export interface ISharingService { canDelete( object: string, recordId: string, - context: SharingExecutionContext, + context: ExecutionContext, ): Promise; /** @@ -295,7 +379,7 @@ export interface ISharingService { canManageShares( object: string, recordId: string, - context: SharingExecutionContext, + context: ExecutionContext, ): Promise; /** @@ -311,7 +395,7 @@ export interface ISharingService { * nothing). Upserts match on `(object, record, recipient, source)` so a * manual grant never clobbers a rule-materialised row (D7). */ - grant(input: GrantShareInput, context: SharingExecutionContext): Promise; + grant(input: GrantShareInput, context: ExecutionContext): Promise; /** * Remove a share row by id. @@ -328,7 +412,7 @@ export interface ISharingService { */ revoke( shareId: string, - context: SharingExecutionContext, + context: ExecutionContext, scope?: { object: string; recordId: string }, ): Promise; @@ -344,7 +428,7 @@ export interface ISharingService { listShares( object: string, recordId: string, - context: SharingExecutionContext, + context: ExecutionContext, ): Promise; } @@ -438,26 +522,31 @@ export interface SharingRuleEvaluationResult { * `sys_record_share` with `source='rule'` and `source_id=rule.id` so * stale grants from a rule update can be reconciled without touching * manual or team-derived shares. + * + * Rule management is capability-gated and rule EVALUATION writes grants that + * decide other principals' visibility, so every method here takes the full + * {@link ExecutionContext} on the same terms as {@link ISharingService} + * (#6523). */ export interface ISharingRuleService { - defineRule(input: DefineSharingRuleInput, context: SharingExecutionContext): Promise; - listRules(filter: { object?: string; activeOnly?: boolean }, context: SharingExecutionContext): Promise; - getRule(idOrName: string, context: SharingExecutionContext): Promise; - deleteRule(idOrName: string, context: SharingExecutionContext): Promise; + defineRule(input: DefineSharingRuleInput, context: ExecutionContext): Promise; + listRules(filter: { object?: string; activeOnly?: boolean }, context: ExecutionContext): Promise; + getRule(idOrName: string, context: ExecutionContext): Promise; + deleteRule(idOrName: string, context: ExecutionContext): Promise; /** * Re-evaluate a rule across every record of its object_name and * reconcile the resulting `sys_record_share` rows. Admin-initiated; * use after rule edits or for backfill. */ - evaluateRule(idOrName: string, context: SharingExecutionContext): Promise; + evaluateRule(idOrName: string, context: ExecutionContext): Promise; /** * Incremental evaluation triggered by the lifecycle hook — re-checks * every active rule for `object` against this single record and * upserts/reconciles only that record's share rows. */ - evaluateAllForRecord(object: string, recordId: string, context: SharingExecutionContext): Promise; + evaluateAllForRecord(object: string, recordId: string, context: ExecutionContext): Promise; } // ─────────────────────────────────────────────────────────────────────