diff --git a/.changeset/action-ctx-session-contract.md b/.changeset/action-ctx-session-contract.md
new file mode 100644
index 0000000000..27f9b2f247
--- /dev/null
+++ b/.changeset/action-ctx-session-contract.md
@@ -0,0 +1,66 @@
+---
+"@objectstack/spec": minor
+"@objectstack/runtime": patch
+---
+
+feat(spec): declare the action-body `ctx.session` contract (#5697)
+
+An action body reads `ctx.session` on every dispatch, and until now **nothing
+declared it**. `actionContext` is a bare `any` at both dispatch sites
+(`domains/actions.ts`, `action-execution.ts`), the sandbox seam types
+`ScriptContext.session` as `unknown`, and the one spec-side mention was an
+inline literal on `ActionHandlerContext` carrying a `[k: string]: unknown`
+catch-all. Declared-nowhere, produced-anyway: no schema, no gate, no generated
+reference page, and nothing the liveness ledger could reach.
+
+That is how the surface drifted without anyone noticing. Its `roles` key carries
+`ExecutionContext.positions` — the ADR-0090 D3 vocabulary handed to authors under
+the one spelling that ADR forbids — while the hook side retired its own
+`session.roles` at #5050. One platform, one key name, two opposite answers.
+
+**`ActionSessionSchema` (`@objectstack/spec/ui`) declares that shape as built.**
+
+```ts
+{ userId?: string; organizationId?: string; roles?: string[] }
+```
+
+This release changes **nothing about what the runtime produces** — it is phase 1
+of #5613's contract-first ruling, and declaring current reality is deliberately
+not the same as endorsing it:
+
+- `roles` is declared **deprecated** in its `.describe()` and its JSDoc. The
+ rename to `positions`, with a deprecation window and an ADR-0087 semantic
+ migration, is #5613 phase 2. There is deliberately **no `positions` key yet** —
+ minting one before the migration would ship two live spellings of one value.
+- The schema is **not strict**, matching `HookContextSchema`: this is a runtime
+ shape the platform hands a body, never authored, and closing it would turn a
+ future engine-side enrichment into a parse failure for whoever parses a context
+ they were given.
+
+Three facts the declaration now states, all of them previously discoverable only
+by reading the builder:
+
+- **Absent means the key is absent.** The builder uses conditional spreads, so
+ `'organizationId' in ctx.session` answers `false` — not "present and
+ `undefined`". The hook path's `input.id` on a bulk write is the opposite case
+ (#5668); an `in` test does not port between them.
+- **No identity envelope yields no session at all** — `undefined`, never `{}`, so
+ a body can tell "no caller" from "an anonymous caller" (#3712). One consequence:
+ `roles` never appears on its own.
+- **`organizationId` is the blessed name** for the caller's active org; the
+ v11-removed `session.tenantId` alias (#3280 / #3290) does not come back.
+
+Type-only on the runtime side, no behaviour change: `buildActionSession()` now
+declares `ActionSession | undefined` instead of `any | undefined`, and
+`ActionHandlerContext.session` is the schema's inferred type rather than an
+inline literal with a catch-all. A handler annotated with `ActionHandler` that
+read an undeclared key off `ctx.session` now gets a compile error naming it —
+that key was never produced. `ScriptContext.session` deliberately stays
+`unknown`: it is one seam over both body kinds, and hook and action sessions are
+different objects.
+
+The declaration ships with the gate it needed —
+`packages/runtime/src/action-session-shape-contract.test.ts` executes the real
+producer and asserts a non-strict parse of the built object returns it
+**unchanged**, so a key the builder starts producing without declaring here is
+stripped and the pin goes red.
diff --git a/content/docs/references/ui/action-params.mdx b/content/docs/references/ui/action-params.mdx
new file mode 100644
index 0000000000..df83ebc1c1
--- /dev/null
+++ b/content/docs/references/ui/action-params.mdx
@@ -0,0 +1,76 @@
+---
+title: Action Params
+description: Action Params protocol schemas
+---
+
+{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
+
+The action DISPATCH contract: what the platform validates on the way in, and
+
+what it hands the handler on the way out.
+
+Two halves, one surface. **Inbound** — action-param VALUE validation
+
+(ADR-0104 D2), below. **Outbound** — the runtime context an action body /
+
+handler receives: `ActionSessionSchema` (the `ctx.session` contract,
+
+#5697), `ActionEngineFacade`, `ActionHandlerContext` and
+
+`ActionHandler`.
+
+## Inbound — action-param VALUE validation (ADR-0104 D2)
+
+An action's declared `params[]` is a complete value contract — `type`,
+
+`required`, `multiple`, `options`, `reference` — but before this it only
+
+informed the client dialog: the server passed `reqBody.params` straight to
+
+the handler, unvalidated (`http-dispatcher.ts`). This module is the pure
+
+contract that lets the REST and MCP dispatch paths enforce that declaration
+
+BEFORE the handler runs, reusing the D1 field value-shape contract
+
+(`valueSchemaFor`).
+
+Purity: schema derivation only (Prime Directive #2). Field-backed params are
+
+resolved to their effective value-shape inputs by the CALLER (the runtime,
+
+which holds the object metadata registry); this module validates the already
+
+resolved descriptors.
+
+
+**Source:** `packages/spec/src/ui/action-params.zod.ts`
+
+
+## TypeScript Usage
+
+```typescript
+import { ActionSessionSchema } from '@objectstack/spec/ui';
+import type { ActionSession } from '@objectstack/spec/ui';
+
+// Validate data
+const result = ActionSessionSchema.parse(data);
+```
+
+---
+
+## ActionSession
+
+Action-body `ctx.session` — the caller identity an action body reads (runtime shape, never authored)
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **userId** | `string` | optional | Invoking user id (absent when the call carries no user) |
+| **organizationId** | `string` | optional | Active organization id (blessed developer-facing name; absent when the call is org-less) |
+| **roles** | `string[]` | optional | DEPRECATED — the VALUE is the caller's ADR-0090 D3 `positions` (`ExecutionContext.positions`, "Formerly `roles`"), delivered at this boundary under the one spelling that vocabulary forbids. Declared here because `buildActionSession()` produces it today — declaring current reality is not endorsing the name: ADR-0090 D3 makes `role` a reserved-forbidden word, #4839 deleted the last two `roles.includes('admin')` readers, and #5050 retired the hook-side `HookContext.session.roles` outright, so a body author currently meets two different answers to one key name on one platform. The rename to `positions` — with its deprecation window, ADR-0087 semantic migration and the `buildActionSession()` comment correction — is #5613 phase 2. There is deliberately NO `positions` key on this shape yet: minting one before the migration would ship two live spellings of one value, which is the defect, not the fix. Never gate PRIVILEGE on this array — ask the security service, which evaluates capability grants, placements and the derived posture (ADR-0095), never a role-name string comparison. |
+
+
+---
+
diff --git a/content/docs/references/ui/index.mdx b/content/docs/references/ui/index.mdx
index 321864cba9..e44125cc1a 100644
--- a/content/docs/references/ui/index.mdx
+++ b/content/docs/references/ui/index.mdx
@@ -7,6 +7,7 @@ This section contains all protocol schemas for the ui layer of ObjectStack.
+
diff --git a/content/docs/references/ui/meta.json b/content/docs/references/ui/meta.json
index 95980719b4..4698949bbd 100644
--- a/content/docs/references/ui/meta.json
+++ b/content/docs/references/ui/meta.json
@@ -3,6 +3,7 @@
"pages": [
"---Apps & Navigation---",
"action",
+ "action-params",
"app",
"page",
"view",
diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md
index f77d44a7aa..0f1f82ff9e 100644
--- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md
+++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md
@@ -21,9 +21,9 @@ regenerate.
| Measure | Value |
|---|---|
| Triaged directories | 5 |
-| Object sites in them | 454 |
-| Still-open (strip) sites | 195 |
-| Files carrying at least one | 29 |
+| Object sites in them | 455 |
+| Still-open (strip) sites | 196 |
+| Files carrying at least one | 30 |
Remaining strip sites by class:
@@ -31,7 +31,7 @@ Remaining strip sites by class:
|---|---|
| authorable — the ruling's forced scope | 13 |
| unresolved — needs a per-schema verdict | 33 |
-| wire / open — out of forced scope | 106 |
+| wire / open — out of forced scope | 107 |
| no door — no carrier, ADR-0049 territory | 14 |
| no gate — carrier live, no parse | 29 |
@@ -43,12 +43,12 @@ The `strict` column is the one the campaign schedules against; it counts both th
| Dir | Sites | strict | passthrough | catchall | strip |
|---|---|---|---|---|---|
-| `ui/` | 170 | 116 | 5 | 0 | 49 |
+| `ui/` | 171 | 116 | 5 | 0 | 50 |
| `data/` | 162 | 54 | 1 | 0 | 107 |
| `automation/` | 75 | 49 | 0 | 0 | 26 |
| `security/` | 20 | 7 | 0 | 0 | 13 |
| `studio/` | 27 | 27 | 0 | 0 | 0 |
-| **total** | **454** | **253** | **6** | **0** | **195** |
+| **total** | **455** | **253** | **6** | **0** | **196** |
## File-level triage — site counts
@@ -60,6 +60,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
| File | Sites |
|---|---|
+| `action-params.zod.ts` | 1 |
| `action.zod.ts` | 8 |
| `app.zod.ts` | 18 |
| `bulk-action.zod.ts` | 3 |
@@ -75,7 +76,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
| `theme.zod.ts` | 6 |
| `view.zod.ts` | 53 |
| `widget.zod.ts` | 9 |
-| **total** | **170** |
+| **total** | **171** |
### `data/` — sites
@@ -155,23 +156,24 @@ over it is here.
### `ui/` — open
-**49 strip of 170**, in 6 file(s).
+**50 strip of 171**, in 7 file(s).
| File | Strip | Sites |
|---|---|---|
+| `action-params.zod.ts` | 1 | 1 |
| `app.zod.ts` | 1 | 18 |
| `chart.zod.ts` | 2 | 8 |
| `component.zod.ts` | 29 | 29 |
| `i18n.zod.ts` | 5 | 6 |
| `view.zod.ts` | 3 | 53 |
| `widget.zod.ts` | 9 | 9 |
-| **total** | **49** | **170** |
+| **total** | **50** | **171** |
| Bucket | Sites |
|---|---|
| authorable — the ruling's forced scope | 4 |
| unresolved — needs a per-schema verdict | 0 |
-| wire / open — out of forced scope | 2 |
+| wire / open — out of forced scope | 3 |
| no door — no carrier, ADR-0049 territory | 14 |
| no gate — carrier live, no parse | 29 |
diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md
index 11e17d3de5..6ff6de0b2e 100644
--- a/docs/audits/2026-07-unknown-key-strictness-ledger.md
+++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md
@@ -625,6 +625,7 @@ sites left to be a verdict about.
| File | Class | Note / next action |
|---|---|---|
| `action.zod.ts` | authorable | **strict as of #4001 批 14 — 0 strip sites remain.** `ActionParamSchema` was strict from #3746, but strictness does not recurse and its `options[]` entry was still strip: an option carrying `color` / `visibleWhen` / `icon` / `disabled` parsed clean through `getMetadataTypeSchema('action')` and came back `{ label, value }`. Closed with `strictObject`, NOT `.passthrough()` — the opposite call from `bulk-action.zod.ts`'s option entry two rows up, and made on measurement rather than symmetry: that def reaches the grid verbatim with no spec door in between and objectui's `BulkActionParam` declares an explicit `[key: string]: unknown`, whereas this path has a door that ALREADY strips and lands in the CLOSED `SelectOptionMetadata`. Whether this surface should carry the field-level per-option vocabulary at all is #5016. Earlier note: **9 → 8 at the #4001 re-measurement** — no schema changed; the ninth "site" was a `z.object(…)` inside a JSDoc paragraph, which the old textual counter could not tell from code **9 → 8 at the #4001 re-measurement** — no schema changed: the ninth "site" was a `z.object(…)` inside a JSDoc paragraph, which the old textual counter could not tell from code |
+| `action-params.zod.ts` | wire | **never strict, deliberately** — one site: `ActionSessionSchema`, the action-body `ctx.session` declared at **#5697** (phase 1 of #5613's contract-first ruling). It is the RUNTIME shape `packages/runtime`'s `buildActionSession()` hands a body, not an authoring surface — nobody writes it — so closing it would turn a future engine-side enrichment into a parse failure for whoever parses a context they were GIVEN: the same call, and the same reason, as `data/hook.zod.ts`'s `HookContextSchema` row. The file had no row until now because everything else it exports is a function or an interface (`validateActionParams`, `ActionHandlerContext`), i.e. zero sites to classify. ⚠️ **Read the arrival direction, because it is the opposite of every other row in this table**: this site did not survive a strictness sweep, it is NEW surface — a shape that was being produced with no declaration anywhere (`actionContext` is a bare `any` at both dispatch sites), which is why neither this ledger nor any gate could see that its `roles` key carries `ExecutionContext.positions` under the spelling ADR-0090 D3 forbids. The key is declared as-built and marked deprecated in its `.describe()`; the rename is #5613 phase 2. A declaration is not an endorsement — do not read this row as "the shape is settled" |
| `view.zod.ts` | authorable | partially strict (ADR-0089); long tail of sub-blocks. `bulkActionDefs` left this file in #4457 — see the row below |
| `bulk-action.zod.ts` | authorable | **strict as of #4457** — `BulkActionDefSchema` (the def itself). It was `z.array(z.record(z.string(), z.any()))` inline in `view.zod.ts`: a selection-bar button with **no shape at all**, so `opeartion` / `excution: 'aggregate'` parsed and shipped as a button that ran the default behaviour. Its two other sites are `BulkActionParamSchema` and that param's `options` entry, both deliberately **open** and both now `.passthrough()` — the param because objectui's `BulkActionParam` declares a `[key: string]: unknown` catch-all for widget config (min/max/step/format), so passthrough is the honest mirror and strictness would reject valid config (same call as `dashboard.zod.ts`'s widget `config`); the OPTION ENTRY on separate measured evidence, since its objectui type is closed and only the runtime path is open — `bulkParamToField` spreads each entry (`plugin-grid/src/components/bulkParamToField.ts:131`) into `SelectOptionMetadata` (`types/src/field-types.ts:288`), which declares and reads `color` / `icon` / `disabled` / `visibleWhen`. **This row said "both deliberately open" while only the parent was `passthrough`** — one intent, two postures, caught by the 2026-08-03 re-measure and closed by the ruling's verdict A (make the code match the prose). The lesson is the campaign's own: prose in this ledger is not a posture reading, which is why the remaining-strip map is gated and this column is not. The def also refuses the combinations the executor never reads (`patch` outside an update, `execution` outside a custom, `batchSize` on an aggregate) and a hand-written `actionDef`, which is renderer-attached |
| `component.zod.ts` | ~~authorable (p)~~ **no gate** | **no parse anywhere (measured, #4001 批 17)** — the `(p)` resolved NEGATIVE, and this is the campaign's largest single reclassification. The standing warning said to verify objectui's React-prop open slots first; doing so found the question was moot one level up. **The carrier is live but it is an open bag**: `PageComponentSchema.properties` is `z.record(z.string(), z.unknown())`, and although `PageComponentSchema` has been `.strict()` since ADR-0089 D3a, **strictness does not recurse** — it closes the component node's own keys and leaves everything under `properties` unchecked. Nothing dispatches `ComponentPropsMap` by `type`. Three measurements on 2026-08-04, controls green in the same run: (1) a BFS from all 24 metadata-type roots plus `ObjectStackSchema`, over a 6899-node closure built with `build-schemas.ts`'s own `zodChildSchemas`/`zodShapeOf` (the #4650 walk), returns **UNREACHABLE for all 52 targets** (21 exported schemas + every one of `ComponentPropsMap`'s 31 entries), while `PageSchema`/`PageComponentSchema`/`PageRegionSchema`/`ThemeSchema`/`ChartConfigSchema`/`ResponsiveConfigSchema` all resolve `root-graph` and 批 13's no-door shapes stay unreachable — the walk stops dead at `properties`. ⚠️ The #5056 bridge defect does not touch this row: it makes the derived-clone bridge report dead shapes as REACHABLE, the opposite direction, and nothing here rests on that bridge — all six positive controls resolve `root-graph` and all 52 targets miss BOTH `root-graph` and `derived-clone`; (2) across `objectstack`, `objectui` and `cloud`, every `.parse()`/`.safeParse()` on anything in this file is inside the file's own unit tests — objectui mirrors the props as hand-written React interfaces and imports only the inferred TYPES, `cloud` references none, and `react-blocks.ts` uses `Object.keys(ComponentPropsMap)` for type NAMES only (its `REACT_BLOCKS[].schema` entries all point at view/chart schemas); (3) empirically through the live door — `definePage()` IS `PageSchema.parse()` — an undeclared key written inside `components[].properties` parses clean and is RETAINED on 10/10 example-corpus pages, while the same key one level out is rejected on 10/10 (the negative control that makes the first number mean anything). ⚠️ **`no gate`, not `no door`** — the vocabulary is ALIVE and must not be retired: objectui's `SchemaRenderer` hoists `properties` onto the node and spreads every key not on its fixed deny-list straight into the React component, so a misspelled key is neither rejected nor dropped — it reaches the renderer and is ignored there, the ADR-0078 failure mode one layer below where this ratchet reaches. That IS the #4909 open-slot shape, but `.passthrough()` would be exactly as vacuous as `.strict()` on a schema nothing parses, so no posture change was made. The fix is to wire the parse at the carrier's own gate — a `packages/lint`/carrier change, filed as **#5068**, which also records the two constraints that stop it being a drive-by: `type` is an open union (`z.union([PageComponentType, z.string()])`, so `record:line_items`-style unregistered types are authored in the wild) and real pages already author shapes these schemas do not declare (`record:details` `sections[].fields[]`/`hideFields[]`, the record picker's `labelField` — `packages/lint/src/validate-page-field-bindings.ts` has documented the untyped bag all along). **Do not reschedule this as strictness work** — that is what the `(p)` was for, and it has been answered. Recorded in three places (file header, `component.test.ts` pin incl. a standing assertion that goes red the day `properties` gets a typed dispatch, this row) |
@@ -849,6 +850,7 @@ next person to open that file will look.
| `chart.zod.ts` | **authorable** | **was `no gate` until #5020** (the cell carries one verdict on purpose — it is the machine-readable input to the generated subtotal, so the history lives here in the evidence). `ChartAggregateSchema` + `ChartGroupBySchema`'s object arm. Config / axis / series / annotation / interaction closed at 批 15; these two were held OUT of the ratchet as `no gate` — carrier live, no parse — because closing them would have gated nothing (#4583). **#5020 wired the parse, so the hold is over and these two are ordinary strictness work again.** `packages/lint/src/validate-react-page-props.ts` now calls `ChartAggregateSchema.safeParse()` on a static `aggregate={{…}}` literal, and the hand-derived `CHART_FUNCTIONS` list + count/field refinement twin are deleted. That is the path **#5022 demonstrated on one key** and this row was blocked on: `ChartDrillDownSchema` arrived with its gate already wired, parsing instead of re-deriving, while `aggregate` beside it did the opposite. ⚠️ **The flip is `no gate` → `authorable`, NOT → closed.** Both sites still STRIP: the parse the gate runs drops `groupby` / `dateGranularty` rather than reporting them, so the ADR-0078 failure mode survives until the posture changes. Converting the two object arms to `strictObject` is **#5583** (Blocked-by resolved; a sub-issue of #4001), which is also where the two `chart.test.ts` "still STRIPS — deliberate" pins invert and where the one product question lands — `groupBy` is declared REQUIRED here and in the published react-blocks type while the renderer honours its absence, so #5020's gate reports that single case at `warning` instead of gating a shape the platform delivers |
| `i18n.zod.ts` | **split** · 5 no door | **批 16 closed the one real door**: `AriaPropsSchema` (`strictObject`, carried as `aria:` on ~30 shapes under six metadata-type roots — it was returning `aria: {}` for a legacy-spelled block). The 5 left are `I18nObject` / `PluralRule` / `NumberFormat` / `DateFormat` / `LocaleConfig`, all **no door** (#5055) — ⛔ **do not close them**. This row shrinks without disappearing, the third such in the ledger after `flow` (批 11) and `etl` (批 12): the reverse pin fires on ZERO, so a row parked at a deliberate floor looks exactly like a row nobody finished, and only the `Class` column separates them |
| `app.zod.ts` | verify | **批 19 ran the check and it came back NEGATIVE — no posture change, and the row's `Class` is held at `verify` deliberately (see below).** `BaseNavItemSchema`. The instruction here was to confirm the members' strictness was not already covering it before touching; it is, and the premise this row carried was wrong twice. (1) **The members do not `.extend()` the base — they spread `...BaseNavItemSchema.shape`.** That is a different mechanism, and the difference is the whole of finding 16: `.extend()` clones INHERIT the base's posture (which is how closing two `view` authoring schemas silently closed the Studio round-trip overlay), while a `...shape` spread copies the per-key schemas into a FRESH `z.object` whose posture is its own. Measured in both directions rather than read off the source, because *"closing the base closes the members"* and *"closing the base is a no-op"* are opposite claims: `strictBase.extend({…})` rejects an unknown key, `z.object({...strictBase.shape})` accepts it, `z.object({...openBase.shape}).strict()` rejects it. (2) **All nine branches already apply their own `.strict()`** with the curated `navItemUnknownKeyError` — asserted per branch through the real door (`AppSchema.navigation`, a `discriminatedUnion` on `type`), with a positive control (every base-contributed key, incl. `requiresService` which no branch declares itself, is ACCEPTED) and a negative control (an undeclared key is REJECTED) in the same run. The base is also module-private and has zero `.parse()` anywhere, so `.strict()` here would be a property of a parse that does not exist. Closing it is therefore a guaranteed no-op, and #4583 is explicit that a no-op closure is not neutral. ⚠️ **The open question is the VOCABULARY, not the measurement** — which is why the `Class` cell was not changed, since it is machine-read and a guess here would be published as a confident subtotal. The two-axis table above resolves carrier-absent + parse-absent to `no door`, whose prescribed follow-up is ADR-0049 retirement — and that prescription is *destructive* here: the vocabulary is fully ALIVE and fully GATED at nine consumers, so retiring the base would delete nine branches' shared keys. `no gate` is wrong for the mirror reason (the gate exists, at the members). `authorable` is the `FormFieldBaseSchema` precedent one row over in `view.zod.ts` — but that base really is `.extend()`ed, so closing it WOULD change behaviour, and calling this one `authorable` invites exactly the later sweep that "finishes the job" on a shape nothing parses. None of the eight enumerated verdicts is honest for a shape that is neither a door nor dead, and adding a ninth changes a machine-read contract — so the decision is the maintainer's (**#5249**). Recorded in three places (the `BaseNavItemSchema` JSDoc + `app-strictness-batch19.test.ts` + this row); the pin includes a guard that fails if any branch ever stops rejecting unknown keys, which is the one change that would make this verdict need re-taking |
+| `action-params.zod.ts` | wire | **out of scope** — `ActionSessionSchema`, the action-body `ctx.session` the runtime hands a body (#5697). Tolerant on purpose, same disposition as `data/hook.zod.ts`'s `HookContextSchema`. What this surface needed was never a closed door but a gate that RUNS: its consistency with the real producer is pinned in `packages/runtime/src/action-session-shape-contract.test.ts`, which asserts that a non-strict parse of the built object returns it UNCHANGED — so a key the builder starts producing without declaring it here is stripped, and the pin goes red |
`sharing.zod.ts` and `notification.zod.ts` left this table at **#5015** by a route no other row has taken: not by being CLOSED, but by having their remaining sites REMOVED. Both were `no door` — ADR-0049 territory, explicitly out of this ratchet's scope — and the enforce-or-remove call came back REMOVE, so `EmbedConfigSchema` and `NotificationActionSchema` are gone rather than strict. Read the reverse pin carefully here, because it fires on zero either way and cannot tell the two routes apart: the `sharing.zod.ts` row said in as many words that it *"shrinks without disappearing — the first `no door` floor"*, and that was true right up until the floor was retired out from under it. A deliberate floor and a retired one look identical from the count; only the `Class` column and this paragraph separate them. `sharing.zod.ts` keeps its TRIAGE row above, because `SharingConfigSchema` is still there and still strict — the file is closed, not empty. `notification.zod.ts` keeps no row anywhere: it has zero object sites left.
diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts
index 5726ffc5a1..4a72eb94dc 100644
--- a/packages/runtime/src/action-execution.ts
+++ b/packages/runtime/src/action-execution.ts
@@ -15,7 +15,7 @@
* here; that stays with the route handlers.
*/
-import { validateActionParams, type ResolvedActionParam } from '@objectstack/spec/ui';
+import { validateActionParams, type ActionSession, type ResolvedActionParam } from '@objectstack/spec/ui';
import type { ExecutionContext } from '@objectstack/spec/kernel';
import type { IObjectQLEngine, ServiceSlotContract, ServiceSlotContracts } from '@objectstack/spec/contracts';
import { checkApiExposure } from './api-exposure.js';
@@ -750,8 +750,22 @@ export function enforceActionParams(deps: ActionExecutionDeps,
* a distinct, configurable axis and stays. Returns undefined for a genuinely
* context-less / self-invoked call so a body can distinguish "no session" the
* same way hooks do.
+ *
+ * [#5697] The shape this returns is now DECLARED — {@link ActionSessionSchema}
+ * in `@objectstack/spec/ui`, phase 1 of #5613's contract-first ruling — and the
+ * annotation here is that declaration, not a restatement of it. Nothing about
+ * what this builds changed; the consistency between the two is pinned in
+ * `action-session-shape-contract.test.ts`.
+ *
+ * ⚠️ Two sentences above are tracked as WRONG, deliberately left standing for
+ * #5613 phase 2 to correct together with the rename they belong to: "mirroring
+ * the hook `ctx.session` shape" has not been true since #5050 retired
+ * `HookContext.session.roles` (the hook session is a different key set from a
+ * different producer), and the `roles` key this builds carries `ec.positions`,
+ * i.e. the ADR-0090 D3 vocabulary under the spelling that ADR bans. Read the
+ * schema's docblock before relying on either.
*/
-export function buildActionSession(_deps: ActionExecutionDeps, ec: any): any | undefined {
+export function buildActionSession(_deps: ActionExecutionDeps, ec: any): ActionSession | undefined {
if (!ec || (ec.userId == null && ec.tenantId == null)) return undefined;
return {
...(ec.userId != null ? { userId: String(ec.userId) } : {}),
diff --git a/packages/runtime/src/action-session-shape-contract.test.ts b/packages/runtime/src/action-session-shape-contract.test.ts
new file mode 100644
index 0000000000..0e34b52fce
--- /dev/null
+++ b/packages/runtime/src/action-session-shape-contract.test.ts
@@ -0,0 +1,122 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * [#5697] The action-body `ctx.session` consistency pin — what
+ * `buildActionSession()` BUILDS against what `ActionSessionSchema`
+ * (`@objectstack/spec/ui`) DECLARES.
+ *
+ * Phase 1 of #5613's contract-first ruling declared the shape without changing
+ * it. A declaration that nothing executes against is exactly the state #5613
+ * found (`actionContext` is a bare `any` at both dispatch sites, so the key set
+ * reached no schema and no gate), so the declaration arrives with the test that
+ * runs the real producer — the `hook-input-shape-contract.test.ts` shape from
+ * #5668, one surface over.
+ *
+ * It lives in `packages/runtime` for the same reason that one lives in
+ * `packages/objectql`: the pin must EXECUTE the producer, and `packages/spec`
+ * cannot import the runtime without inverting the dependency.
+ *
+ * ## What each assertion is actually guarding
+ *
+ * `ActionSessionSchema` is deliberately NOT strict (it is a runtime shape the
+ * platform hands a body — see the schema's docblock), so `safeParse().success`
+ * alone would stay green for a builder that started emitting an undeclared key.
+ * The load-bearing assertion is therefore `parse(built)` deep-equals `built`:
+ * a non-strict parse STRIPS what it does not declare, so equality is the fact
+ * that every key the builder produces is a key the contract names.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { ActionSessionSchema } from '@objectstack/spec/ui';
+
+import { buildActionSession } from './action-execution.js';
+
+/** `buildActionSession` never reads `deps`; the parameter is signature-only. */
+const deps: any = { resolveService: () => undefined, getObjectQL: async () => undefined };
+const build = (ec: unknown) => buildActionSession(deps, ec as any);
+
+describe('#5697 — action `ctx.session` matches its declared contract', () => {
+ it('builds exactly the declared keys, and the declaration covers all of them', () => {
+ const built = build({ userId: 'u_1', tenantId: 'org_acme', positions: ['sales_rep', 'org_admin'] });
+
+ // `roles`, not `positions` — the deprecated spelling is what the
+ // builder emits today and what the contract therefore declares. This
+ // assertion is the one #5613 phase 2 flips.
+ expect(Object.keys(built!).sort()).toEqual(['organizationId', 'roles', 'userId']);
+
+ // Non-strict parse: an UNDECLARED key would be silently stripped here,
+ // so deep equality — not `.success` — is what proves the contract
+ // covers everything the producer emits.
+ expect(ActionSessionSchema.parse(built)).toEqual(built);
+ expect(ActionSessionSchema.safeParse(built).success).toBe(true);
+ });
+
+ it('stringifies the ids the declaration types as strings', () => {
+ // The builder wraps both in `String(...)`; a driver handing back a
+ // numeric id would otherwise produce a session the contract rejects.
+ const built = build({ userId: 42, tenantId: 7, positions: [] });
+ expect(built).toEqual({ userId: '42', organizationId: '7' });
+ expect(ActionSessionSchema.safeParse(built).success).toBe(true);
+ });
+
+ it('translates `ExecutionContext.tenantId` to the blessed `organizationId`', () => {
+ const built = build({ tenantId: 'org_acme' });
+ expect(built).toEqual({ organizationId: 'org_acme' });
+ // The v11-removed alias (#3280 / #3290) must not reappear.
+ expect('tenantId' in built!).toBe(false);
+ });
+
+ it('carries `ec.positions` verbatim under the deprecated `roles` spelling', () => {
+ const positions = ['sales_rep', 'org_admin'];
+ const built = build({ userId: 'u_1', positions });
+ // Phase 2 (#5613) renames the KEY; this pins that the VALUE is, and
+ // stays, the ADR-0090 D3 vocabulary — so the rename is a rename and
+ // not a semantic change smuggled inside one.
+ expect((built as { roles?: string[] }).roles).toEqual(positions);
+ expect(ActionSessionSchema.safeParse(built).success).toBe(true);
+ });
+});
+
+describe('#5697 — conditional-spread semantics: absent means the KEY is absent', () => {
+ it('omits `organizationId` entirely when the context carries no tenant', () => {
+ const built = build({ userId: 'u_1' });
+
+ // MEASURED, not assumed, and the opposite of the hook path's #5668
+ // case: there `input.id` is a PRESENT key holding `undefined` because
+ // the engine builds it with a shorthand, so `'id' in input` answers
+ // true. Here the builder uses a conditional SPREAD, so the key does not
+ // exist at all. A body must not port an `in` test between the two.
+ expect('organizationId' in built!).toBe(false);
+ expect(Object.keys(built!)).toEqual(['userId']);
+ expect(ActionSessionSchema.parse(built)).toEqual(built);
+ });
+
+ it('omits `userId` entirely for an org-scoped call with no user', () => {
+ const built = build({ tenantId: 'org_acme', positions: ['org_admin'] });
+ expect('userId' in built!).toBe(false);
+ expect(Object.keys(built!).sort()).toEqual(['organizationId', 'roles']);
+ });
+
+ it('omits `roles` for an empty or absent positions array', () => {
+ expect(Object.keys(build({ userId: 'u_1', positions: [] })!)).toEqual(['userId']);
+ expect(Object.keys(build({ userId: 'u_1' })!)).toEqual(['userId']);
+ // A non-array `positions` is ignored rather than passed through — the
+ // declared `string[]` would otherwise be a lie the parse catches.
+ expect(Object.keys(build({ userId: 'u_1', positions: 'org_admin' })!)).toEqual(['userId']);
+ });
+});
+
+describe('#5697 — no identity envelope yields NO session, never an empty one', () => {
+ it.each([
+ ['undefined context', undefined],
+ ['empty context', {}],
+ ['positions only — no user, no org', { positions: ['org_admin'] }],
+ ])('%s → undefined', (_label, ec) => {
+ // #3712's distinction, on the action side: a body can tell "no identity
+ // envelope at all" from "an anonymous caller" only because this is
+ // `undefined` rather than `{}`. The third row is why `roles` can never
+ // appear alone — positions without a user and without an org produce no
+ // session for it to appear on.
+ expect(build(ec)).toBeUndefined();
+ });
+});
diff --git a/packages/runtime/src/sandbox/script-runner.ts b/packages/runtime/src/sandbox/script-runner.ts
index 0a40e4ff0a..c133735a0f 100644
--- a/packages/runtime/src/sandbox/script-runner.ts
+++ b/packages/runtime/src/sandbox/script-runner.ts
@@ -68,6 +68,28 @@ export interface ScriptContext {
input: unknown;
previous?: unknown;
user?: unknown;
+ /**
+ * The caller session. TWO different shapes reach this one field, because
+ * this interface is a single generic seam over both body kinds:
+ *
+ * - a HOOK body gets `HookContext.session` (`@objectstack/spec/data`) —
+ * `userId` / `actor` / `organizationId` / `accessToken` / `isSystem` /
+ * the skip flags, built by ObjectQL's `buildSession()`;
+ * - an ACTION body gets `ActionSession` (`@objectstack/spec/ui`,
+ * {@link ActionSessionSchema}) — `userId` / `organizationId` / `roles`,
+ * built by `buildActionSession()`.
+ *
+ * They are NOT the same object and never converge: `roles` exists on the
+ * action side only (and is deprecated there — #5613 phase 2 renames it to
+ * `positions`), while the hook side retired that key at #5050.
+ *
+ * Left `unknown` rather than typed as the union on purpose (#5697, which is
+ * a zero-behaviour-change declaration): narrowing this field would force
+ * every consumer of the seam — `quickjs-runner`'s `installCtx`, the body
+ * runners, the hook path's own producers — to discriminate a body kind this
+ * type does not carry. Typing it belongs with whatever change is willing to
+ * pay that, not with declaring what the producers already build.
+ */
session?: unknown;
/**
* The lifecycle event name the hook is firing for (e.g. `beforeInsert`,
diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json
index d01de75d12..33c4daba7a 100644
--- a/packages/spec/api-surface.json
+++ b/packages/spec/api-surface.json
@@ -3170,6 +3170,8 @@
"ActionParamIssue (interface)",
"ActionParamSchema (const)",
"ActionSchema (const)",
+ "ActionSession (type)",
+ "ActionSessionSchema (const)",
"ActionType (const)",
"AddRecordConfig (type)",
"AddRecordConfigSchema (const)",
diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json
index 8aa4d5a947..98b3a4bca7 100644
--- a/packages/spec/authorable-surface.json
+++ b/packages/spec/authorable-surface.json
@@ -6924,6 +6924,9 @@
"ui/ActionParam:requiresFeature",
"ui/ActionParam:type",
"ui/ActionParam:visible",
+ "ui/ActionSession:organizationId",
+ "ui/ActionSession:roles",
+ "ui/ActionSession:userId",
"ui/AddRecordConfig:enabled",
"ui/AddRecordConfig:formView",
"ui/AddRecordConfig:mode",
diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json
index da34730f5f..70fc145482 100644
--- a/packages/spec/json-schema.manifest.json
+++ b/packages/spec/json-schema.manifest.json
@@ -1460,6 +1460,7 @@
"ui/ActionLocation",
"ui/ActionNavItem",
"ui/ActionParam",
+ "ui/ActionSession",
"ui/ActionType",
"ui/AddRecordConfig",
"ui/App",
diff --git a/packages/spec/scripts/build-docs.ts b/packages/spec/scripts/build-docs.ts
index 6c9de938f0..e78c5d9a1b 100644
--- a/packages/spec/scripts/build-docs.ts
+++ b/packages/spec/scripts/build-docs.ts
@@ -448,7 +448,11 @@ const SECTION_GROUPS: Record
{ section: 'Content & Collaboration', pages: ['doc', 'book', 'collaboration'] },
],
ui: [
- { section: 'Apps & Navigation', pages: ['app', 'page', 'view', 'action'] },
+ // `action-params` sits beside `action` deliberately: it is the same
+ // surface's RUNTIME half (the `ctx.session` contract an action body reads,
+ // #5697), and a reader who found the action declaration should find what a
+ // body receives in the same section rather than under "More".
+ { section: 'Apps & Navigation', pages: ['app', 'page', 'view', 'action', 'action-params'] },
{ section: 'Visualization', pages: ['chart', 'dashboard', 'dataset', 'report', 'widget', 'component'] },
// `animation` / `dnd` / `keyboard` / `touch` / `offline` left this section at
// #4988: the five `ui/` interaction config modules were retired whole
diff --git a/packages/spec/src/ui/action-params.zod.ts b/packages/spec/src/ui/action-params.zod.ts
index 3485a01a99..cffd4567f3 100644
--- a/packages/spec/src/ui/action-params.zod.ts
+++ b/packages/spec/src/ui/action-params.zod.ts
@@ -1,7 +1,16 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
/**
- * Action-param VALUE validation (ADR-0104 D2).
+ * The action DISPATCH contract: what the platform validates on the way in, and
+ * what it hands the handler on the way out.
+ *
+ * Two halves, one surface. **Inbound** — action-param VALUE validation
+ * (ADR-0104 D2), below. **Outbound** — the runtime context an action body /
+ * handler receives: {@link ActionSessionSchema} (the `ctx.session` contract,
+ * #5697), {@link ActionEngineFacade}, {@link ActionHandlerContext} and
+ * {@link ActionHandler}.
+ *
+ * ## Inbound — action-param VALUE validation (ADR-0104 D2)
*
* An action's declared `params[]` is a complete value contract — `type`,
* `required`, `multiple`, `options`, `reference` — but before this it only
@@ -17,8 +26,11 @@
* resolved descriptors.
*/
+import { z } from 'zod';
+
import { valueSchemaFor } from '../data/field-value.zod';
import type { FieldErrorCode } from '../api/errors.zod';
+import { lazySchema } from '../shared/lazy-schema';
/**
* A declared action param resolved to its effective value-shape inputs. A
@@ -141,6 +153,102 @@ export interface ActionEngineFacade {
find(object: string, query: Record): Promise>>;
}
+/**
+ * The caller session an action BODY / handler reads as `ctx.session`.
+ *
+ * This is the CONTRACT for what `packages/runtime`'s `buildActionSession()`
+ * (`src/action-execution.ts`) produces today — phase 1 of #5613's
+ * contract-first ruling (#5697). It DECLARES the current shape and changes
+ * nothing about what the runtime builds.
+ *
+ * Why a declaration was worth its own change: `actionContext` is a bare `any`
+ * at both dispatch sites (`domains/actions.ts`, `action-execution.ts`) and the
+ * sandbox seam types `ScriptContext.session` as `unknown`, so this key set
+ * reached no schema, no gate and no generated reference page. It was
+ * declared-nowhere / produced-anyway — which is how the `roles` spelling below
+ * survived a vocabulary ban and a sibling retirement without either being
+ * noticed. A shape nothing declares is a shape nothing can catch drifting.
+ *
+ * ## Read the shape exactly — absent means the KEY IS ABSENT
+ *
+ * All three keys are optional and the builder emits them by CONDITIONAL
+ * SPREAD, so a missing value means the key is **not present**:
+ * `'organizationId' in ctx.session` answers `false`, not `undefined`. The hook
+ * path's `input.id` on a bulk write is the OPPOSITE case — key present, value
+ * `undefined`, because the engine builds it with a shorthand (#5668). The two
+ * are not interchangeable; do not port an `in` test between them.
+ *
+ * The session as a whole is `undefined` — never `{}` — for a call carrying
+ * neither `userId` nor `tenantId`, so a body can distinguish "no identity
+ * envelope at all" from "an anonymous caller" the same way a hook does
+ * (#3712). One consequence worth knowing: `roles` can never appear on its own,
+ * because a context with positions but no user and no org yields no session.
+ *
+ * ## NOT the hook `ctx.session`
+ *
+ * `HookContextSchema.session` (`@objectstack/spec/data`) is a DIFFERENT object,
+ * with a different key set (`actor`, `accessToken`, `isSystem`, the skip flags)
+ * from a different producer (ObjectQL's `buildSession()`). The
+ * `buildActionSession()` docblock still says it mirrors the hook shape; that
+ * sentence stopped being true at #5050, and correcting it rides with the
+ * phase-2 rename (#5613), not with this declaration.
+ *
+ * ## Deliberately NOT strict
+ *
+ * A runtime shape the platform hands a body, never authored — same posture and
+ * same reason as `HookContextSchema`: making an engine-side enrichment a
+ * breaking parse for whoever parses a context they were GIVEN inverts the
+ * contract. What the non-strict posture still buys is the pin in
+ * `packages/runtime/src/action-session-shape-contract.test.ts`: parsing the
+ * real built object must return it UNCHANGED, so any key the builder starts
+ * producing without declaring here is stripped and the pin goes red.
+ */
+export const ActionSessionSchema = lazySchema(() => z.object({
+ /**
+ * The invoking user's id (`ExecutionContext.userId`, stringified). Absent for
+ * a call with no user — a service/system invocation scoped only to an org.
+ */
+ userId: z.string().optional().describe('Invoking user id (absent when the call carries no user)'),
+
+ /**
+ * Active organization id — the blessed developer-facing name for the
+ * caller's current org, the same value as the `organization_id` column and
+ * `current_user.organizationId` (RLS). Sourced from
+ * `ExecutionContext.tenantId`, which is the distinct driver-level isolation
+ * axis and keeps its own name; the deprecated `session.tenantId` alias
+ * (#3280) was removed in v11 (#3290) and must not come back.
+ */
+ organizationId: z.string().optional().describe('Active organization id (blessed developer-facing name; absent when the call is org-less)'),
+
+ /**
+ * @deprecated ADR-0090 D3 — the forbidden spelling of `positions`. Declared
+ * because the runtime produces it, not because the name is blessed. The
+ * rename is #5613 phase 2; do NOT add a `positions` key here ahead of it.
+ */
+ roles: z.array(z.string()).optional().describe(
+ 'DEPRECATED — the VALUE is the caller\'s ADR-0090 D3 `positions` '
+ + '(`ExecutionContext.positions`, "Formerly `roles`"), delivered at this boundary under the one '
+ + 'spelling that vocabulary forbids. Declared here because `buildActionSession()` produces it '
+ + 'today — declaring current reality is not endorsing the name: ADR-0090 D3 makes `role` a '
+ + 'reserved-forbidden word, #4839 deleted the last two `roles.includes(\'admin\')` readers, and '
+ + '#5050 retired the hook-side `HookContext.session.roles` outright, so a body author currently '
+ + 'meets two different answers to one key name on one platform. The rename to `positions` — with '
+ + 'its deprecation window, ADR-0087 semantic migration and the `buildActionSession()` comment '
+ + 'correction — is #5613 phase 2. There is deliberately NO `positions` key on this shape yet: '
+ + 'minting one before the migration would ship two live spellings of one value, which is the '
+ + 'defect, not the fix. Never gate PRIVILEGE on this array — ask the security service, which '
+ + 'evaluates capability grants, placements and the derived posture (ADR-0095), never a '
+ + 'role-name string comparison.',
+ ),
+}).describe('Action-body `ctx.session` — the caller identity an action body reads (runtime shape, never authored)'));
+
+/**
+ * The parsed action-body `ctx.session`. `ActionHandlerContext.session` is this
+ * type, so the schema and the handler-facing type cannot drift into two shapes
+ * for one object.
+ */
+export type ActionSession = z.infer;
+
/**
* The runtime context an action handler receives (ADR-0104 D2). `params` is
* validated against the action's declared param contract at dispatch BEFORE
@@ -163,8 +271,21 @@ export interface ActionHandlerContext<
params: TParams;
/** The invoking principal. */
user: { id: string; name?: string; email?: string; organizationId?: string; [k: string]: unknown };
- /** Caller session (active org / roles), mirroring the hook `ctx.session`. */
- session?: { userId?: string; organizationId?: string; roles?: string[]; [k: string]: unknown };
+ /**
+ * Caller session — {@link ActionSessionSchema}, the declared contract for
+ * what `buildActionSession()` produces. Absent entirely for a call with no
+ * identity envelope.
+ *
+ * Previously an inline literal carrying a `[k: string]: unknown` catch-all
+ * and the claim that it mirrors the hook `ctx.session`. Both are gone: the
+ * shape now has ONE declaration (#5697), and it does not mirror the hook
+ * session — see the schema's docblock. Dropping the catch-all narrows no
+ * live call site (measured across objectstack / objectui / cloud: this
+ * interface has zero importers outside its own module today); the SCHEMA
+ * stays non-strict, so an extra key at runtime still parses — it just stops
+ * being something the type invites an author to invent.
+ */
+ session?: ActionSession;
/** Trusted engine facade for cross-object writes (see {@link ActionEngineFacade}). */
engine: ActionEngineFacade;
}