diff --git a/.changeset/hook-context-session-roles-retired.md b/.changeset/hook-context-session-roles-retired.md new file mode 100644 index 0000000000..0fa5d48b2f --- /dev/null +++ b/.changeset/hook-context-session-roles-retired.md @@ -0,0 +1,65 @@ +--- +"@objectstack/spec": major +--- + +refactor(spec)!: retire `HookContext.session.roles` — declared, read by two dead branches, never produced (#5050) + +`session.roles` on the runtime hook context was ADR-0049's enforce-or-remove +case with **neither end**: it was declared in `data/hook.zod.ts`, read by +exactly two consumers, and produced by nobody. The two readers were +plugin-approvals' admin exemptions — the approval record lock and the delegation +write guard, each opening with `session.roles?.includes('admin')` — and both +were deleted in #4839 (PR #5049) on the maintainer's ruling. The producer side +was empty the whole time: ObjectQL's `buildSession()` builds the session field +by field (`userId`, `organizationId`, `accessToken`, `isSystem`, `actor`, the +skip flags) and has never written `roles`, and nothing else feeds a +HookContext. So every read resolved `undefined`, +and a guard keyed on it was dead code that merely LOOKED like an authorization +decision — plus a second admin dialect competing with the one ADR-0090 D3 / +ADR-0095 D3 sanction. + +Cross-repo consumer check ran in both directions before removing anything +(#4895's discipline, after #4865's tombstone was disproven by objectui): +`cloud` has zero `session.roles` while its hook consumers really do read +`hookContext?.session?.userId` (`service-cloud/src/marketplace-visibility-plugin.ts`, +`control-plane-org-scope-plugin.ts`) — a positive control in the same run; +`objectui` has zero, and its `roles` are the `/auth/me` **user** payload, a +different surface that is untouched. + +One neighbour is called out rather than folded in, because mistaking it for a +producer would read as refuting the whole finding (#4865's lesson): an **action** +body's `ctx.session` is a different, untyped object built by `runtime`'s +`buildActionSession()`, and it does populate a `roles` key from `ec.positions`. +It never becomes a HookContext and no schema types it, so it is neither evidence +against this retirement nor fixed by it — it is filed and tracked apart. + +**Nothing observable changes.** A key nobody wrote and nothing read cannot alter +a single decision — this is the declaration catching up with the runtime, not a +behaviour change. + +FROM → TO: + +- `HookContext.session.roles` (`@objectstack/spec/data`) → removed. Delete the + key. To gate a hook on the caller, read `ctx.session.userId` / + `ctx.session.isSystem`; to judge PRIVILEGE, ask the security service, which + evaluates capability grants (`permissions`), placements (`positions`) and the + derived posture off the execution context (ADR-0095 D3) — never a role-name + string comparison. + +The retirement kit: the key is **tombstoned**, not deleted, because +`HookContextSchema` is deliberately not `.strict()` (strictness there would turn +an engine-internal enrichment into a breaking change for anyone parsing a +context they were handed, as `provenance` was in #3712) — a plain delete would +have stripped it in silence, the #3733 / ADR-0104 failure. `retiredKey()` gives +both channels instead: `tsc` types the key `never` at any producer, and a parse +raises the prescription itself. There is **no** ADR-0087 D2 conversion and +nothing for `os migrate meta` to 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` (#4579) / `activationEvents` (#4657) shape, +registered as the `hook-context-session-roles-retired` **semantic** migration at +major 17 so the prescription still reaches `spec-changes.json`, the generated +upgrade guide and the `spec_changes` MCP tool. The four export/def ratchets are +unchanged by design: this narrows a nested key inside a surviving def, which +`api-surface.json`, `authorable-surface.json` (whose walk records top-level keys +per def), `api-surface-signatures.json` and `json-schema.manifest.json` are all +blind to — the enum-narrowing reading of the two, not the whole-def one. diff --git a/content/docs/references/data/hook.mdx b/content/docs/references/data/hook.mdx index 8fa503212b..8c51a66ab9 100644 --- a/content/docs/references/data/hook.mdx +++ b/content/docs/references/data/hook.mdx @@ -37,7 +37,7 @@ const result = HookContextSchema.parse(data); | **input** | `Record` | ✅ | Mutable input parameters | | **result** | `any` | optional | Operation result (After hooks only) | | **previous** | `Record` | optional | Record state before operation | -| **session** | `{ userId?: string; actor?: string; organizationId?: string; roles?: string[]; … }` | optional | Current session context | +| **session** | `{ userId?: string; actor?: string; organizationId?: string; accessToken?: string; … }` | optional | Current session context | | **provenance** | `{ flowRunId?: string; attributedUserId?: string }` | optional | Server-stamped write provenance (never client-supplied, never an authorization input) | | **transaction** | `any` | optional | Database transaction handle | | **ql** | `any` | ✅ | ObjectQL Engine Reference | diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index d46c689c96..be6af3a4ac 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -206,6 +206,8 @@ It closes the enforce-or-remove line with two `./ui` vocabulary shapes that neve 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. +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. + ### Mechanical (applied for you) | 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 - **`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) - 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. - 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`. +- **`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) + - 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. + - 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. --- diff --git a/packages/plugins/plugin-approvals/src/admin-exemption-retired.test.ts b/packages/plugins/plugin-approvals/src/admin-exemption-retired.test.ts index df1304abd9..ed1ad4926b 100644 --- a/packages/plugins/plugin-approvals/src/admin-exemption-retired.test.ts +++ b/packages/plugins/plugin-approvals/src/admin-exemption-retired.test.ts @@ -11,9 +11,14 @@ * **delegation write-guard**. `roles` has no producer anywhere in the platform — * ObjectQL's `buildSession()` builds its session field by field and never writes * it — so both branches were dead on every real engine path. Classic - * declared ≠ enforced: the spec's `HookContext` session declares + * declared ≠ enforced: the spec's `HookContext` session declared * `roles: z.array(z.string()).optional()`, two consumers read it, and no - * producer ever fills it. + * producer ever filled it. (Past tense as of #5050: with these two readers gone + * the key had neither end, so the spec retired it under ADR-0049 — it is now a + * `retiredKey()` tombstone in `data/hook.zod.ts`. The pin below is unaffected + * and stays load-bearing: it guards the *dialect*, not the declaration, and the + * fixtures that still spell `roles: ['admin']` are deliberate — they prove the + * retired spelling grants nothing at runtime either.) * * They also spoke a **second privilege dialect**. This codebase judges privilege * by the ADR-0095 vocabulary — capability grants (`permissions`), placements diff --git a/packages/spec/authorable-surface.base.json b/packages/spec/authorable-surface.base.json index fcfe5cf368..4da66f15de 100644 --- a/packages/spec/authorable-surface.base.json +++ b/packages/spec/authorable-surface.base.json @@ -1,6 +1,6 @@ { "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.", - "baseRev": "168f60f1adf5e4f44ed818eccbba442052722328", + "baseRev": "5acb93add66435880ffed0d3aff79db29ae1e932", "keys": [ "ai/AIModelConfig:maxTokens", "ai/AIModelConfig:model", @@ -5824,10 +5824,13 @@ "system/EmailAndPasswordConfig:resetPasswordTokenExpiresIn", "system/EmailAndPasswordConfig:revokeSessionsOnPasswordReset", "system/EmailServiceConfig:apiKey", + "system/EmailServiceConfig:appName", "system/EmailServiceConfig:defaultFrom", + "system/EmailServiceConfig:defaultTemplateContext", "system/EmailServiceConfig:options", "system/EmailServiceConfig:persist", "system/EmailServiceConfig:provider", + "system/EmailServiceConfig:queueDelivery", "system/EmailServiceConfig:retries", "system/EmailTemplateDefinition:_lock", "system/EmailTemplateDefinition:_lockDocsUrl", diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index adec636c9f..d147a8658a 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -572,6 +572,13 @@ "migrationId": "ui-notification-action-embed-config-retired", "toMajor": 17, "rationale": "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." + }, + { + "surface": "data.hookContext.session.roles", + "replacement": "(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)", + "migrationId": "hook-context-session-roles-retired", + "toMajor": 17, + "rationale": "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." } ], "removed": [] @@ -1203,6 +1210,13 @@ "migrationId": "ui-notification-action-embed-config-retired", "toMajor": 17, "rationale": "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." + }, + { + "surface": "data.hookContext.session.roles", + "replacement": "(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)", + "migrationId": "hook-context-session-roles-retired", + "toMajor": 17, + "rationale": "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." } ], "removed": [] diff --git a/packages/spec/src/data/hook.test.ts b/packages/spec/src/data/hook.test.ts index f44423bb3f..c35ec0a8e1 100644 --- a/packages/spec/src/data/hook.test.ts +++ b/packages/spec/src/data/hook.test.ts @@ -573,6 +573,9 @@ describe('HookContextSchema', () => { describe('Session Context', () => { it('should accept session with user info', () => { + // `roles` used to ride along in this fixture; it was retired in #5050 + // (declared, never produced) and now has its own pin block below. The + // keys asserted here are the ones `buildSession()` really writes. const context = HookContextSchema.parse({ object: 'account', event: 'beforeInsert', @@ -580,14 +583,14 @@ describe('HookContextSchema', () => { session: { userId: 'user_123', organizationId: 'org_456', - roles: ['user', 'admin'], + isSystem: false, }, ql: {}, }); expect(context.session?.userId).toBe('user_123'); expect(context.session?.organizationId).toBe('org_456'); - expect(context.session?.roles).toContain('admin'); + expect(context.session?.isSystem).toBe(false); }); it('should accept session with access token', () => { @@ -679,7 +682,10 @@ describe('HookContextSchema', () => { // type-checked it — vitest only sees `HookContextSchema.parse`, // which strips unknown keys silently (#5286). organizationId: 'org_456', - roles: ['user'], + // `roles` stood here for the same reason until #5050 retired it. It + // is now a `retiredKey()` tombstone, so this literal — which IS typed + // as `HookContext` — would no longer compile: the tsc channel the + // #5286 comment above says this fixture never had. }, transaction: { id: 'tx_789' }, ql: {}, @@ -847,3 +853,125 @@ describe('defineHook (#4269)', () => { expect(() => defineHook({ ...config, name: 'NotSnakeCase' })).toThrow(); }); }); + +/** + * `HookContext.session.roles` retirement (#5050, ADR-0049 enforce-or-remove). + * + * The key was DECLARED here, READ by two exemption branches in plugin-approvals + * (the approval record lock and the delegation write guard, both deleted in + * #4839 / PR #5049), and NEVER PRODUCED on the hook path — ObjectQL's + * `buildSession()` writes the session field by field and has no `roles` write, + * here or in `cloud` (whose hook consumers read `hookContext?.session?.userId` + * and nothing else). With the readers gone the key had neither end, which is + * the state ADR-0049 says must not persist. + * + * The one neighbour worth naming, so nobody "disproves" the above with it: an + * ACTION body's `ctx.session` is a different, untyped object built by + * `runtime`'s `buildActionSession()`, and it does populate a `roles` key from + * `ec.positions`. It never becomes a HookContext and no schema types it, so it + * is neither evidence against this retirement nor fixed by it — tracked apart. + * + * Both channels are pinned below, because they answer different questions: + * + * - the PARSE channel (`toThrow`) — what a runtime value meets. It matters + * here even though nobody authors a HookContext: `HookContextSchema` is + * exported and the generated reference documents `HookContextSchema.parse`, + * so a consumer parsing a context it was handed is a real, designed-for + * caller; + * - the TSC channel (`@ts-expect-error`) — what a producer meets, and the one + * that would have caught this key being written back. It is only live + * because #5286/#5478 put the test layer in front of `tsc` + * (`tsconfig.test.json`); before that every directive in this package was + * inert. + * + * Reverse-verified by restoring `roles: z.array(z.string()).optional()` in + * `hook.zod.ts`: `pnpm --filter @objectstack/spec test` turns the two parse + * assertions red, and `pnpm --filter @objectstack/spec typecheck` reports + * TS2578 "Unused '@ts-expect-error' directive" on the two directives below. + * Direction predicted before running it, and that is what it did. + */ +describe('session.roles retirement (#5050, ADR-0049)', () => { + it('REJECTS an authored `roles`, with the prescription in the message', () => { + expect(() => + HookContextSchema.parse({ + object: 'account', + event: 'beforeInsert', + input: {}, + session: { userId: 'user_123', roles: ['admin'] }, + ql: {}, + }), + ).toThrow(/session\.roles.*removed.*never produced/s); + }); + + it('names the live vocabulary rather than only refusing', () => { + // A tombstone whose message stops at "removed" leaves the reader to guess, + // and the guess this key invites is a second admin dialect. + expect(() => + HookContextSchema.parse({ + object: 'account', + event: 'beforeInsert', + input: {}, + session: { roles: [] }, + ql: {}, + }), + ).toThrow(/ctx\.session\.userId.*permissions.*positions/s); + }); + + it('parses cleanly once the key is gone, and carries no `roles` on the way out', () => { + const context = HookContextSchema.parse({ + object: 'account', + event: 'beforeInsert', + input: {}, + session: { userId: 'user_123', organizationId: 'org_456', isSystem: true }, + ql: {}, + }); + + expect(context.session?.userId).toBe('user_123'); + expect(context.session).not.toHaveProperty('roles'); + }); + + it('is a TOMBSTONE, not new strictness — an unrecognized key still strips silently', () => { + // The distinction is the whole reason `retiredKey()` exists here. This + // schema must stay tolerant (the header says so: an engine-internal + // enrichment must not break a consumer parsing a context it was handed), + // so a plain deletion would have stripped `roles` in silence — the + // #3733 / ADR-0104 failure. Only the retired key is loud. + const context = HookContextSchema.parse({ + object: 'account', + event: 'beforeInsert', + input: {}, + session: { userId: 'user_123', somethingTheEngineMayAddLater: true } as never, + ql: {}, + }); + + expect(context.session?.userId).toBe('user_123'); + expect(context.session).not.toHaveProperty('somethingTheEngineMayAddLater'); + }); + + it('fails tsc at the producer — the channel that outranks the parse here', () => { + const context: HookContext = { + object: 'account', + event: 'beforeInsert', + input: {}, + session: { + userId: 'user_123', + // @ts-expect-error — `session.roles` was retired in #5050; the input type is `never`. + roles: ['admin'], + }, + ql: {}, + }; + + // ...and the same directive holds for the shape ObjectQL builds, which is + // the site that would have had to start producing the key for any reader + // to ever see it. + const built: NonNullable = { + userId: 'user_123', + organizationId: 'org_456', + // @ts-expect-error — `buildSession()` never wrote this, and now it cannot. + roles: [], + }; + + expect(() => HookContextSchema.parse(context)).toThrow(/session\.roles/s); + expect(built.userId).toBe('user_123'); + }); +}); diff --git a/packages/spec/src/data/hook.zod.ts b/packages/spec/src/data/hook.zod.ts index 2c1726e4b4..bc514f8720 100644 --- a/packages/spec/src/data/hook.zod.ts +++ b/packages/spec/src/data/hook.zod.ts @@ -8,6 +8,7 @@ import { ExpressionInputSchema } from '../shared/expression.zod'; * Defines the interception points in the ObjectQL execution pipeline. */ import { lazySchema } from '../shared/lazy-schema'; +import { retiredKey } from '../shared/retired-key'; import { strictUnknownKeyError } from '../shared/suggestions.zod'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; import { HookBodySchema } from './hook-body.zod'; @@ -371,11 +372,71 @@ export const HookContextSchema = lazySchema(() => z.object({ * deliberately untouched. */ organizationId: z.string().optional().describe('Active organization ID (blessed developer-facing name)'), - roles: z.array(z.string()).optional(), accessToken: z.string().optional(), isSystem: z.boolean().optional().describe('True when the call was made with an elevated system context (engine self-writes)'), skipTriggers: z.boolean().optional().describe('True when record-change automation (flow triggers) must be suppressed for this write — e.g. package seed replay. Lifecycle hooks still run.'), skipAutomations: z.boolean().optional().describe('True when metadata-bound automation hooks must be suppressed for this write — e.g. data import with "run automations" unchecked, or import undo. Implies skipTriggers; code-registered system hooks (audit, security) still run.'), + // `roles` REMOVED (#5050, ADR-0049 D2). It was DECLARED here, READ by two + // dead exemption branches in plugin-approvals (the approval record lock and + // the delegation write guard, both deleted in #4839 / PR #5049), and NEVER + // PRODUCED on the hook path: ObjectQL's `buildSession()` + // (`packages/objectql/src/engine.ts`) builds the session field by field — + // `userId`, `organizationId`, `accessToken`, `isSystem`, `actor`, the skip + // flags — and has no `roles` write, and no other producer feeds a + // HookContext. So both readers resolved `undefined` on every real engine + // path: an authorization decision in shape only. #5049 removed the last two + // readers; this removes the declaration, which is what ADR-0049 + // enforce-or-remove asks for once a key has neither end. + // + // ⚠️ One NEIGHBOUR, deliberately not conflated (the #4865 lesson — a + // tombstone claim disproven by a surface nobody checked): an ACTION body's + // `ctx.session` is a DIFFERENT object, built by + // `runtime/src/action-execution.ts` `buildActionSession()`, and it does + // write a `roles` key today (from `ec.positions`, i.e. the ADR-0090 D3 + // vocabulary spelled in the banned name). It is untyped `any`, reaches no + // schema, and never becomes a HookContext — so it neither refutes the + // never-produced finding here nor is fixed by this removal. Filed as + // #5613; do not "restore" this key on the strength of having seen + // `session.roles` populated inside an action body. + // + // Tombstoned rather than deleted: `HookContextSchema` is deliberately NOT + // `.strict()` (see the header), so a plain deletion would make Zod strip + // the key silently — the #3733 / ADR-0104 failure, and exactly the silent + // no-op this retirement exists to end. + // + // ⚠️ Keep tombstones at the BOTTOM of this shape. The generated reference + // renders an inline object as its first four declared keys plus `…` + // (`scripts/lib/format-type.ts`, INLINE_KEY_LIMIT), and a `z.never()` has + // no JSON-Schema `type`, so it prints as `any` with no room for the + // `[REMOVED]` prescription a top-level row would carry. In its original + // position `roles` was the 4th key and `references/data/hook.mdx` began + // advertising `roles?: any` — a retired key reading as a free-form + // authorable slot, the ADR-0033 trap pointed at the docs. Below the live + // keys it is elided instead, and the two real channels (tsc + the parse) + // are untouched. The renderer gap itself is filed separately. + // + // The live vocabulary is unchanged and lives elsewhere on purpose: a hook + // that gates on the caller reads `session.userId` / `session.isSystem` + // here, and privilege itself is judged on the ExecutionContext the security + // service resolves — capability grants (`permissions`), placements + // (`positions`) and the derived posture (ADR-0095 D3) — never by a session + // field named `roles` or a comparison against the string 'admin' + // (ADR-0090 D3 bans the `role` spelling outright). + roles: retiredKey( + '`HookContext.session.roles` was removed in @objectstack/spec 17.0.0 (#5050, ADR-0049 D2) — ' + + 'it was declared, read by two dead exemption branches (removed in #5049), and never ' + + 'produced: ObjectQL\'s `buildSession()` builds the session field by field and has never ' + + 'written `roles`, so every read resolved `undefined` and a guard keyed on it was dead ' + + 'code that merely LOOKED like an authorization decision. Delete the key. To gate a hook ' + + 'on the caller, read `ctx.session.userId` / `ctx.session.isSystem`; to judge PRIVILEGE, ' + + 'ask the security service, which evaluates the ADR-0095 vocabulary on the execution ' + + 'context — capability grants (`permissions`), placements (`positions`) and the derived ' + + 'posture — never a role-name string comparison (ADR-0090 D3 bans the `role` spelling ' + + 'outright). Nothing to migrate: a HookContext is built per operation by the engine and ' + + 'never stored, so no metadata source carries this key. NOTE an ACTION body\'s ' + + '`ctx.session` is a different object and still carries its own `roles` array today; ' + + 'that surface is tracked separately (#5613) and is not what this key was.', + ), }).optional().describe('Current session context'), /** diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index f1c1bcafe3..3b0f793dcc 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -955,7 +955,33 @@ const step17: MigrationStep = { + '`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.', + + 'names and are untouched.\n\n' + + '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.', conversionIds: [ 'action-execute-to-target', 'field-conditionalRequired-to-requiredWhen', @@ -1808,6 +1834,43 @@ const step17: MigrationStep = { + 'Public form sharing is unaffected — `FormView.sharing` still gates the anonymous ' + 'endpoints on `allowAnonymous` + `publicLink`.', }, + { + id: 'hook-context-session-roles-retired', + surface: 'data.hookContext.session.roles', + replacement: + '(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)', + reason: + '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.', + acceptanceCriteria: + '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.', + }, ], }; diff --git a/skills/objectstack-data/references/data-hooks.md b/skills/objectstack-data/references/data-hooks.md index 9a0bacfdb8..5eac6ec6dc 100644 --- a/skills/objectstack-data/references/data-hooks.md +++ b/skills/objectstack-data/references/data-hooks.md @@ -346,7 +346,7 @@ The sandbox is handed a **JSON snapshot** of these (built by | `ctx.previous` | object \| `undefined` | Pre-write record on update/delete. **`undefined` on insert** → use `!ctx.previous` to detect *create*. | | `ctx.result` | object \| `undefined` | `after*` only. ⚠️ **partial** on afterUpdate — see gotcha 1. | | `ctx.user` | object \| `undefined` | `{ id, name, email, organizationId }`. `undefined` for system / unauthenticated writes. | -| `ctx.session` | object \| `undefined` | `{ userId, organizationId, roles, … }`. | +| `ctx.session` | object \| `undefined` | `{ userId, organizationId, isSystem, … }`. **No role list** — `session.roles` was retired in 17.0.0 (#5050): it was declared but never produced, so every read was `undefined`. | | `ctx.event` | string | e.g. `'afterUpdate'` — dispatch on it when one hook subscribes to several events. | | `ctx.object` | string | The target object name. | | `ctx.api` | object | Cross-object CRUD. Gated by `api.read` / `api.write` — see below. | @@ -569,8 +569,12 @@ interface HookContext { organizationId?: string; // Active org — the single blessed name. Matches the // `organization_id` column + `current_user.organizationId` (RLS). // The former `tenantId` alias was removed in #3290. - roles?: string[]; + // There is no `roles` here: `session.roles` was declared but + // never produced, and was retired in 17.0.0 (#5050). Privilege + // is judged by the security service (permissions / positions / + // posture), never by a role-name string in a hook. accessToken?: string; + isSystem?: boolean; // Elevated system context (engine self-writes). }; transaction?: unknown; // Database transaction handle @@ -991,10 +995,18 @@ const maskSensitiveData = defineHook({ object: ['contact', 'lead'], events: ['afterFind'], // fires for findOne too — no separate afterFindOne handler: async (ctx) => { - // Check user role - const isAdmin = ctx.session?.roles?.includes('admin'); - - if (!isAdmin) { + // Exempt the engine's own elevated reads (`isSystem`) — internal writes + // and self-reads must see the real values. + // + // ⚠️ Do NOT gate this on a role name. `ctx.session` carries no role list: + // `session.roles` was declared for years, never produced by any engine + // path, and retired in 17.0.0 (#5050) — `ctx.session?.roles?.includes(…)` + // was always `undefined`, so a mask written that way looked role-aware and + // was not. A per-role exemption belongs in field-level permissions (the + // callout above), which the read path applies for you. + const isElevated = ctx.session?.isSystem === true; + + if (!isElevated) { // Mask sensitive fields const maskField = (record: any) => { if (record.ssn) {