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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions .changeset/hook-context-session-roles-retired.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion content/docs/references/data/hook.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ const result = HookContextSchema.parse(data);
| **input** | `Record<string, any>` | ✅ | Mutable input parameters |
| **result** | `any` | optional | Operation result (After hooks only) |
| **previous** | `Record<string, any>` | 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 |
Expand Down
5 changes: 5 additions & 0 deletions docs/protocol-upgrade-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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.

---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion packages/spec/authorable-surface.base.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading