Skip to content

Commit cbb6a5c

Browse files
os-zhuangclaude
andauthored
refactor(spec)!: retire HookContext.session.roles — declared, read by two dead branches, never produced (#5050) (#5621)
`session.roles` on the runtime hook context had neither end: declared in `data/hook.zod.ts`, read only by the two plugin-approvals admin exemptions deleted in #4839 (PR #5049), and never written by `buildSession()` or anything else feeding a HookContext. ADR-0049 enforce-or-remove disposition: REMOVE. - tombstoned with `retiredKey()` (HookContextSchema is deliberately not `.strict()`, so a plain delete would strip the key silently — #3733/ADR-0104) - placed BELOW the live keys: the reference generator renders a `z.never()` as `any` inside an inline shape summary, so in its original 4th position it made `references/data/hook.mdx` advertise `roles?: any` (renderer gap filed #5606) - ADR-0087: a SemanticMigration (`hook-context-session-roles-retired`), NOT a D2 conversion — a HookContext is built per operation and never stored, so no source exists to rewrite (the `openApi31` / `activationEvents` shape) - pins both channels: the parse prescription and two `@ts-expect-error` directives, live since #5286/#5478 put the test layer in front of tsc - skills/objectstack-data hook reference no longer teaches the dead key Cross-repo consumer check ran in both directions (cloud/objectui, #4895's discipline). The action body's `ctx.session` is a different, untyped object that does carry `roles` — named explicitly here and filed as #5613 so it is not mistaken for a producer of this key. Claude-Session: https://claude.ai/code/session_018fxLGQdatPbBUvCgiVxg6D Co-authored-by: Claude <noreply@anthropic.com>
1 parent 30b7843 commit cbb6a5c

10 files changed

Lines changed: 371 additions & 15 deletions

File tree

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
---
2+
"@objectstack/spec": major
3+
---
4+
5+
refactor(spec)!: retire `HookContext.session.roles` — declared, read by two dead branches, never produced (#5050)
6+
7+
`session.roles` on the runtime hook context was ADR-0049's enforce-or-remove
8+
case with **neither end**: it was declared in `data/hook.zod.ts`, read by
9+
exactly two consumers, and produced by nobody. The two readers were
10+
plugin-approvals' admin exemptions — the approval record lock and the delegation
11+
write guard, each opening with `session.roles?.includes('admin')` — and both
12+
were deleted in #4839 (PR #5049) on the maintainer's ruling. The producer side
13+
was empty the whole time: ObjectQL's `buildSession()` builds the session field
14+
by field (`userId`, `organizationId`, `accessToken`, `isSystem`, `actor`, the
15+
skip flags) and has never written `roles`, and nothing else feeds a
16+
HookContext. So every read resolved `undefined`,
17+
and a guard keyed on it was dead code that merely LOOKED like an authorization
18+
decision — plus a second admin dialect competing with the one ADR-0090 D3 /
19+
ADR-0095 D3 sanction.
20+
21+
Cross-repo consumer check ran in both directions before removing anything
22+
(#4895's discipline, after #4865's tombstone was disproven by objectui):
23+
`cloud` has zero `session.roles` while its hook consumers really do read
24+
`hookContext?.session?.userId` (`service-cloud/src/marketplace-visibility-plugin.ts`,
25+
`control-plane-org-scope-plugin.ts`) — a positive control in the same run;
26+
`objectui` has zero, and its `roles` are the `/auth/me` **user** payload, a
27+
different surface that is untouched.
28+
29+
One neighbour is called out rather than folded in, because mistaking it for a
30+
producer would read as refuting the whole finding (#4865's lesson): an **action**
31+
body's `ctx.session` is a different, untyped object built by `runtime`'s
32+
`buildActionSession()`, and it does populate a `roles` key from `ec.positions`.
33+
It never becomes a HookContext and no schema types it, so it is neither evidence
34+
against this retirement nor fixed by it — it is filed and tracked apart.
35+
36+
**Nothing observable changes.** A key nobody wrote and nothing read cannot alter
37+
a single decision — this is the declaration catching up with the runtime, not a
38+
behaviour change.
39+
40+
FROM → TO:
41+
42+
- `HookContext.session.roles` (`@objectstack/spec/data`) → removed. Delete the
43+
key. To gate a hook on the caller, read `ctx.session.userId` /
44+
`ctx.session.isSystem`; to judge PRIVILEGE, ask the security service, which
45+
evaluates capability grants (`permissions`), placements (`positions`) and the
46+
derived posture off the execution context (ADR-0095 D3) — never a role-name
47+
string comparison.
48+
49+
The retirement kit: the key is **tombstoned**, not deleted, because
50+
`HookContextSchema` is deliberately not `.strict()` (strictness there would turn
51+
an engine-internal enrichment into a breaking change for anyone parsing a
52+
context they were handed, as `provenance` was in #3712) — a plain delete would
53+
have stripped it in silence, the #3733 / ADR-0104 failure. `retiredKey()` gives
54+
both channels instead: `tsc` types the key `never` at any producer, and a parse
55+
raises the prescription itself. There is **no** ADR-0087 D2 conversion and
56+
nothing for `os migrate meta` to rewrite: a HookContext is built per operation
57+
by the engine and never stored, so no `sys_metadata` row, example or template
58+
can carry the key — the `openApi31` (#4579) / `activationEvents` (#4657) shape,
59+
registered as the `hook-context-session-roles-retired` **semantic** migration at
60+
major 17 so the prescription still reaches `spec-changes.json`, the generated
61+
upgrade guide and the `spec_changes` MCP tool. The four export/def ratchets are
62+
unchanged by design: this narrows a nested key inside a surviving def, which
63+
`api-surface.json`, `authorable-surface.json` (whose walk records top-level keys
64+
per def), `api-surface-signatures.json` and `json-schema.manifest.json` are all
65+
blind to — the enum-narrowing reading of the two, not the whole-def one.

content/docs/references/data/hook.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ const result = HookContextSchema.parse(data);
3737
| **input** | `Record<string, any>` || Mutable input parameters |
3838
| **result** | `any` | optional | Operation result (After hooks only) |
3939
| **previous** | `Record<string, any>` | optional | Record state before operation |
40-
| **session** | `{ userId?: string; actor?: string; organizationId?: string; roles?: string[]; … }` | optional | Current session context |
40+
| **session** | `{ userId?: string; actor?: string; organizationId?: string; accessToken?: string; … }` | optional | Current session context |
4141
| **provenance** | `{ flowRunId?: string; attributedUserId?: string }` | optional | Server-stamped write provenance (never client-supplied, never an authorization input) |
4242
| **transaction** | `any` | optional | Database transaction handle |
4343
| **ql** | `any` || ObjectQL Engine Reference |

docs/protocol-upgrade-guide.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,8 @@ It closes the enforce-or-remove line with two `./ui` vocabulary shapes that neve
206206

207207
The same is true of the protocol-17 retirement that closes this list, and the pair is worth reading together (#4988, ADR-0049): the five `@objectstack/spec/ui` interaction-config modules — `touch.zod.ts`, `dnd.zod.ts`, `keyboard.zod.ts`, `animation.zod.ts` and `offline.zod.ts`, 22 `z.object` sites and 64 exported names — are deleted whole, with their reference docs. They were never reachable: no schema in the protocol declared a `touch:` / `dnd:` / `keyboard:` / `animation:` / `offline:` key, so no metadata document could carry one and none needs rewriting now. The defect was on the DOCUMENTATION side, which is the half that made it urgent — `authorable-surface.json` carried 109 keys under these defs and the generated `references/ui/*` pages rendered them as authoring tables, so an AI author reading `dnd.mdx` wrote a `dnd:` block that `PageComponentSchema` then rejected as an unknown key. That is a published capability the runtime does not deliver (Prime Directive #10), not a strictness gap: closing the shapes would have validated a slot nobody can reach. Business reading behind the ruling: these five are RENDERER BUILT-IN behaviour, decided by the component library rather than authored per page; offline is a platform capability whose vocabulary belongs on a sync engine that has not been built. ⚠️ The `animation` here is `ui/animation.zod.ts` (`ComponentAnimation` / `MotionConfig` / `PageTransition` / `AnimationTrigger`), a DIFFERENT surface from the theme `animation` block retired above by #5021 — that one had a carrier key and got a tombstone; this one had none and gets deletion. The one name worth checking on upgrade is the bare `ConflictResolution` type: it left with `ui/offline.zod.ts` and is now published by nobody. `ConnectorConflictResolution` (`@objectstack/spec/integration`, connector sync) and `ConflictResolutionStrategy` (`@objectstack/spec/api`, route merge policy) are different concepts under their own names and are untouched.
208208

209+
The last enforce-or-remove entry of this step is on the RUNTIME context rather than on anything authorable: `HookContext.session.roles` (#5050). It was declared in `data/hook.zod.ts`, read by exactly two consumers — the approvals record lock and the delegation write guard, each opening with `session.roles?.includes('admin')` — and produced by nobody on the hook path: ObjectQL's `buildSession()` writes the session field by field (`userId`, `organizationId`, `accessToken`, `isSystem`, `actor`, the skip flags) and has no `roles` write, here or in `cloud`, whose hook consumers read `hookContext?.session?.userId` and nothing else (an ACTION body's `ctx.session` is a different untyped object that does carry one, tracked apart). So both branches were dead on every real engine path: an authorization decision in shape only, and — worse for a reader — a SECOND admin dialect competing with the one ADR-0095 D3 sanctions. #4839 (PR #5049) deleted the two readers on the maintainer's ruling; this step removes the declaration that outlived them, which is what ADR-0049 asks for once a key has neither end. Nothing observable changes: a key nobody wrote and nothing read cannot alter a single decision. It is tombstoned rather than deleted because `HookContextSchema` is deliberately NOT `.strict()` (strictness there would make an engine-internal enrichment a breaking change for anyone parsing a context they were handed, as `provenance` was in #3712), so a plain delete would strip the key in silence — the #3733 / ADR-0104 failure this whole pass exists to end. There is NO conversion and no source rewrite: a HookContext is built per operation by the engine and never stored, so no `sys_metadata` row, example or template can carry the key — the `openApi31` / `activationEvents` shape, one semantic TODO for hook authors. The live vocabulary is untouched and deliberately elsewhere: gate on `session.userId` / `session.isSystem` in the hook, and judge PRIVILEGE through the security service, which reads capability grants (`permissions`), placements (`positions`) and the derived posture off the execution context.
210+
209211
### Mechanical (applied for you)
210212

211213
| Conversion | Surface | Change | Load window |
@@ -331,6 +333,9 @@ The same is true of the protocol-17 retirement that closes this list, and the pa
331333
- **`ui-notification-action-embed-config-retired`**`ui.notificationAction / ui.embedConfig` → (removed — there is no replacement shape, because there was never a key to write either into. Delete the import and the value. Notification presentation is still described by the surviving `NotificationType` / `NotificationSeverity` / `NotificationPosition` vocabulary; public access to a form is granted by the LIVE `FormView.sharing` block (`SharingConfig`), which is untouched. Notification action buttons as metadata, and iframe embedding, return via the enforce route of ADR-0049 through a new ADR — carrier key and renderer first, vocabulary second)
332334
- Why not automatic: Both shapes were published `@objectstack/spec/ui` vocabulary with NO AUTHORING DOOR. #4001 批 14 measured them three ways on 2026-08-03 and this retirement re-ran all three against `origin/main` before removing anything, each with a positive control that passed in the same run: (1) CARRIER — no schema in `packages/spec/src` declared a key of either type (`ui/notification.zod`'s only non-test importer was the barrel; `ui/sharing.zod`'s were the barrel and `ui/view.zod.ts`, which names its SIBLING `SharingConfigSchema`), measured by resolving specifiers rather than substring-matching, because the repo holds two `sharing.zod` modules and a substring test miscredits `stack.zod.ts` to the UI one; (2) REACHABILITY — a BFS from the 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema`, over `build-schemas.ts`'s own walk including its derived-clone bridge, never reached either, while `Page` / `Action` / `DashboardWidget` / `Webhook` and `SharingConfig` itself all resolved `root-graph` in the same run and an injected synthetic carrier flipped both; (3) PARSE — zero `.parse()` in objectstack, cloud or objectui outside their own unit tests. So nobody could author one and nothing ever validated one: the #3950 shape, an exported schema with no consumer read as a capability, and the ADR-0033 trap where an AI author takes `EmbedConfigSchema` in the published bundle as proof the platform serves iframes. Neither is stored metadata and neither has a carrier, so no `sys_metadata` row can hold one and there is no source for the D2 chain to rewrite; this entry is the D3 record. 批 14 deliberately did NOT close them with `.strict()` — strictness is a property of a PARSE, and closing a shape nothing parses buys only "a precisely-validated dead slot, the more convincing lie" (#4583) — and filed the disposition as #5015, ruled REMOVE on 2026-08-04. Each was orphaned by an earlier retirement one level up: `NotificationAction` lost its wrappers at #4610 (`NotificationSchema` / `NotificationConfigSchema`, deleted for zero consumers), and `EmbedConfig` lost its key at 17.0.0 when the 2026-06 liveness audit retired `App.embed` (no iframe route ever read it) — that key still stands as a `retiredKey()` tombstone in `app.zod.ts`, so an author who wrote the KEY already meets a prescription; this removes the value shape that outlived it. ⚠️ The retirement is per SCHEMA, not per file: `ui/sharing.zod` KEEPS `SharingConfigSchema`, a live door carried by `FormViewSchema.sharing` and read by `rest-server.ts` to mount the anonymous form routes, and `ui/notification.zod` keeps its three presentation enums. objectui consumed `NotificationActionSchema.shape.variant` as a VOCABULARY (never a parse) to pin its own hand-written `NotificationActionButton` interface — which is exactly why "has a consumer" never meant "has an authoring door" here; that pin is adapted objectui-side when it refreshes this dependency. ADR-0049, #5015.
333335
- Done when: No code imports `NotificationActionSchema`, `NotificationAction`, `EmbedConfigSchema` or `EmbedConfig` from `@objectstack/spec` or `@objectstack/spec/ui` — both are TS2305 after upgrade, on every public entry (pinned by resolved symbol identity in `notification-embed-retirement.test.ts`). The same pin asserts the SURVIVORS in the same run, and that half is equally load-bearing: `NotificationTypeSchema` / `NotificationSeveritySchema` / `NotificationPositionSchema` and `SharingConfigSchema` must still be exported from `./ui`, and both modules must still load — a retirement that deleted either file would satisfy the absence half while destroying working surface. Nothing regresses at runtime, because nothing ever ran: no notification action was ever parsed from metadata and no iframe route ever read an embed config. Public form sharing is unaffected — `FormView.sharing` still gates the anonymous endpoints on `allowAnonymous` + `publicLink`.
336+
- **`hook-context-session-roles-retired`**`data.hookContext.session.roles` → (removed — gate on `session.userId` / `session.isSystem`; for PRIVILEGE ask the security service, which reads `permissions` / `positions` / posture off the execution context, ADR-0095 D3)
337+
- Why not automatic: Declared on the runtime hook context, read by exactly two consumers, produced by nobody. The two readers were the approvals record lock and the delegation write guard, each opening with `session.roles?.includes('admin')`; ObjectQL's `buildSession()` builds the session field by field and has never written `roles`, and nothing else feeds a HookContext in objectstack, cloud or objectui (cloud's hook consumers read `hookContext?.session?.userId`; objectui's `roles` are the `/auth/me` user payload, a different surface; an ACTION body's `ctx.session` is a different untyped object that does carry `roles`, tracked apart and unaffected). Both branches were therefore dead on every real engine path — an authorization decision in shape only, and a second admin dialect competing with the one ADR-0090 D3 / ADR-0095 D3 sanction. #4839 (PR #5049) removed the readers; this removes the declaration, per ADR-0049 enforce-or-remove. This is a RUNTIME context, not stored metadata: the engine builds a HookContext per operation and nothing persists one, so no `sys_metadata` row, example or template can carry the key and there is no source for the D2 chain to rewrite — the `openApi31` (#4579) / `activationEvents` (#4657) shape, one semantic TODO rather than a stack conversion. The key IS tombstoned (`HookContextSchema` is deliberately not `.strict()` — a plain delete would strip it silently, #3733 / ADR-0104), so a consumer that parses a context it was handed still meets the prescription. ADR-0049, #5050.
338+
- Done when: No hook reads `ctx.session.roles`; caller gating uses `ctx.session.userId` / `ctx.session.isSystem`, and privilege comes from the security service (`permissions` / `positions` / posture). Constructing a HookContext session with `roles` fails `tsc` (the input type is `never`) and fails `HookContextSchema.parse` with the retirement prescription instead of being silently stripped. Nothing regresses at runtime: the key had no producer, so no decision anywhere ever saw a value in it.
334339

335340
---
336341

packages/plugins/plugin-approvals/src/admin-exemption-retired.test.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,14 @@
1111
* **delegation write-guard**. `roles` has no producer anywhere in the platform —
1212
* ObjectQL's `buildSession()` builds its session field by field and never writes
1313
* it — so both branches were dead on every real engine path. Classic
14-
* declared ≠ enforced: the spec's `HookContext` session declares
14+
* declared ≠ enforced: the spec's `HookContext` session declared
1515
* `roles: z.array(z.string()).optional()`, two consumers read it, and no
16-
* producer ever fills it.
16+
* producer ever filled it. (Past tense as of #5050: with these two readers gone
17+
* the key had neither end, so the spec retired it under ADR-0049 — it is now a
18+
* `retiredKey()` tombstone in `data/hook.zod.ts`. The pin below is unaffected
19+
* and stays load-bearing: it guards the *dialect*, not the declaration, and the
20+
* fixtures that still spell `roles: ['admin']` are deliberate — they prove the
21+
* retired spelling grants nothing at runtime either.)
1722
*
1823
* They also spoke a **second privilege dialect**. This codebase judges privilege
1924
* by the ADR-0095 vocabulary — capability grants (`permissions`), placements

packages/spec/authorable-surface.base.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"description": "In-tree anchor for the authorable-surface deletion gate (#4650, #5235): a verbatim copy of the keys in authorable-surface.json as they stood at `baseRev`, a commit on origin/main. A build that CAN reach origin/main anchors on the merge base instead, and re-verifies this file against `baseRev` — so a PR that edits it to hide a deletion goes red wherever the network exists. A build that CANNOT reach GitHub (image-build stages, air-gapped, fork, historical-tag reproduction) anchors here instead of failing. Written only by `gen:schema`, only from a git-resolved baseline — never from the build that is being checked. See #5235.",
3-
"baseRev": "168f60f1adf5e4f44ed818eccbba442052722328",
3+
"baseRev": "5acb93add66435880ffed0d3aff79db29ae1e932",
44
"keys": [
55
"ai/AIModelConfig:maxTokens",
66
"ai/AIModelConfig:model",
@@ -5824,10 +5824,13 @@
58245824
"system/EmailAndPasswordConfig:resetPasswordTokenExpiresIn",
58255825
"system/EmailAndPasswordConfig:revokeSessionsOnPasswordReset",
58265826
"system/EmailServiceConfig:apiKey",
5827+
"system/EmailServiceConfig:appName",
58275828
"system/EmailServiceConfig:defaultFrom",
5829+
"system/EmailServiceConfig:defaultTemplateContext",
58285830
"system/EmailServiceConfig:options",
58295831
"system/EmailServiceConfig:persist",
58305832
"system/EmailServiceConfig:provider",
5833+
"system/EmailServiceConfig:queueDelivery",
58315834
"system/EmailServiceConfig:retries",
58325835
"system/EmailTemplateDefinition:_lock",
58335836
"system/EmailTemplateDefinition:_lockDocsUrl",

0 commit comments

Comments
 (0)