From 7ba42940dfe4196f67e58a454f1112bd7f44f7f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 06:29:27 +0000 Subject: [PATCH 1/2] refactor(spec,plugin-sharing)!: retire the exported `SharingExecutionContext` type (#7218) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deferred deletion recorded when #7070 split the convergence in two. #6523 / PR #7068 converged 36 contract signatures onto the full `resolveAuthzContext` envelope (the #6206 ruling: enforcement adjudicates on the whole envelope, never a per-site subset); PR #7140 (identity: plugin-sharing, plugin-audit) and PR #7206 (services: plugin-approvals, plugin-reports) re-annotated the implementations. Both halves landed with the type still exported because it is DEFINED in `packages/spec`, whose retirement is the spec seat's to make. Nothing declares it any more, so it goes — from `@objectstack/spec` and from `@objectstack/plugin-sharing`, which re-exported it. Public-surface removal on two packages, so both generated snapshots are regenerated (`api-surface/contracts.json`, `export-origins/contracts.json`), and the changeset carries the callout plus an ADR-0087 D3 semantic entry (`sharing-execution-context-retired`): a published TypeScript surface with no spec schema has no `retiredKey()` tombstone and no parse rejection, so the ledger is the only channel that reaches an upgrader. The pins needed the deliberate pass, not a find-and-delete. All three `exec-context-annotation.pin.ts` files told their failure story as "the parameter narrows back to `SharingExecutionContext`" — a type the deletion removes, while the failure mode it named survives: the six fields can be re-declared under any name, and the fresh-literal excess-property checks fire only on the fields a given literal happens to spell. Each pin now keeps the retired shape as a local, non-exported SPECIMEN and refutes every enforcement parameter against it by type identity, so a re-narrowing is red twice over and neither check depends on the retired export coming back. `packages/spec/src/contracts/sharing-service.test.ts` is re-anchored the same way, and its "twin unchanged in shape" case becomes "twin stays retired". The narrative the retired type's doc block carried — the measured `(context as any).posture` specimen, and why tsc cannot police narrowing at all — moves to the module doc of `contracts/sharing-service`, which the contracts and all three pins now point at. No runtime behaviour changes: the type was erased at compile time and no signature's accepted shape moved. Claude-Session: https://claude.ai/code/session_01ArkxxyvoyPaDhHiX5KhQBi Co-authored-by: os-zhuang Co-authored-by: Claude Opus 5 --- .../retire-sharing-execution-context.md | 52 +++++++++ .../runtime-services/sharing-service.mdx | 22 ++-- docs/protocol-upgrade-guide.md | 3 + .../src/exec-context-annotation.pin.ts | 65 +++++++++++- .../src/exec-context-annotation.pin.ts | 67 +++++++++++- .../src/exec-context-annotation.pin.ts | 64 ++++++++++- packages/plugins/plugin-sharing/src/index.ts | 1 - packages/spec/api-surface/contracts.json | 1 - packages/spec/export-origins/contracts.json | 1 - packages/spec/spec-changes.json | 14 +++ .../spec/src/contracts/approval-service.ts | 11 +- packages/spec/src/contracts/report-service.ts | 5 +- .../src/contracts/sharing-service.test.ts | 77 ++++++++++---- .../spec/src/contracts/sharing-service.ts | 100 ++++-------------- packages/spec/src/migrations/registry.ts | 51 +++++++++ 15 files changed, 406 insertions(+), 128 deletions(-) create mode 100644 .changeset/retire-sharing-execution-context.md 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 341a88b3fa..4ea0191d19 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -443,6 +443,9 @@ No mechanical rewrite exists, in either direction. The refused values carry no r This is a RUNTIME registration API, not stored metadata, so — like `hook-context-session-roles-retired` at this step — there is no `sys_metadata` row for the D2 chain to rewrite and the ledger entry is the notification channel. One metadata surface reaches it INDIRECTLY and is the reason this is not purely a code-side note: a `record-change` flow's start node forwards `config.objectName` verbatim into `registerHook` (`RecordChangeTrigger.start`), so a flow authored with a blank `objectName` used to bind a trigger to EVERY object in the tenant. It now fails to bind instead, loudly — the automation engine's per-flow bind guard warns and the `kernel:bootstrapped` binding audit re-reports it — which is the correct end state, but it is an observable change for that flow. #6573, #4281, #4001, #5928, ADR-0078. - Done when: No `registerHook` call site passes an empty `object` target, and none passes an `excludeObjects` list covering every name in its `object` list. Every `record-change` flow start node declares a non-blank `config.objectName`, or omits the key if the flow is genuinely meant to fire on every object. Boot completes with no "[ObjectQL] Hook ... declares an empty `object` target" throw and no "[record-change] ... not bound" warning naming a flow you expect to fire. +- **`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 3f7c4a8ee4..c67f115ea5 100644 --- a/packages/spec/api-surface/contracts.json +++ b/packages/spec/api-surface/contracts.json @@ -263,7 +263,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 8dc03fc9cf..bf2a568c50 100644 --- a/packages/spec/export-origins/contracts.json +++ b/packages/spec/export-origins/contracts.json @@ -263,7 +263,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 aa55208261..abbf0bb249 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -798,6 +798,13 @@ "migrationId": "hook-register-empty-object-target-refused", "toMajor": 17, "rationale": "#4281 ruled that an empty hook target is not \"no target\" and closed the shape at the two METADATA doors — `HookSchema.object`'s refine and `hook-binder.ts`'s `normalizeObjects`. `engine.registerHook`, the CODE door, goes through neither, so all three spellings still registered, each producing a defect the author did not write: `''` is FALSY, so the allow face was skipped entirely and the entry became a GLOBAL hook (#4281's headline failure mode — blank intent taking the broadest possible blast radius); `[]` and `['']` are truthy but admit no object name, so the entry could never fire. #5928 then added the `excludeObjects` face, which brought a fourth shape reached by arithmetic rather than by one bad name: an `object` list every member of which is also excluded admits nothing, so that entry can never fire either. All four are ADR-0078 silently-inert declarations, and all four are now refused at REGISTRATION.\n\nNo mechanical rewrite exists, in either direction. The refused values carry no recoverable intent — `object: ''` could have meant `'*'` (what it actually did) or a specific object name the author forgot to fill in, and those are opposite registrations; choosing between them is a judgment the chain cannot make. Nor could the MATCHING read be changed instead: teaching the matcher that `''` is an unmatchable name would silently convert a hook firing on every object into one firing on none — the same class of defect pointing the other way, which is why #5928 declined to do it in passing.\n\nThis is a RUNTIME registration API, not stored metadata, so — like `hook-context-session-roles-retired` at this step — there is no `sys_metadata` row for the D2 chain to rewrite and the ledger entry is the notification channel. One metadata surface reaches it INDIRECTLY and is the reason this is not purely a code-side note: a `record-change` flow's start node forwards `config.objectName` verbatim into `registerHook` (`RecordChangeTrigger.start`), so a flow authored with a blank `objectName` used to bind a trigger to EVERY object in the tenant. It now fails to bind instead, loudly — the automation engine's per-flow bind guard warns and the `kernel:bootstrapped` binding audit re-reports it — which is the correct end state, but it is an observable change for that flow. #6573, #4281, #4001, #5928, ADR-0078." + }, + { + "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": [] @@ -1655,6 +1662,13 @@ "migrationId": "hook-register-empty-object-target-refused", "toMajor": 17, "rationale": "#4281 ruled that an empty hook target is not \"no target\" and closed the shape at the two METADATA doors — `HookSchema.object`'s refine and `hook-binder.ts`'s `normalizeObjects`. `engine.registerHook`, the CODE door, goes through neither, so all three spellings still registered, each producing a defect the author did not write: `''` is FALSY, so the allow face was skipped entirely and the entry became a GLOBAL hook (#4281's headline failure mode — blank intent taking the broadest possible blast radius); `[]` and `['']` are truthy but admit no object name, so the entry could never fire. #5928 then added the `excludeObjects` face, which brought a fourth shape reached by arithmetic rather than by one bad name: an `object` list every member of which is also excluded admits nothing, so that entry can never fire either. All four are ADR-0078 silently-inert declarations, and all four are now refused at REGISTRATION.\n\nNo mechanical rewrite exists, in either direction. The refused values carry no recoverable intent — `object: ''` could have meant `'*'` (what it actually did) or a specific object name the author forgot to fill in, and those are opposite registrations; choosing between them is a judgment the chain cannot make. Nor could the MATCHING read be changed instead: teaching the matcher that `''` is an unmatchable name would silently convert a hook firing on every object into one firing on none — the same class of defect pointing the other way, which is why #5928 declined to do it in passing.\n\nThis is a RUNTIME registration API, not stored metadata, so — like `hook-context-session-roles-retired` at this step — there is no `sys_metadata` row for the D2 chain to rewrite and the ledger entry is the notification channel. One metadata surface reaches it INDIRECTLY and is the reason this is not purely a code-side note: a `record-change` flow's start node forwards `config.objectName` verbatim into `registerHook` (`RecordChangeTrigger.start`), so a flow authored with a blank `objectName` used to bind a trigger to EVERY object in the tenant. It now fails to bind instead, loudly — the automation engine's per-flow bind guard warns and the `kernel:bootstrapped` binding audit re-reports it — which is the correct end state, but it is an observable change for that flow. #6573, #4281, #4001, #5928, ADR-0078." + }, + { + "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 34f721b68e..b67db53594 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 cbe7649c17..fcb2754def 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -3405,6 +3405,57 @@ const step17: MigrationStep = { + '"[ObjectQL] Hook ... declares an empty `object` target" throw and no ' + '"[record-change] ... not bound" warning naming a flow you expect to fire.', }, + { + 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.', + }, ], }; From 922b8009d54a40a643b44d6c200d96658e5c8988 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 08:53:03 +0000 Subject: [PATCH 2/2] chore(spec): regenerate ledger artifacts after merging main The textual merge kept this branch's side of `spec-changes.json` and `docs/protocol-upgrade-guide.md`, which predate PR #7322's `action-global-nav-location-removed` conversion. Regenerated from the merged registry so both that entry and this branch's `sharing-execution-context-retired` semantic entry project into the artifacts. Generated files, never hand-merged. Claude-Session: https://claude.ai/code/session_01ArkxxyvoyPaDhHiX5KhQBi Co-authored-by: os-zhuang Co-authored-by: Claude Opus 5 --- docs/protocol-upgrade-guide.md | 3 +++ packages/spec/spec-changes.json | 12 ++++++++++++ 2 files changed, 15 insertions(+) diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 4ea0191d19..e58b7a4a9b 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -236,6 +236,8 @@ The same descriptor loses a key in this step, and the pairing is the point (#674 The plugin manifest loses its whole `loading` block in this step (#4914, ADR-0049, maintainer ruling 2026-08-04) — the same enforce-or-remove question asked of a block rather than a key, and answered REMOVE on measurement: every reference to `manifest.loading.*` in objectstack, cloud and objectui lived inside `packages/spec` itself, so a full loading policy parsed, entered the manifest, and configured nothing. The reason it outranked ordinary inert-key cleanup is that one of its members was `sandboxing`, declaring process / vm / iframe / web-worker isolation and a service ACL: an inert SECURITY control is worse than an absent one, because an author (very often an AI, ADR-0033) reads the vocabulary as proof the isolation exists and stops looking. Hot reload was a two-source defect on top of that — the retired `PluginHotReloadSchema` was the dead one of two vocabularies, and the ruling converges on the live one, `HotReloadConfigSchema`, which `HotReloadManager` actually reads and which is KEPT unenforced as the starting point for a separate future decision. Like `isAsync`, its prescription is a semantic entry rather than a conversion: a manifest is not a stack collection, so `os migrate meta` has no seam at which to rewrite one. +The action LOCATION vocabulary loses `global_nav` in this step (#6888, ADR-0049, maintainer ruling 2026-08-09). It was declared from the day `ACTION_LOCATIONS` was written and no product surface ever served it: the console command palette composes its groups from nav items, objects, dashboards, pages, reports, recent items and record search, and reads no action metadata at all — so an action declaring this location never reached a user. What lifts it above ordinary inert-declaration cleanup is that the authoring tool PROMISED the surface: the Studio designer previewed a mock `⌘K · Command palette` frame for exactly this value, so an author (very often an AI, ADR-0033) declared it, watched it "render", shipped it, and got nothing — the ADR-0078 shape arriving through a location vocabulary rather than through a missing key. It was retired rather than implemented because the demand evidence is empty: no user has asked for command-palette actions and the only two declarers were our own showcase corpus, so wiring the palette would have been capability expansion with no pull. This is an enum VALUE, not a key, so — as with `crypto.hash` and the two aggregate functions above — there is no `retiredKey()` tombstone: the enum error map carries the prescription, keyed on the received value so only the spelling that used to be legal is told it "was removed". The conversion strips the value from `action.locations` and KEEPS the key even when the array empties, because on this surface `locations: []` and an absent `locations` are different declarations: the empty array is the documented headless shape (callable over REST/MCP/AI, capability gate and audit trail intact), while an absent key means nobody placed the action — which is what `packages/lint`'s `action-no-placement` warns about. An object-less action, whose only reason for declaring `global_nav` was that it has no row and no record header to render on, is therefore migrated to the declaration it always meant. + ### Mechanical (applied for you) | Conversion | Surface | Change | Load window | @@ -295,6 +297,7 @@ The plugin manifest loses its whole `loading` block in this step (#4914, ADR-004 | `page-structure-inert-keys-removed` | `page.component.page:header.icon / page.component.page:card.actions` | page:header prop 'icon' and page:card prop 'actions' removed (#6946 — neither has a renderer read point in objectui; the header resolves icons per action and the card renders title/children/footer only) | retired — `migrate meta` only | | `record-details-layout-removed` | `page.component.record:details.layout` | record:details component prop 'layout' removed (#6946 — the declared auto|custom modes were never implemented; the renderer branches only on inline|compact, values the schema never permitted, so both legal values selected nothing) | retired — `migrate meta` only | | `app-hidden-to-unpublished` | `app.hidden` | stored app publish gate 'hidden' → '_unpublished' (#4829, ADR-0045 amended — `hidden` carried BOTH the publish gate and 'keep out of the App Switcher', so the built-in Account app was withheld from every non-builder; the gate is now the machine-managed `_unpublished`, and `hidden` is navigation presentation only, never an access gate. Stored rows only — an authored `hidden: true` is left untouched) | retired — `migrate meta` only | +| `action-global-nav-location-removed` | `action.locations[]` | action location 'global_nav' removed (#6888 — no running-app surface rendered it; the ⌘K palette reads no action metadata, while the Studio designer previewed a command-palette frame for it. The value is stripped and the key kept, so an action left with no location becomes the documented headless shape `locations: []`) | retired — `migrate meta` only | ### Semantic (delegated to you, with acceptance criteria) diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index abbf0bb249..7e85310997 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -397,6 +397,12 @@ "to": "stored app publish gate 'hidden' → '_unpublished' (#4829, ADR-0045 amended — `hidden` carried BOTH the publish gate and 'keep out of the App Switcher', so the built-in Account app was withheld from every non-builder; the gate is now the machine-managed `_unpublished`, and `hidden` is navigation presentation only, never an access gate. Stored rows only — an authored `hidden: true` is left untouched)", "conversionId": "app-hidden-to-unpublished", "toMajor": 17 + }, + { + "surface": "action.locations[]", + "to": "action location 'global_nav' removed (#6888 — no running-app surface rendered it; the ⌘K palette reads no action metadata, while the Studio designer previewed a command-palette frame for it. The value is stripped and the key kept, so an action left with no location becomes the documented headless shape `locations: []`)", + "conversionId": "action-global-nav-location-removed", + "toMajor": 17 } ], "migrated": [ @@ -1331,6 +1337,12 @@ "to": "stored app publish gate 'hidden' → '_unpublished' (#4829, ADR-0045 amended — `hidden` carried BOTH the publish gate and 'keep out of the App Switcher', so the built-in Account app was withheld from every non-builder; the gate is now the machine-managed `_unpublished`, and `hidden` is navigation presentation only, never an access gate. Stored rows only — an authored `hidden: true` is left untouched)", "conversionId": "app-hidden-to-unpublished", "toMajor": 17 + }, + { + "surface": "action.locations[]", + "to": "action location 'global_nav' removed (#6888 — no running-app surface rendered it; the ⌘K palette reads no action metadata, while the Studio designer previewed a command-palette frame for it. The value is stripped and the key kept, so an action left with no location becomes the documented headless shape `locations: []`)", + "conversionId": "action-global-nav-location-removed", + "toMajor": 17 } ], "migrated": [