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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .changeset/retire-sharing-execution-context.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- adr-0087: registered sharing-execution-context-retired -->

**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.
22 changes: 14 additions & 8 deletions content/docs/kernel/runtime-services/sharing-service.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,21 @@ description: Record-level sharing and editability checks.
## Methods

```ts
services.sharing.buildReadFilter(object: string, context: SharingExecutionContext): Promise<unknown | null>
services.sharing.canEdit(object: string, recordId: string, context: SharingExecutionContext): Promise<boolean>
services.sharing.canDelete(object: string, recordId: string, context: SharingExecutionContext): Promise<boolean>
services.sharing.canManageShares(object: string, recordId: string, context: SharingExecutionContext): Promise<boolean>
services.sharing.grant(input: GrantShareInput, context: SharingExecutionContext): Promise<RecordShare>
services.sharing.revoke(shareId: string, context: SharingExecutionContext, scope?: { object: string; recordId: string }): Promise<void>
services.sharing.listShares(object: string, recordId: string, context: SharingExecutionContext): Promise<RecordShare[]>
services.sharing.buildReadFilter(object: string, context: ExecutionContext): Promise<unknown | null>
services.sharing.canEdit(object: string, recordId: string, context: ExecutionContext): Promise<boolean>
services.sharing.canDelete(object: string, recordId: string, context: ExecutionContext): Promise<boolean>
services.sharing.canManageShares(object: string, recordId: string, context: ExecutionContext): Promise<boolean>
services.sharing.grant(input: GrantShareInput, context: ExecutionContext): Promise<RecordShare>
services.sharing.revoke(shareId: string, context: ExecutionContext, scope?: { object: string; recordId: string }): Promise<void>
services.sharing.listShares(object: string, recordId: string, context: ExecutionContext): Promise<RecordShare[]>
```

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
Expand Down Expand Up @@ -86,7 +92,7 @@ export async function mayEditContract(
): Promise<boolean> {
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,
Expand Down
3 changes: 3 additions & 0 deletions docs/protocol-upgrade-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -45,6 +57,46 @@ type AuthorizeFileReadContext = Parameters<ApprovalService['authorizeFileRead']>
type OpenNodeRequestContext = Parameters<ApprovalService['openNodeRequest']>[1];
type ListRequestsContext = Parameters<ApprovalService['listRequests']>[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<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2)
? true
: false;
/** Compile error when the argument is not `false`. */
type Refute<T extends false> = 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<Eq<GetRequestContext, RetiredSharingContextSpecimen>>,
Refute<Eq<DecideContext, RetiredSharingContextSpecimen>>,
Refute<Eq<ListActionsContext, RetiredSharingContextSpecimen>>,
Refute<Eq<AuthorizeFileReadContext, RetiredSharingContextSpecimen>>,
Refute<Eq<OpenNodeRequestContext, RetiredSharingContextSpecimen>>,
Refute<Eq<ListRequestsContext, RetiredSharingContextSpecimen>>,
];

/**
* 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
Expand All @@ -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.
Expand Down
Loading
Loading