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; }