Skip to content

Commit dadd1ad

Browse files
os-zhuangclaudeos-zhuang
authored
refactor(spec,plugin-sharing)!: retire the exported SharingExecutionContext type (#7218) (#7325)
* refactor(spec,plugin-sharing)!: retire the exported `SharingExecutionContext` type (#7218) 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 <steve@objectstack.ai> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * 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 <steve@objectstack.ai> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: os-zhuang <steve@objectstack.ai>
1 parent 3d5c090 commit dadd1ad

15 files changed

Lines changed: 406 additions & 128 deletions

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/plugin-sharing": minor
4+
"@objectstack/plugin-approvals": patch
5+
"@objectstack/plugin-reports": patch
6+
---
7+
8+
refactor(spec,plugin-sharing): retire the exported `SharingExecutionContext` type (#7218)
9+
10+
<!-- adr-0087: registered sharing-execution-context-retired -->
11+
12+
**BREAKING — public surface removal.** `SharingExecutionContext` is deleted from
13+
`@objectstack/spec` (`contracts/sharing-service`) and from
14+
`@objectstack/plugin-sharing`, which re-exported it. Both `api-surface/` and
15+
`export-origins/` snapshots are regenerated accordingly.
16+
17+
This is the deferred deletion recorded when #7070 split the convergence in two.
18+
#6523 / PR #7068 converged 36 contract signatures onto the full
19+
`resolveAuthzContext` envelope (`ExecutionContext`), applying the #6206 ruling —
20+
enforcement adjudicates on the whole envelope, never a per-site subset. The
21+
consumer halves then re-annotated the implementations: PR #7140 (identity:
22+
`plugin-sharing`, `plugin-audit`) and PR #7206 (services: `plugin-approvals`,
23+
`plugin-reports`). Both landed with the type still exported, because it is
24+
DEFINED in `packages/spec` and that package's retirement is the spec seat's to
25+
make. Nothing declares it any more, so it goes.
26+
27+
**Migration.** Anyone who imported `SharingExecutionContext` from either package
28+
should import `ExecutionContext` from `@objectstack/spec` instead — the type the
29+
contracts have declared since #7068. The old shape was six optional fields, all
30+
of which exist on the envelope with the same names and types, so a value that
31+
satisfied the retired type already satisfies `ExecutionContext`; only the
32+
spelling of the annotation changes.
33+
34+
**No runtime behaviour changes.** The type was erased at compile time and no
35+
signature's accepted shape moved: the contracts already took the wide envelope.
36+
37+
**What the retirement did NOT remove — the reason to read the pins.** Deleting
38+
the type does not make re-narrowing a compile error. Structural subtyping still
39+
accepts a six-field context where the envelope is expected, so the boundary is
40+
held by the declared parameter type plus the pins, exactly as before. The three
41+
`exec-context-annotation.pin.ts` files (`plugin-sharing`, `plugin-approvals`,
42+
`plugin-reports`) told their failure story as "the parameter narrows back to
43+
`SharingExecutionContext`", which a deletion would have quietly hollowed out.
44+
Each now keeps the retired six-field shape as a local, non-exported SPECIMEN
45+
type and refutes every enforcement parameter against it by type identity, so a
46+
re-narrowing under ANY name is red — alongside the fresh-literal
47+
excess-property checks they already carried. `sharing-service.test.ts` in
48+
`packages/spec` is re-anchored the same way, and its "twin unchanged in shape"
49+
case becomes a "twin stays retired" case. The narrative the retired type's doc
50+
block carried (the measured `(context as any).posture` specimen, and why tsc
51+
cannot police this) moves to the module doc of `contracts/sharing-service`,
52+
which the contracts and pins now point at.

content/docs/kernel/runtime-services/sharing-service.mdx

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,21 @@ description: Record-level sharing and editability checks.
1111
## Methods
1212

1313
```ts
14-
services.sharing.buildReadFilter(object: string, context: SharingExecutionContext): Promise<unknown | null>
15-
services.sharing.canEdit(object: string, recordId: string, context: SharingExecutionContext): Promise<boolean>
16-
services.sharing.canDelete(object: string, recordId: string, context: SharingExecutionContext): Promise<boolean>
17-
services.sharing.canManageShares(object: string, recordId: string, context: SharingExecutionContext): Promise<boolean>
18-
services.sharing.grant(input: GrantShareInput, context: SharingExecutionContext): Promise<RecordShare>
19-
services.sharing.revoke(shareId: string, context: SharingExecutionContext, scope?: { object: string; recordId: string }): Promise<void>
20-
services.sharing.listShares(object: string, recordId: string, context: SharingExecutionContext): Promise<RecordShare[]>
14+
services.sharing.buildReadFilter(object: string, context: ExecutionContext): Promise<unknown | null>
15+
services.sharing.canEdit(object: string, recordId: string, context: ExecutionContext): Promise<boolean>
16+
services.sharing.canDelete(object: string, recordId: string, context: ExecutionContext): Promise<boolean>
17+
services.sharing.canManageShares(object: string, recordId: string, context: ExecutionContext): Promise<boolean>
18+
services.sharing.grant(input: GrantShareInput, context: ExecutionContext): Promise<RecordShare>
19+
services.sharing.revoke(shareId: string, context: ExecutionContext, scope?: { object: string; recordId: string }): Promise<void>
20+
services.sharing.listShares(object: string, recordId: string, context: ExecutionContext): Promise<RecordShare[]>
2121
```
2222

23+
Every method above **adjudicates access**, so each takes the complete
24+
`resolveAuthzContext` envelope (`ExecutionContext`) — not a per-site subset
25+
(#6523, applying the #6206 ruling). The six-field `SharingExecutionContext`
26+
these signatures used to name was retired in #7218; pass the whole context you
27+
were handed, unchanged.
28+
2329
## Management authority (ADR-0111)
2430

2531
`grant` / `revoke` / `listShares` are **management operations**, enforced in the
@@ -86,7 +92,7 @@ export async function mayEditContract(
8692
): Promise<boolean> {
8793
return sharing.canEdit('contract', recordId, {
8894
userId: session.userId,
89-
// `SharingExecutionContext` names the org `tenantId`; a session exposes the
95+
// The execution context names the org `tenantId`; a session exposes the
9096
// same value as `organizationId` (the `session.tenantId` alias was removed in
9197
// v11, #3290).
9298
tenantId: session.organizationId,

docs/protocol-upgrade-guide.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,9 @@ This is a CODE-path API, not stored metadata, so — like `hook-register-empty-o
455455

456456
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.
457457
- 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.
458+
- **`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
459+
- 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.
460+
- 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.
458461

459462
---
460463

packages/plugins/plugin-approvals/src/exec-context-annotation.pin.ts

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,26 @@
1717
* in this package. Nothing would notice. This module is the one thing that
1818
* does — every declaration below is red exactly when a parameter narrows back.
1919
*
20-
* HOW IT BITES: TypeScript's excess-property check on a FRESH object literal.
21-
* `posture` (ADR-0095 D2), `accessible_org_ids` (ADR-0105 D2) and
20+
* HOW IT BITES, part 1: TypeScript's excess-property check on a FRESH object
21+
* literal. `posture` (ADR-0095 D2), `accessible_org_ids` (ADR-0105 D2) and
2222
* `org_user_ids` are fields of the envelope that the retired six-field shape
2323
* did not carry, so a literal naming them is rejected the moment the parameter
2424
* is annotated with anything that lacks them. Note this is the ONLY direction
2525
* that works: a `@ts-expect-error` asserting the reverse would be unsatisfied
2626
* and fail the build, because a narrow context IS assignable to a wide
27-
* parameter — the boundary the retired type's own doc block records.
27+
* parameter — see item 3 of the module doc on
28+
* `@objectstack/spec/contracts/sharing-service` for that boundary.
29+
*
30+
* HOW IT BITES, part 2 (#7218): type IDENTITY against the retired shape itself.
31+
* #7135's failure story was "the parameter narrows back to the six-field
32+
* `SharingExecutionContext`" — a type that no longer exists, since #7218
33+
* deleted it from the contract surface once all three implementations had been
34+
* re-annotated. Deleting the type does NOT delete the failure mode: the six
35+
* fields can be re-declared here under any name, and the literal checks above
36+
* only fire on the fields a given literal happens to spell. So the retired
37+
* shape is kept below as a local SPECIMEN and each parameter is refuted
38+
* against it. A re-narrowing is then red twice over, and neither check depends
39+
* on the retired export coming back.
2840
*
2941
* WHY A `.pin.ts` AND NOT A `*.test.ts`: `packages/plugins/plugin-approvals/
3042
* tsconfig.json` excludes `**\/*.test.ts` (measured on this card, and the same
@@ -45,6 +57,46 @@ type AuthorizeFileReadContext = Parameters<ApprovalService['authorizeFileRead']>
4557
type OpenNodeRequestContext = Parameters<ApprovalService['openNodeRequest']>[1];
4658
type ListRequestsContext = Parameters<ApprovalService['listRequests']>[1];
4759

60+
/**
61+
* [#7218] The RETIRED six-field shape, kept here as a SPECIMEN — a deliberate
62+
* COPY of the type `@objectstack/spec` exported as `SharingExecutionContext`
63+
* until #7218 deleted it. Copied rather than imported on purpose: nothing may
64+
* depend on the retired name again, and a local copy is what lets this pin keep
65+
* naming the shape it refuses after the export is gone.
66+
*
67+
* ⛔ Not a vocabulary to reach for, and not exported.
68+
*/
69+
type RetiredSharingContextSpecimen = {
70+
userId?: string;
71+
tenantId?: string;
72+
positions?: string[];
73+
permissions?: string[];
74+
systemPermissions?: string[];
75+
isSystem?: boolean;
76+
};
77+
78+
/** Type-level identity: true iff A and B are the same type. */
79+
type Eq<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2)
80+
? true
81+
: false;
82+
/** Compile error when the argument is not `false`. */
83+
type Refute<T extends false> = T;
84+
85+
/**
86+
* NEGATIVE, at the type level: no enforcement parameter IS the retired shape.
87+
* Red the moment one is re-annotated with those six fields under any spelling
88+
* — the failure #7135's pin told as "narrows back to `SharingExecutionContext`",
89+
* restated so it no longer needs the deleted name to be checkable.
90+
*/
91+
type _NotTheRetiredShape = [
92+
Refute<Eq<GetRequestContext, RetiredSharingContextSpecimen>>,
93+
Refute<Eq<DecideContext, RetiredSharingContextSpecimen>>,
94+
Refute<Eq<ListActionsContext, RetiredSharingContextSpecimen>>,
95+
Refute<Eq<AuthorizeFileReadContext, RetiredSharingContextSpecimen>>,
96+
Refute<Eq<OpenNodeRequestContext, RetiredSharingContextSpecimen>>,
97+
Refute<Eq<ListRequestsContext, RetiredSharingContextSpecimen>>,
98+
];
99+
48100
/**
49101
* Never called — every line below is a type-level assertion evaluated by
50102
* `tsc --noEmit`. The parameters are taken as arguments rather than read off a
@@ -70,6 +122,13 @@ export function __pinApprovalsTakesTheFullEnvelope(
70122
openNodeRequest(undefined as never, { userId: 'u1', posture: 'MEMBER', accessible_org_ids: ['org_a'] });
71123
listRequests(undefined, { userId: 'u1', org_user_ids: ['u1'] });
72124

125+
// ── NEGATIVE: none of these parameters IS the retired six-field shape. ───
126+
// The tuple is all-`false` exactly when every `Refute` above holds; a
127+
// parameter re-narrowed to the specimen makes its slot `true` and this
128+
// assignment stops compiling.
129+
const notTheRetiredShape: _NotTheRetiredShape = [false, false, false, false, false, false];
130+
void notTheRetiredShape;
131+
73132
// ── NEGATIVE: widening must not have degenerated into `any`. ─────────────
74133
// A parameter erased to `any` would swallow every positive above just as
75134
// happily, so the pin is only worth its weight if wrong input still fails.

0 commit comments

Comments
 (0)