diff --git a/.changeset/retire-sharing-execution-context.md b/.changeset/retire-sharing-execution-context.md new file mode 100644 index 0000000000..d0772ec484 --- /dev/null +++ b/.changeset/retire-sharing-execution-context.md @@ -0,0 +1,52 @@ +--- +"@objectstack/spec": minor +"@objectstack/plugin-sharing": minor +"@objectstack/plugin-approvals": patch +"@objectstack/plugin-reports": patch +--- + +refactor(spec,plugin-sharing): retire the exported `SharingExecutionContext` type (#7218) + + + +**BREAKING — public surface removal.** `SharingExecutionContext` is deleted from +`@objectstack/spec` (`contracts/sharing-service`) and from +`@objectstack/plugin-sharing`, which re-exported it. Both `api-surface/` and +`export-origins/` snapshots are regenerated accordingly. + +This is the deferred deletion recorded when #7070 split the convergence in two. +#6523 / PR #7068 converged 36 contract signatures onto the full +`resolveAuthzContext` envelope (`ExecutionContext`), applying the #6206 ruling — +enforcement adjudicates on the whole envelope, never a per-site subset. The +consumer halves then re-annotated the implementations: PR #7140 (identity: +`plugin-sharing`, `plugin-audit`) and PR #7206 (services: `plugin-approvals`, +`plugin-reports`). Both landed with the type still exported, because it is +DEFINED in `packages/spec` and that package's retirement is the spec seat's to +make. Nothing declares it any more, so it goes. + +**Migration.** Anyone who imported `SharingExecutionContext` from either package +should import `ExecutionContext` from `@objectstack/spec` instead — the type the +contracts have declared since #7068. The old shape was six optional fields, all +of which exist on the envelope with the same names and types, so a value that +satisfied the retired type already satisfies `ExecutionContext`; only the +spelling of the annotation changes. + +**No runtime behaviour changes.** The type was erased at compile time and no +signature's accepted shape moved: the contracts already took the wide envelope. + +**What the retirement did NOT remove — the reason to read the pins.** Deleting +the type does not make re-narrowing a compile error. Structural subtyping still +accepts a six-field context where the envelope is expected, so the boundary is +held by the declared parameter type plus the pins, exactly as before. The three +`exec-context-annotation.pin.ts` files (`plugin-sharing`, `plugin-approvals`, +`plugin-reports`) told their failure story as "the parameter narrows back to +`SharingExecutionContext`", which a deletion would have quietly hollowed out. +Each now keeps the retired six-field shape as a local, non-exported SPECIMEN +type and refutes every enforcement parameter against it by type identity, so a +re-narrowing under ANY name is red — alongside the fresh-literal +excess-property checks they already carried. `sharing-service.test.ts` in +`packages/spec` is re-anchored the same way, and its "twin unchanged in shape" +case becomes a "twin stays retired" case. The narrative the retired type's doc +block carried (the measured `(context as any).posture` specimen, and why tsc +cannot police this) moves to the module doc of `contracts/sharing-service`, +which the contracts and pins now point at. diff --git a/content/docs/kernel/runtime-services/sharing-service.mdx b/content/docs/kernel/runtime-services/sharing-service.mdx index 374696207d..7225c07b29 100644 --- a/content/docs/kernel/runtime-services/sharing-service.mdx +++ b/content/docs/kernel/runtime-services/sharing-service.mdx @@ -11,15 +11,21 @@ description: Record-level sharing and editability checks. ## Methods ```ts -services.sharing.buildReadFilter(object: string, context: SharingExecutionContext): Promise -services.sharing.canEdit(object: string, recordId: string, context: SharingExecutionContext): Promise -services.sharing.canDelete(object: string, recordId: string, context: SharingExecutionContext): Promise -services.sharing.canManageShares(object: string, recordId: string, context: SharingExecutionContext): Promise -services.sharing.grant(input: GrantShareInput, context: SharingExecutionContext): Promise -services.sharing.revoke(shareId: string, context: SharingExecutionContext, scope?: { object: string; recordId: string }): Promise -services.sharing.listShares(object: string, recordId: string, context: SharingExecutionContext): Promise +services.sharing.buildReadFilter(object: string, context: ExecutionContext): Promise +services.sharing.canEdit(object: string, recordId: string, context: ExecutionContext): Promise +services.sharing.canDelete(object: string, recordId: string, context: ExecutionContext): Promise +services.sharing.canManageShares(object: string, recordId: string, context: ExecutionContext): Promise +services.sharing.grant(input: GrantShareInput, context: ExecutionContext): Promise +services.sharing.revoke(shareId: string, context: ExecutionContext, scope?: { object: string; recordId: string }): Promise +services.sharing.listShares(object: string, recordId: string, context: ExecutionContext): Promise ``` +Every method above **adjudicates access**, so each takes the complete +`resolveAuthzContext` envelope (`ExecutionContext`) — not a per-site subset +(#6523, applying the #6206 ruling). The six-field `SharingExecutionContext` +these signatures used to name was retired in #7218; pass the whole context you +were handed, unchanged. + ## Management authority (ADR-0111) `grant` / `revoke` / `listShares` are **management operations**, enforced in the @@ -86,7 +92,7 @@ export async function mayEditContract( ): Promise { return sharing.canEdit('contract', recordId, { userId: session.userId, - // `SharingExecutionContext` names the org `tenantId`; a session exposes the + // The execution context names the org `tenantId`; a session exposes the // same value as `organizationId` (the `session.tenantId` alias was removed in // v11, #3290). tenantId: session.organizationId, diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 24a2b20d4a..b8b0424120 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -455,6 +455,9 @@ This is a CODE-path API, not stored metadata, so — like `hook-register-empty-o ONE AUTHOR-REACHABLE SURFACE reaches this indirectly and is why it is not purely a code-side note: a saved report's `query.orderBy` (`sys_saved_report`) is forwarded verbatim into `engine.find` by `plugin-reports`, bypassing the ingress gate. A report authored to sort by a formula field used to run and return rows in an arbitrary order; it now fails loudly, with the remedy in the message. One further path is deliberately NOT a refusal: a nested `expand` sort raises this refusal inside `expandRelatedRecords`, whose pre-existing graceful-degradation `catch` swallows every expand failure and retains the raw foreign keys — so that path moves from silent to OBSERVABLE (a warning naming the field and the fix) rather than refusing. Reversing that backstop is a separate decision on all expand failure modes. #7095, #6994, #6924, #4226, #4256, #3821, ADR-0112. - Done when: No `engine.find` / `engine.findOne` call site sorts by a `formula` field, and no saved report's `query.orderBy` names one — grep your report definitions for an `orderBy` field whose object declares it as a `formula`, and denormalise it onto a stored column written when the source changes. A `summary` / rollup field needs no action: it has a real maintained column and sorts correctly. Reads complete with no `INVALID_SORT` naming a formula field, and no "Failed to expand relationship field" warning whose error text names one. +- **`sharing-execution-context-retired`** — `@objectstack/spec: the exported type `SharingExecutionContext` (`contracts/sharing-service`), and its re-export from @objectstack/plugin-sharing — the six-field context shape (`userId` / `tenantId` / `positions` / `permissions` / `systemPermissions` / `isSystem`) that sharing, approval and report enforcement signatures used to name` → `ExecutionContext` from `@objectstack/spec` — the complete `resolveAuthzContext` envelope the contracts have declared since #6523. Every one of the retired type's six fields exists on it under the same name and type, so a value that satisfied the old type already satisfies the envelope: only the annotation is rewritten, never the value + - Why not automatic: ADR-0049 enforce-or-remove, completing the #6206 ruling (2026-08-07: enforcement adjudicates on the WHOLE envelope, never a per-site subset). This type was the declared context parameter of 36 signatures across three contracts — `ISharingService` / `ISharingRuleService`, `IApprovalService`, `IReportService` — and it omitted four fields those gates need: `accessible_org_ids` (under the `group` tenancy posture this IS the Layer 0 wall, ADR-0105 D2), `org_user_ids`, `posture` (ADR-0095 D2) and `tabPermissions`. Its damage ran in the MIRROR direction of the share-link twin (#6430 / PR #6511): nothing trimmed the VALUES — the engine middleware always handed the whole context down — 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 (`const posture = (context as any).posture` in plugin-approvals' privileged-override gate). #6523 / PR #7068 converged the contracts, PR #7140 and PR #7206 re-annotated the four implementations, and this card removes the now-unreferenced declaration (#7070, #7218). Why this needs a ledger entry despite nothing in-repo referencing it: it is the `export-field-meta-constraints-retired` / `hook-context-session-roles-retired` disposition — a PUBLISHED TypeScript surface with no spec schema, so there is no `retiredKey()` tombstone and no parse rejection that could carry the prescription, and the ledger is the only channel that reaches an upgrader. Why D3 semantic and not a D2 conversion: nothing authored or stored changes shape. The name is only ever spelled inside a consumer's own TypeScript, so no `objectstack migrate meta` transform can reach it, and no `sys_metadata` row carries it. ADR-0049 / ADR-0087, #7218. + - Done when: No source of yours imports `SharingExecutionContext` from `@objectstack/spec` or `@objectstack/plugin-sharing`; each such import becomes `ExecutionContext` from `@objectstack/spec` and the build is green. tsc IS a sufficient detector here, unlike the optional-key retirements at this step: the name is gone outright, so every remaining reference is a hard resolution error rather than a silent `undefined`. ⚠️ Then check the direction tsc CANNOT see: widening an annotation never rejects a value, so an enforcement path that only ever received a hand-built six-field object still compiles and still under-adjudicates. Confirm each caller passes the context it was HANDED, unchanged, rather than a literal it assembled — and that any gate of yours reading `posture`, `accessible_org_ids`, `org_user_ids` or `tabPermissions` now reads them declared, with no `as any` in the path. --- diff --git a/packages/plugins/plugin-approvals/src/exec-context-annotation.pin.ts b/packages/plugins/plugin-approvals/src/exec-context-annotation.pin.ts index 5d017941f0..502cebae33 100644 --- a/packages/plugins/plugin-approvals/src/exec-context-annotation.pin.ts +++ b/packages/plugins/plugin-approvals/src/exec-context-annotation.pin.ts @@ -17,14 +17,26 @@ * in this package. Nothing would notice. This module is the one thing that * does — every declaration below is red exactly when a parameter narrows back. * - * HOW IT BITES: TypeScript's excess-property check on a FRESH object literal. - * `posture` (ADR-0095 D2), `accessible_org_ids` (ADR-0105 D2) and + * HOW IT BITES, part 1: TypeScript's excess-property check on a FRESH object + * literal. `posture` (ADR-0095 D2), `accessible_org_ids` (ADR-0105 D2) and * `org_user_ids` are fields of the envelope that the retired six-field shape * did not carry, so a literal naming them is rejected the moment the parameter * is annotated with anything that lacks them. Note this is the ONLY direction * that works: a `@ts-expect-error` asserting the reverse would be unsatisfied * and fail the build, because a narrow context IS assignable to a wide - * parameter — the boundary the retired type's own doc block records. + * parameter — see item 3 of the module doc on + * `@objectstack/spec/contracts/sharing-service` for that boundary. + * + * HOW IT BITES, part 2 (#7218): type IDENTITY against the retired shape itself. + * #7135's failure story was "the parameter narrows back to the six-field + * `SharingExecutionContext`" — a type that no longer exists, since #7218 + * deleted it from the contract surface once all three implementations had been + * re-annotated. Deleting the type does NOT delete the failure mode: the six + * fields can be re-declared here under any name, and the literal checks above + * only fire on the fields a given literal happens to spell. So the retired + * shape is kept below as a local SPECIMEN and each parameter is refuted + * against it. A re-narrowing is then red twice over, and neither check depends + * on the retired export coming back. * * WHY A `.pin.ts` AND NOT A `*.test.ts`: `packages/plugins/plugin-approvals/ * tsconfig.json` excludes `**\/*.test.ts` (measured on this card, and the same @@ -45,6 +57,46 @@ type AuthorizeFileReadContext = Parameters type OpenNodeRequestContext = Parameters[1]; type ListRequestsContext = Parameters[1]; +/** + * [#7218] The RETIRED six-field shape, kept here as a SPECIMEN — a deliberate + * COPY of the type `@objectstack/spec` exported as `SharingExecutionContext` + * until #7218 deleted it. Copied rather than imported on purpose: nothing may + * depend on the retired name again, and a local copy is what lets this pin keep + * naming the shape it refuses after the export is gone. + * + * ⛔ Not a vocabulary to reach for, and not exported. + */ +type RetiredSharingContextSpecimen = { + userId?: string; + tenantId?: string; + positions?: string[]; + permissions?: string[]; + systemPermissions?: string[]; + isSystem?: boolean; +}; + +/** 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 `false`. */ +type Refute = T; + +/** + * NEGATIVE, at the type level: no enforcement parameter IS the retired shape. + * Red the moment one is re-annotated with those six fields under any spelling + * — the failure #7135's pin told as "narrows back to `SharingExecutionContext`", + * restated so it no longer needs the deleted name to be checkable. + */ +type _NotTheRetiredShape = [ + Refute>, + Refute>, + Refute>, + Refute>, + Refute>, + Refute>, +]; + /** * Never called — every line below is a type-level assertion evaluated by * `tsc --noEmit`. The parameters are taken as arguments rather than read off a @@ -70,6 +122,13 @@ export function __pinApprovalsTakesTheFullEnvelope( openNodeRequest(undefined as never, { userId: 'u1', posture: 'MEMBER', accessible_org_ids: ['org_a'] }); listRequests(undefined, { userId: 'u1', org_user_ids: ['u1'] }); + // ── NEGATIVE: none of these parameters IS the retired six-field shape. ─── + // The tuple is all-`false` exactly when every `Refute` above holds; a + // parameter re-narrowed to the specimen makes its slot `true` and this + // assignment stops compiling. + const notTheRetiredShape: _NotTheRetiredShape = [false, false, false, false, false, false]; + void notTheRetiredShape; + // ── NEGATIVE: widening must not have degenerated into `any`. ───────────── // A parameter erased to `any` would swallow every positive above just as // happily, so the pin is only worth its weight if wrong input still fails. diff --git a/packages/plugins/plugin-reports/src/exec-context-annotation.pin.ts b/packages/plugins/plugin-reports/src/exec-context-annotation.pin.ts index 5bf8237ae3..0a7e0b1156 100644 --- a/packages/plugins/plugin-reports/src/exec-context-annotation.pin.ts +++ b/packages/plugins/plugin-reports/src/exec-context-annotation.pin.ts @@ -17,14 +17,26 @@ * in this package. Nothing would notice. This module is the one thing that * does — every declaration below is red exactly when a parameter narrows back. * - * HOW IT BITES: TypeScript's excess-property check on a FRESH object literal. - * `posture` (ADR-0095 D2), `accessible_org_ids` (ADR-0105 D2) and + * HOW IT BITES, part 1: TypeScript's excess-property check on a FRESH object + * literal. `posture` (ADR-0095 D2), `accessible_org_ids` (ADR-0105 D2) and * `org_user_ids` are fields of the envelope that the retired six-field shape * did not carry, so a literal naming them is rejected the moment the parameter * is annotated with anything that lacks them. Note this is the ONLY direction * that works: a `@ts-expect-error` asserting the reverse would be unsatisfied * and fail the build, because a narrow context IS assignable to a wide - * parameter — the boundary the retired type's own doc block records. + * parameter — see item 3 of the module doc on + * `@objectstack/spec/contracts/sharing-service` for that boundary. + * + * HOW IT BITES, part 2 (#7218): type IDENTITY against the retired shape itself. + * #7135's failure story was "the parameter narrows back to the six-field + * `SharingExecutionContext`" — a type that no longer exists, since #7218 + * deleted it from the contract surface once all three implementations had been + * re-annotated. Deleting the type does NOT delete the failure mode: the six + * fields can be re-declared here under any name, and the literal checks above + * only fire on the fields a given literal happens to spell. So the retired + * shape is kept below as a local SPECIMEN and each parameter is refuted + * against it. A re-narrowing is then red twice over, and neither check depends + * on the retired export coming back. * * WHY A `.pin.ts` AND NOT A `*.test.ts`: unlike its sibling packages, * `packages/plugins/plugin-reports/tsconfig.json` does NOT exclude @@ -53,6 +65,48 @@ type ListSchedulesContext = Parameters[1]; */ type ResolvedOwnerContext = NonNullable>>; +/** + * [#7218] The RETIRED six-field shape, kept here as a SPECIMEN — a deliberate + * COPY of the type `@objectstack/spec` exported as `SharingExecutionContext` + * until #7218 deleted it. Copied rather than imported on purpose: nothing may + * depend on the retired name again, and a local copy is what lets this pin keep + * naming the shape it refuses after the export is gone. + * + * ⛔ Not a vocabulary to reach for, and not exported. + */ +type RetiredSharingContextSpecimen = { + userId?: string; + tenantId?: string; + positions?: string[]; + permissions?: string[]; + systemPermissions?: string[]; + isSystem?: boolean; +}; + +/** 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 `false`. */ +type Refute = T; + +/** + * NEGATIVE, at the type level: neither the method parameters nor what the owner + * resolver hands back IS the retired shape. Red the moment one is re-annotated + * with those six fields under any spelling — the failure #7135's pin told as + * "narrows back to `SharingExecutionContext`", restated so it no longer needs + * the deleted name to be checkable. `ResolvedOwnerContext` is included because a + * scheduled run adjudicates on whatever the resolver's RETURN type says it has. + */ +type _NotTheRetiredShape = [ + Refute>, + Refute>, + Refute>, + Refute>, + Refute>, + Refute>, +]; + /** * Never called — every line below is a type-level assertion evaluated by * `tsc --noEmit`. The parameters are taken as arguments rather than read off a @@ -79,6 +133,13 @@ export function __pinReportsTakesTheFullEnvelope( const posture: ResolvedOwnerContext['posture'] = ownerContext.posture; void posture; + // ── NEGATIVE: none of these types IS the retired six-field shape. ──────── + // The tuple is all-`false` exactly when every `Refute` above holds; a + // parameter (or the resolver's return type) re-narrowed to the specimen makes + // its slot `true` and this assignment stops compiling. + const notTheRetiredShape: _NotTheRetiredShape = [false, false, false, false, false, false]; + void notTheRetiredShape; + // ── NEGATIVE: widening must not have degenerated into `any`. ───────────── // A parameter erased to `any` would swallow every positive above just as // happily, so the pin is only worth its weight if wrong input still fails. diff --git a/packages/plugins/plugin-sharing/src/exec-context-annotation.pin.ts b/packages/plugins/plugin-sharing/src/exec-context-annotation.pin.ts index 905c099f84..d569ebc410 100644 --- a/packages/plugins/plugin-sharing/src/exec-context-annotation.pin.ts +++ b/packages/plugins/plugin-sharing/src/exec-context-annotation.pin.ts @@ -17,14 +17,26 @@ * in this package. Nothing would notice. This module is the one thing that * does — every declaration below is red exactly when a parameter narrows back. * - * HOW IT BITES: TypeScript's excess-property check on a FRESH object literal. - * `posture` (ADR-0095 D2), `accessible_org_ids` (ADR-0105 D2) and + * HOW IT BITES, part 1: TypeScript's excess-property check on a FRESH object + * literal. `posture` (ADR-0095 D2), `accessible_org_ids` (ADR-0105 D2) and * `org_user_ids` are fields of the envelope that the retired six-field shape * did not carry, so a literal naming them is rejected the moment the parameter * is annotated with anything that lacks them. Note this is the ONLY direction * that works: a `@ts-expect-error` asserting the reverse would be unsatisfied * and fail the build, because a narrow context IS assignable to a wide - * parameter — the boundary `SharingExecutionContext`'s own doc block records. + * parameter — see item 3 of the module doc on + * `@objectstack/spec/contracts/sharing-service` for that boundary. + * + * HOW IT BITES, part 2 (#7218): type IDENTITY against the retired shape itself. + * #7136's failure story was "the parameter narrows back to + * `SharingExecutionContext`" — a type that no longer exists, since #7218 + * deleted it from the contract surface once all three implementations had been + * re-annotated. Deleting the type does NOT delete the failure mode: the six + * fields can be re-declared here under any name, and the literal checks above + * only fire on the fields a given literal happens to spell. So the retired + * shape is kept below as a local SPECIMEN and each parameter is refuted + * against it. A re-narrowing is then red twice over, and neither check depends + * on the retired export coming back. * * WHY A `.pin.ts` AND NOT A `*.test.ts`: `packages/plugins/plugin-sharing/ * tsconfig.json` excludes `**\/*.test.ts` (a measured TEST_DEBT of 3 in @@ -49,6 +61,45 @@ type DefineRuleContext = Parameters[1]; /** What the seam hands a test — the resolved envelope, not a projection of it. */ type SeamContext = Awaited>; +/** + * [#7218] The RETIRED six-field shape, kept here as a SPECIMEN — a deliberate + * COPY of the type `@objectstack/spec` exported as `SharingExecutionContext` + * until #7218 deleted it (and this package re-exported until the same card). + * Copied rather than imported on purpose: nothing may depend on the retired + * name again, and a local copy is what lets this pin keep naming the shape it + * refuses after the export is gone. + * + * ⛔ Not a vocabulary to reach for, and not exported. + */ +type RetiredSharingContextSpecimen = { + userId?: string; + tenantId?: string; + positions?: string[]; + permissions?: string[]; + systemPermissions?: string[]; + isSystem?: boolean; +}; + +/** 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 `false`. */ +type Refute = T; + +/** + * NEGATIVE, at the type level: no enforcement parameter IS the retired shape. + * Red the moment one is re-annotated with those six fields under any spelling + * — the failure #7136's pin told as "narrows back to `SharingExecutionContext`", + * restated so it no longer needs the deleted name to be checkable. + */ +type _NotTheRetiredShape = [ + Refute>, + Refute>, + Refute>, + Refute>, +]; + /** * Never called — every line below is a type-level assertion evaluated by * `tsc --noEmit`. The parameters are taken as arguments rather than read off a @@ -73,6 +124,13 @@ export function __pinEnforcementTakesTheFullEnvelope( const posture: SeamContext['posture'] = seamContext.posture; void posture; + // ── NEGATIVE: none of these parameters IS the retired six-field shape. ─── + // The tuple is `[false, false, false, false]` exactly when every `Refute` + // above holds; a parameter re-narrowed to the specimen makes its slot `true` + // and this assignment stops compiling. + const notTheRetiredShape: _NotTheRetiredShape = [false, false, false, false]; + void notTheRetiredShape; + // ── NEGATIVE: widening must not have degenerated into `any`. ───────────── // A parameter erased to `any` would swallow every positive above just as // happily, so the pin is only worth its weight if wrong input still fails. diff --git a/packages/plugins/plugin-sharing/src/index.ts b/packages/plugins/plugin-sharing/src/index.ts index ca248d4ba9..ffc40803dd 100644 --- a/packages/plugins/plugin-sharing/src/index.ts +++ b/packages/plugins/plugin-sharing/src/index.ts @@ -104,7 +104,6 @@ export type { IBusinessUnitGraphService, RecordShare, GrantShareInput, - SharingExecutionContext, ShareAccessLevel, RecordShareRecipientType, ShareSource, diff --git a/packages/spec/api-surface/contracts.json b/packages/spec/api-surface/contracts.json index 91c2bb4533..42f7ecf551 100644 --- a/packages/spec/api-surface/contracts.json +++ b/packages/spec/api-surface/contracts.json @@ -268,7 +268,6 @@ "ShareLinkExecutionContext (interface)", "ShareLinkPermission (type)", "ShareSource (type)", - "SharingExecutionContext (interface)", "SharingRuleEvaluationResult (interface)", "SharingRuleRecipientType (type)", "SharingRuleRow (interface)", diff --git a/packages/spec/export-origins/contracts.json b/packages/spec/export-origins/contracts.json index 7d66cef74b..71038c9b8b 100644 --- a/packages/spec/export-origins/contracts.json +++ b/packages/spec/export-origins/contracts.json @@ -268,7 +268,6 @@ "ShareLinkExecutionContext": "src/contracts/share-link-service.ts#ShareLinkExecutionContext (interface)", "ShareLinkPermission": "src/contracts/share-link-service.ts#ShareLinkPermission (type)", "ShareSource": "src/contracts/sharing-service.ts#ShareSource (type)", - "SharingExecutionContext": "src/contracts/sharing-service.ts#SharingExecutionContext (interface)", "SharingRuleEvaluationResult": "src/contracts/sharing-service.ts#SharingRuleEvaluationResult (interface)", "SharingRuleRecipientType": "src/contracts/sharing-service.ts#SharingRuleRecipientType (type)", "SharingRuleRow": "src/contracts/sharing-service.ts#SharingRuleRow (interface)", diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index c94a5b5603..c7a19279da 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -811,6 +811,13 @@ "migrationId": "engine-find-formula-order-by-refused", "toMajor": 17, "rationale": "#4226 / #4256 / #6994 closed the SORT axis at the REST ingress (`assertSortFieldsExist`, `400 INVALID_SORT`), which covers everything reaching `findData`: the list route, `POST /data/:object/query`, the export route and the RPC dispatcher. A caller reaching `engine.find()` / `engine.findOne()` DIRECTLY passed through none of it, and a `formula` ORDER BY there was dropped in silence. Measured on a real driver: `asc` and `desc` came back BYTE-IDENTICAL, in insertion order, under a success, with the rows carrying the very values they were asked to be ordered by. No column exists to order by (a formula is computed on read, so no driver materialises one), so the ORDER BY reached the driver, found nothing, and the unknown-column backstop returned the rows unordered.\n\nRuled 2026-08-10 on #7095: an ORDER BY the engine cannot apply is a 4xx with guidance prose at the public boundary, never a silent drop — the same direction as the analytics dataset refusal envelope and the #6924 sort-hint prescription. The engine's documented internal-caller tolerance (`assertProjectionFieldsExist`'s docblock) was to survive only behind a pinned internal path, and only if a MEASURED internal call site relied on it. The #7095 sweep of every in-tree `orderBy` reaching the engine directly — hooks, flows, reports, queue/job adapters, sharing, metadata loaders, expand sub-reads — found NONE: every hardcoded internal sort names a real stored column (`created_at`, `updated_at`, `version`, `priority`, `scheduled_for`, `started_at`, `next_run_at`, `recorded_at`, `id`), and no shipped object in the repo declares a `formula` field at all. So no internal path shipped, and there is no flag to opt back into the drop.\n\nThis is a CODE-path API, not stored metadata, so — like `hook-register-empty-object-target-refused` at this step — there is no `sys_metadata` row for the D2 chain to rewrite and the ledger entry is the notification channel. No mechanical rewrite exists in either direction: the platform cannot invent the stored column the remedy prescribes, and it must not sort post-hoc instead — `driver.find` has already applied `limit` / `offset`, so re-sorting after the formulas are evaluated would reorder an ARBITRARY PAGE, which looks correct on small result sets and is wrong the moment pagination is involved.\n\nONE AUTHOR-REACHABLE SURFACE reaches this indirectly and is why it is not purely a code-side note: a saved report's `query.orderBy` (`sys_saved_report`) is forwarded verbatim into `engine.find` by `plugin-reports`, bypassing the ingress gate. A report authored to sort by a formula field used to run and return rows in an arbitrary order; it now fails loudly, with the remedy in the message. One further path is deliberately NOT a refusal: a nested `expand` sort raises this refusal inside `expandRelatedRecords`, whose pre-existing graceful-degradation `catch` swallows every expand failure and retains the raw foreign keys — so that path moves from silent to OBSERVABLE (a warning naming the field and the fix) rather than refusing. Reversing that backstop is a separate decision on all expand failure modes. #7095, #6994, #6924, #4226, #4256, #3821, ADR-0112." + }, + { + "surface": "@objectstack/spec: the exported type `SharingExecutionContext` (`contracts/sharing-service`), and its re-export from @objectstack/plugin-sharing — the six-field context shape (`userId` / `tenantId` / `positions` / `permissions` / `systemPermissions` / `isSystem`) that sharing, approval and report enforcement signatures used to name", + "replacement": "`ExecutionContext` from `@objectstack/spec` — the complete `resolveAuthzContext` envelope the contracts have declared since #6523. Every one of the retired type's six fields exists on it under the same name and type, so a value that satisfied the old type already satisfies the envelope: only the annotation is rewritten, never the value", + "migrationId": "sharing-execution-context-retired", + "toMajor": 17, + "rationale": "ADR-0049 enforce-or-remove, completing the #6206 ruling (2026-08-07: enforcement adjudicates on the WHOLE envelope, never a per-site subset). This type was the declared context parameter of 36 signatures across three contracts — `ISharingService` / `ISharingRuleService`, `IApprovalService`, `IReportService` — and it omitted four fields those gates need: `accessible_org_ids` (under the `group` tenancy posture this IS the Layer 0 wall, ADR-0105 D2), `org_user_ids`, `posture` (ADR-0095 D2) and `tabPermissions`. Its damage ran in the MIRROR direction of the share-link twin (#6430 / PR #6511): nothing trimmed the VALUES — the engine middleware always handed the whole context down — 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 (`const posture = (context as any).posture` in plugin-approvals' privileged-override gate). #6523 / PR #7068 converged the contracts, PR #7140 and PR #7206 re-annotated the four implementations, and this card removes the now-unreferenced declaration (#7070, #7218). Why this needs a ledger entry despite nothing in-repo referencing it: it is the `export-field-meta-constraints-retired` / `hook-context-session-roles-retired` disposition — a PUBLISHED TypeScript surface with no spec schema, so there is no `retiredKey()` tombstone and no parse rejection that could carry the prescription, and the ledger is the only channel that reaches an upgrader. Why D3 semantic and not a D2 conversion: nothing authored or stored changes shape. The name is only ever spelled inside a consumer's own TypeScript, so no `objectstack migrate meta` transform can reach it, and no `sys_metadata` row carries it. ADR-0049 / ADR-0087, #7218." } ], "removed": [] @@ -1681,6 +1688,13 @@ "migrationId": "engine-find-formula-order-by-refused", "toMajor": 17, "rationale": "#4226 / #4256 / #6994 closed the SORT axis at the REST ingress (`assertSortFieldsExist`, `400 INVALID_SORT`), which covers everything reaching `findData`: the list route, `POST /data/:object/query`, the export route and the RPC dispatcher. A caller reaching `engine.find()` / `engine.findOne()` DIRECTLY passed through none of it, and a `formula` ORDER BY there was dropped in silence. Measured on a real driver: `asc` and `desc` came back BYTE-IDENTICAL, in insertion order, under a success, with the rows carrying the very values they were asked to be ordered by. No column exists to order by (a formula is computed on read, so no driver materialises one), so the ORDER BY reached the driver, found nothing, and the unknown-column backstop returned the rows unordered.\n\nRuled 2026-08-10 on #7095: an ORDER BY the engine cannot apply is a 4xx with guidance prose at the public boundary, never a silent drop — the same direction as the analytics dataset refusal envelope and the #6924 sort-hint prescription. The engine's documented internal-caller tolerance (`assertProjectionFieldsExist`'s docblock) was to survive only behind a pinned internal path, and only if a MEASURED internal call site relied on it. The #7095 sweep of every in-tree `orderBy` reaching the engine directly — hooks, flows, reports, queue/job adapters, sharing, metadata loaders, expand sub-reads — found NONE: every hardcoded internal sort names a real stored column (`created_at`, `updated_at`, `version`, `priority`, `scheduled_for`, `started_at`, `next_run_at`, `recorded_at`, `id`), and no shipped object in the repo declares a `formula` field at all. So no internal path shipped, and there is no flag to opt back into the drop.\n\nThis is a CODE-path API, not stored metadata, so — like `hook-register-empty-object-target-refused` at this step — there is no `sys_metadata` row for the D2 chain to rewrite and the ledger entry is the notification channel. No mechanical rewrite exists in either direction: the platform cannot invent the stored column the remedy prescribes, and it must not sort post-hoc instead — `driver.find` has already applied `limit` / `offset`, so re-sorting after the formulas are evaluated would reorder an ARBITRARY PAGE, which looks correct on small result sets and is wrong the moment pagination is involved.\n\nONE AUTHOR-REACHABLE SURFACE reaches this indirectly and is why it is not purely a code-side note: a saved report's `query.orderBy` (`sys_saved_report`) is forwarded verbatim into `engine.find` by `plugin-reports`, bypassing the ingress gate. A report authored to sort by a formula field used to run and return rows in an arbitrary order; it now fails loudly, with the remedy in the message. One further path is deliberately NOT a refusal: a nested `expand` sort raises this refusal inside `expandRelatedRecords`, whose pre-existing graceful-degradation `catch` swallows every expand failure and retains the raw foreign keys — so that path moves from silent to OBSERVABLE (a warning naming the field and the fix) rather than refusing. Reversing that backstop is a separate decision on all expand failure modes. #7095, #6994, #6924, #4226, #4256, #3821, ADR-0112." + }, + { + "surface": "@objectstack/spec: the exported type `SharingExecutionContext` (`contracts/sharing-service`), and its re-export from @objectstack/plugin-sharing — the six-field context shape (`userId` / `tenantId` / `positions` / `permissions` / `systemPermissions` / `isSystem`) that sharing, approval and report enforcement signatures used to name", + "replacement": "`ExecutionContext` from `@objectstack/spec` — the complete `resolveAuthzContext` envelope the contracts have declared since #6523. Every one of the retired type's six fields exists on it under the same name and type, so a value that satisfied the old type already satisfies the envelope: only the annotation is rewritten, never the value", + "migrationId": "sharing-execution-context-retired", + "toMajor": 17, + "rationale": "ADR-0049 enforce-or-remove, completing the #6206 ruling (2026-08-07: enforcement adjudicates on the WHOLE envelope, never a per-site subset). This type was the declared context parameter of 36 signatures across three contracts — `ISharingService` / `ISharingRuleService`, `IApprovalService`, `IReportService` — and it omitted four fields those gates need: `accessible_org_ids` (under the `group` tenancy posture this IS the Layer 0 wall, ADR-0105 D2), `org_user_ids`, `posture` (ADR-0095 D2) and `tabPermissions`. Its damage ran in the MIRROR direction of the share-link twin (#6430 / PR #6511): nothing trimmed the VALUES — the engine middleware always handed the whole context down — 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 (`const posture = (context as any).posture` in plugin-approvals' privileged-override gate). #6523 / PR #7068 converged the contracts, PR #7140 and PR #7206 re-annotated the four implementations, and this card removes the now-unreferenced declaration (#7070, #7218). Why this needs a ledger entry despite nothing in-repo referencing it: it is the `export-field-meta-constraints-retired` / `hook-context-session-roles-retired` disposition — a PUBLISHED TypeScript surface with no spec schema, so there is no `retiredKey()` tombstone and no parse rejection that could carry the prescription, and the ledger is the only channel that reaches an upgrader. Why D3 semantic and not a D2 conversion: nothing authored or stored changes shape. The name is only ever spelled inside a consumer's own TypeScript, so no `objectstack migrate meta` transform can reach it, and no `sys_metadata` row carries it. ADR-0049 / ADR-0087, #7218." } ], "removed": [] diff --git a/packages/spec/src/contracts/approval-service.ts b/packages/spec/src/contracts/approval-service.ts index 0fdefb28ed..e91133f0ad 100644 --- a/packages/spec/src/contracts/approval-service.ts +++ b/packages/spec/src/contracts/approval-service.ts @@ -18,11 +18,12 @@ // [#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. +// context shape this contract used to borrow from `sharing-service` +// (`SharingExecutionContext`, retired in #7218 once every implementation had +// been re-annotated). 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 item 3 of the module doc in +// `./sharing-service.js` for the boundary and the measured consequence. import type { ExecutionContext } from '../kernel/execution-context.zod.js'; /** diff --git a/packages/spec/src/contracts/report-service.ts b/packages/spec/src/contracts/report-service.ts index ff21ec204d..a944f54eac 100644 --- a/packages/spec/src/contracts/report-service.ts +++ b/packages/spec/src/contracts/report-service.ts @@ -16,8 +16,9 @@ // [#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 +// than the six-field context shape this contract used to borrow from +// `sharing-service` (`SharingExecutionContext`, retired in #7218 once every +// implementation had been re-annotated). See item 3 of the module doc in // `./sharing-service.js` for the boundary. import type { ExecutionContext } from '../kernel/execution-context.zod.js'; diff --git a/packages/spec/src/contracts/sharing-service.test.ts b/packages/spec/src/contracts/sharing-service.test.ts index bfc02060ea..da24604597 100644 --- a/packages/spec/src/contracts/sharing-service.test.ts +++ b/packages/spec/src/contracts/sharing-service.test.ts @@ -6,7 +6,6 @@ import type { ISharingRuleService, ISharingService, RecordShareRecipientType, - SharingExecutionContext, SharingRuleRecipientType, SharingWriteVerdict, } from './sharing-service'; @@ -157,7 +156,7 @@ describe('[#5125] ISharingService write-gate bypass documentation parity', () => * * The interface declared `organizationId?` and `tenantId?` side by side with no * doc saying which one carries the caller's active organization. The single - * in-repo producer filled `organizationId` from a `SharingExecutionContext` + * in-repo producer filled `organizationId` from a six-field sharing context * that only has `tenantId`, so the read was structurally always `null`; the * real consumer (cloud `security-enterprise`) reads `organizationId`, saw * `null`, and skipped tenant isolation. Both ends were "contract-compliant" and @@ -505,7 +504,8 @@ describe('[#6428] ISharingService tri-state write verdict', () => { * #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 + * PR #6511). `SharingExecutionContext` (exported from `sharing-service.ts` + * until #7218 retired it) 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 @@ -533,18 +533,44 @@ describe('[#6428] ISharingService tri-state write verdict', () => { * 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. + * which was TS2353 at a call site stating a trimmed envelope; (3) that the + * narrow shape stays RETIRED — #7218 deleted the exported type once every + * implementation had been re-annotated, and the specimen below keeps the + * refusal enforceable so the convergence cannot be undone by re-declaring the + * six-field subset under any name. * * 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. + * is no `@ts-expect-error` asserting that a six-field context 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. */ +/** + * [#7218] The RETIRED six-field shape, kept HERE as a SPECIMEN. + * + * `SharingExecutionContext` was exported from `./sharing-service.ts` until + * #7218 deleted it. This local declaration is a deliberate COPY, not an import + * — nothing may depend on the retired export again, and copying is what makes + * the deletion permanent while the pins below stay meaningful. Without it the + * `Refute` assertions in the first case would have had nothing to refute and + * would have been dropped, which is the quiet way a convergence gets undone: + * re-declaring these six fields under a new name is exactly the per-site + * subset the #6206 ruling removed, and this type is what makes that re-narrowing + * red instead of invisible. + * + * ⛔ Not a vocabulary to reach for, and not to be exported from this file. + */ +type RetiredSharingContextSpecimen = { + userId?: string; + tenantId?: string; + positions?: string[]; + permissions?: string[]; + systemPermissions?: string[]; + isSystem?: boolean; +}; + 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 @@ -564,10 +590,13 @@ describe('[#6523] sharing / approval / report enforcement takes the full Executi Assert>, Assert>, Assert>, - // …and none of them is the six-field twin any more. - Refute>, - Refute>, - Refute>, + // …and none of them is the six-field twin any more — measured against + // the retired shape itself (the specimen above), so re-declaring that + // subset under a fresh name is caught, not just re-importing the name + // #7218 deleted. + Refute>, + Refute>, + Refute>, ]; const pinned: _Pins = [true, true, true, true, true, true, false, false, false]; expect(pinned).toHaveLength(9); @@ -578,7 +607,7 @@ describe('[#6523] sharing / approval / report enforcement takes the full Executi // — `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 + // '…'", as measured against the six-field shape). That is precisely the wall // `plugin-approvals` climbed with `(context as any).posture`. const seen: Array> = []; const buildReadFilter: ISharingService['buildReadFilter'] = async (object, context) => { @@ -628,13 +657,17 @@ describe('[#6523] sharing / approval / report enforcement takes the full Executi }); }); - 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; + it('keeps the narrow twin RETIRED — and states why tsc cannot be the wall', () => { + // [#7218] The twin is gone from the contract surface: nothing in + // `packages/spec` declares it and `@objectstack/plugin-sharing` no longer + // re-exports it. What survives is the specimen above, and it exists to keep + // this refusal enforceable — re-narrowing an enforcement parameter to these + // six fields (under ANY name) turns the `Refute` pins in the first case + // red. Widening the specimen field by field instead would rebuild the + // per-site subset the ruling removed, so its shape is pinned too. + type SpecimenKeys = keyof RetiredSharingContextSpecimen; type _ShapeUnchanged = Assert< - Eq + Eq >; const shapeUnchanged: _ShapeUnchanged = true; @@ -642,8 +675,10 @@ describe('[#6523] sharing / approval / report enforcement takes the full Executi // 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 }; + // be unsatisfied and fail the build. Deleting the exported type changed + // nothing about that: retirement removes the READY-MADE narrow spelling, + // it does not make narrowing a compile error. + const residue: RetiredSharingContextSpecimen = { userId: 'usr_1', isSystem: false }; const widened: ExecutionContext = residue; expect(shapeUnchanged).toBe(true); expect(widened.userId).toBe('usr_1'); diff --git a/packages/spec/src/contracts/sharing-service.ts b/packages/spec/src/contracts/sharing-service.ts index 9534792c59..bf0320a649 100644 --- a/packages/spec/src/contracts/sharing-service.ts +++ b/packages/spec/src/contracts/sharing-service.ts @@ -38,9 +38,25 @@ * 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. + * applying the #6206 ruling). The six-field shape those parameters used + * to name (`SharingExecutionContext`, exported from here until #7218 + * retired it) omitted `accessible_org_ids` (under the `group` tenancy + * posture this IS the Layer 0 wall, ADR-0105 D2), `org_user_ids`, + * `posture` (ADR-0095 D2) and `tabPermissions` — so an implementation + * could not READ what it had been handed without casting out of its own + * contract (the measured specimen: `const posture = (context as + * any).posture` in `plugin-approvals`' privileged-override gate). + * + * ⛔ **tsc cannot police this, and no pin should pretend otherwise.** + * Structural subtyping makes a six-field context assignable to + * {@link ExecutionContext} — every field exists there with a compatible + * type and nothing in the wider type is required — so re-narrowing a + * parameter still COMPILES, and an `@ts-expect-error` asserting the + * reverse would be unsatisfied and fail the build. What holds the line is + * the declared parameter type here (any narrowing becomes visible AT THE + * CALL SITE) plus the type-identity pins in + * `sharing-service.test.ts` and the three `exec-context-annotation.pin.ts` + * files in `plugin-sharing` / `plugin-approvals` / `plugin-reports`. * * Manual share CRUD is exposed via `grant()`, `revoke()`, and * `listShares()`. The REST layer wires these to @@ -57,7 +73,7 @@ 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 +// no per-site subset contracts). See item 3 of the module doc above for the // boundary that draws and why. import type { ExecutionContext } from '../kernel/execution-context.zod.js'; @@ -127,82 +143,6 @@ export interface GrantShareInput { reason?: string; } -/** - * ⛔ 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; - positions?: string[]; - permissions?: string[]; - /** - * [ADR-0111] Capability names the caller holds (`manage_sharing`, …) — - * resolved by `resolveAuthzContext` alongside `permissions` (which carries - * permission-set NAMES, not capabilities). Consulted by the sharing-rule - * management gate; absent → the caller holds no capabilities (fail closed). - */ - systemPermissions?: string[]; - isSystem?: boolean; -} - /** * [#6428] The verdict a per-record WRITE gate returns. THREE states, because * two cannot say what this service actually knows: diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index f8780d3caa..bcade358b2 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -3491,6 +3491,57 @@ const step17: MigrationStep = { + '`INVALID_SORT` naming a formula field, and no "Failed to expand relationship field" ' + 'warning whose error text names one.', }, + { + id: 'sharing-execution-context-retired', + surface: + '@objectstack/spec: the exported type `SharingExecutionContext` ' + + '(`contracts/sharing-service`), and its re-export from ' + + '@objectstack/plugin-sharing — the six-field context shape ' + + '(`userId` / `tenantId` / `positions` / `permissions` / `systemPermissions` / ' + + '`isSystem`) that sharing, approval and report enforcement signatures used to name', + replacement: + '`ExecutionContext` from `@objectstack/spec` — the complete ' + + '`resolveAuthzContext` envelope the contracts have declared since #6523. Every one ' + + 'of the retired type\'s six fields exists on it under the same name and type, so a ' + + 'value that satisfied the old type already satisfies the envelope: only the ' + + 'annotation is rewritten, never the value', + reason: + 'ADR-0049 enforce-or-remove, completing the #6206 ruling (2026-08-07: enforcement ' + + 'adjudicates on the WHOLE envelope, never a per-site subset). This type was the ' + + 'declared context parameter of 36 signatures across three contracts — ' + + '`ISharingService` / `ISharingRuleService`, `IApprovalService`, `IReportService` — ' + + 'and it omitted four fields those gates need: `accessible_org_ids` (under the ' + + '`group` tenancy posture this IS the Layer 0 wall, ADR-0105 D2), `org_user_ids`, ' + + '`posture` (ADR-0095 D2) and `tabPermissions`. Its damage ran in the MIRROR ' + + 'direction of the share-link twin (#6430 / PR #6511): nothing trimmed the VALUES — ' + + "the engine middleware always handed the whole context down — 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 (`const posture = (context as any).posture` ' + + "in plugin-approvals' privileged-override gate). #6523 / PR #7068 converged the " + + 'contracts, PR #7140 and PR #7206 re-annotated the four implementations, and this ' + + 'card removes the now-unreferenced declaration (#7070, #7218). ' + + 'Why this needs a ledger entry despite nothing in-repo referencing it: it is the ' + + '`export-field-meta-constraints-retired` / `hook-context-session-roles-retired` ' + + 'disposition — a PUBLISHED TypeScript surface with no spec schema, so there is no ' + + '`retiredKey()` tombstone and no parse rejection that could carry the prescription, ' + + 'and the ledger is the only channel that reaches an upgrader. ' + + 'Why D3 semantic and not a D2 conversion: nothing authored or stored changes shape. ' + + 'The name is only ever spelled inside a consumer\'s own TypeScript, so no ' + + '`objectstack migrate meta` transform can reach it, and no `sys_metadata` row ' + + 'carries it. ADR-0049 / ADR-0087, #7218.', + acceptanceCriteria: + 'No source of yours imports `SharingExecutionContext` from `@objectstack/spec` or ' + + '`@objectstack/plugin-sharing`; each such import becomes `ExecutionContext` from ' + + '`@objectstack/spec` and the build is green. tsc IS a sufficient detector here, ' + + 'unlike the optional-key retirements at this step: the name is gone outright, so ' + + 'every remaining reference is a hard resolution error rather than a silent ' + + '`undefined`. ⚠️ Then check the direction tsc CANNOT see: widening an annotation ' + + 'never rejects a value, so an enforcement path that only ever received a hand-built ' + + 'six-field object still compiles and still under-adjudicates. Confirm each caller ' + + 'passes the context it was HANDED, unchanged, rather than a literal it assembled — ' + + 'and that any gate of yours reading `posture`, `accessible_org_ids`, `org_user_ids` ' + + 'or `tabPermissions` now reads them declared, with no `as any` in the path.', + }, ], };