Skip to content

Commit 9fe9c1d

Browse files
os-zhuangclaude
andauthored
feat(spec): declare the action-body ctx.session contract (#5697) (#5721)
Phase 1 of #5613's contract-first ruling: declare what `buildActionSession()` builds today, change nothing about what it builds. - `ActionSessionSchema` / `ActionSession` in `@objectstack/spec/ui` (`ui/action-params.zod.ts`, beside the action runtime-context declarations it already carries): `{ userId?, organizationId?, roles? }`, non-strict, matching `HookContextSchema`'s wire posture. - `roles` declared as built and marked DEPRECATED in `.describe()` + JSDoc: the value is `ExecutionContext.positions` under the spelling ADR-0090 D3 forbids; the rename is #5613 phase 2 and no `positions` key is minted now. - Type-only on the runtime side: `buildActionSession()` returns `ActionSession | undefined`, `ActionHandlerContext.session` is the inferred type. `ScriptContext.session` stays `unknown` (one seam, two body kinds) with the two shapes documented. - Consistency pin executing the real producer: `packages/runtime/src/action-session-shape-contract.test.ts`. Claude-Session: https://claude.ai/code/session_018fxLGQdatPbBUvCgiVxg6D Co-authored-by: Claude <noreply@anthropic.com>
1 parent 50f04dc commit 9fe9c1d

14 files changed

Lines changed: 453 additions & 16 deletions
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/runtime": patch
4+
---
5+
6+
feat(spec): declare the action-body `ctx.session` contract (#5697)
7+
8+
An action body reads `ctx.session` on every dispatch, and until now **nothing
9+
declared it**. `actionContext` is a bare `any` at both dispatch sites
10+
(`domains/actions.ts`, `action-execution.ts`), the sandbox seam types
11+
`ScriptContext.session` as `unknown`, and the one spec-side mention was an
12+
inline literal on `ActionHandlerContext` carrying a `[k: string]: unknown`
13+
catch-all. Declared-nowhere, produced-anyway: no schema, no gate, no generated
14+
reference page, and nothing the liveness ledger could reach.
15+
16+
That is how the surface drifted without anyone noticing. Its `roles` key carries
17+
`ExecutionContext.positions` — the ADR-0090 D3 vocabulary handed to authors under
18+
the one spelling that ADR forbids — while the hook side retired its own
19+
`session.roles` at #5050. One platform, one key name, two opposite answers.
20+
21+
**`ActionSessionSchema` (`@objectstack/spec/ui`) declares that shape as built.**
22+
23+
```ts
24+
{ userId?: string; organizationId?: string; roles?: string[] }
25+
```
26+
27+
This release changes **nothing about what the runtime produces** — it is phase 1
28+
of #5613's contract-first ruling, and declaring current reality is deliberately
29+
not the same as endorsing it:
30+
31+
- `roles` is declared **deprecated** in its `.describe()` and its JSDoc. The
32+
rename to `positions`, with a deprecation window and an ADR-0087 semantic
33+
migration, is #5613 phase 2. There is deliberately **no `positions` key yet**
34+
minting one before the migration would ship two live spellings of one value.
35+
- The schema is **not strict**, matching `HookContextSchema`: this is a runtime
36+
shape the platform hands a body, never authored, and closing it would turn a
37+
future engine-side enrichment into a parse failure for whoever parses a context
38+
they were given.
39+
40+
Three facts the declaration now states, all of them previously discoverable only
41+
by reading the builder:
42+
43+
- **Absent means the key is absent.** The builder uses conditional spreads, so
44+
`'organizationId' in ctx.session` answers `false` — not "present and
45+
`undefined`". The hook path's `input.id` on a bulk write is the opposite case
46+
(#5668); an `in` test does not port between them.
47+
- **No identity envelope yields no session at all**`undefined`, never `{}`, so
48+
a body can tell "no caller" from "an anonymous caller" (#3712). One consequence:
49+
`roles` never appears on its own.
50+
- **`organizationId` is the blessed name** for the caller's active org; the
51+
v11-removed `session.tenantId` alias (#3280 / #3290) does not come back.
52+
53+
Type-only on the runtime side, no behaviour change: `buildActionSession()` now
54+
declares `ActionSession | undefined` instead of `any | undefined`, and
55+
`ActionHandlerContext.session` is the schema's inferred type rather than an
56+
inline literal with a catch-all. A handler annotated with `ActionHandler` that
57+
read an undeclared key off `ctx.session` now gets a compile error naming it —
58+
that key was never produced. `ScriptContext.session` deliberately stays
59+
`unknown`: it is one seam over both body kinds, and hook and action sessions are
60+
different objects.
61+
62+
The declaration ships with the gate it needed —
63+
`packages/runtime/src/action-session-shape-contract.test.ts` executes the real
64+
producer and asserts a non-strict parse of the built object returns it
65+
**unchanged**, so a key the builder starts producing without declaring here is
66+
stripped and the pin goes red.
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
---
2+
title: Action Params
3+
description: Action Params protocol schemas
4+
---
5+
6+
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
7+
8+
The action DISPATCH contract: what the platform validates on the way in, and
9+
10+
what it hands the handler on the way out.
11+
12+
Two halves, one surface. **Inbound** — action-param VALUE validation
13+
14+
(ADR-0104 D2), below. **Outbound** — the runtime context an action body /
15+
16+
handler receives: `ActionSessionSchema` (the `ctx.session` contract,
17+
18+
#5697), `ActionEngineFacade`, `ActionHandlerContext` and
19+
20+
`ActionHandler`.
21+
22+
## Inbound — action-param VALUE validation (ADR-0104 D2)
23+
24+
An action's declared `params[]` is a complete value contract — `type`,
25+
26+
`required`, `multiple`, `options`, `reference` — but before this it only
27+
28+
informed the client dialog: the server passed `reqBody.params` straight to
29+
30+
the handler, unvalidated (`http-dispatcher.ts`). This module is the pure
31+
32+
contract that lets the REST and MCP dispatch paths enforce that declaration
33+
34+
BEFORE the handler runs, reusing the D1 field value-shape contract
35+
36+
(`valueSchemaFor`).
37+
38+
Purity: schema derivation only (Prime Directive #2). Field-backed params are
39+
40+
resolved to their effective value-shape inputs by the CALLER (the runtime,
41+
42+
which holds the object metadata registry); this module validates the already
43+
44+
resolved descriptors.
45+
46+
<Callout type="info">
47+
**Source:** `packages/spec/src/ui/action-params.zod.ts`
48+
</Callout>
49+
50+
## TypeScript Usage
51+
52+
```typescript
53+
import { ActionSessionSchema } from '@objectstack/spec/ui';
54+
import type { ActionSession } from '@objectstack/spec/ui';
55+
56+
// Validate data
57+
const result = ActionSessionSchema.parse(data);
58+
```
59+
60+
---
61+
62+
## ActionSession
63+
64+
Action-body `ctx.session` — the caller identity an action body reads (runtime shape, never authored)
65+
66+
### Properties
67+
68+
| Property | Type | Required | Description |
69+
| :--- | :--- | :--- | :--- |
70+
| **userId** | `string` | optional | Invoking user id (absent when the call carries no user) |
71+
| **organizationId** | `string` | optional | Active organization id (blessed developer-facing name; absent when the call is org-less) |
72+
| **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. |
73+
74+
75+
---
76+

content/docs/references/ui/index.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ This section contains all protocol schemas for the ui layer of ObjectStack.
77

88
<Cards>
99
<Card href="/docs/references/ui/action" title="Action" description="Source: packages/spec/src/ui/action.zod.ts" />
10+
<Card href="/docs/references/ui/action-params" title="Action Params" description="Source: packages/spec/src/ui/action-params.zod.ts" />
1011
<Card href="/docs/references/ui/app" title="App" description="Source: packages/spec/src/ui/app.zod.ts" />
1112
<Card href="/docs/references/ui/bulk-action" title="Bulk Action" description="Source: packages/spec/src/ui/bulk-action.zod.ts" />
1213
<Card href="/docs/references/ui/chart" title="Chart" description="Source: packages/spec/src/ui/chart.zod.ts" />

content/docs/references/ui/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"pages": [
44
"---Apps & Navigation---",
55
"action",
6+
"action-params",
67
"app",
78
"page",
89
"view",

docs/audits/2026-07-unknown-key-strictness-ledger.counts.md

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,17 +21,17 @@ regenerate.
2121
| Measure | Value |
2222
|---|---|
2323
| Triaged directories | 5 |
24-
| Object sites in them | 454 |
25-
| Still-open (strip) sites | 195 |
26-
| Files carrying at least one | 29 |
24+
| Object sites in them | 455 |
25+
| Still-open (strip) sites | 196 |
26+
| Files carrying at least one | 30 |
2727

2828
Remaining strip sites by class:
2929

3030
| Bucket | Sites |
3131
|---|---|
3232
| authorable — the ruling's forced scope | 13 |
3333
| unresolved — needs a per-schema verdict | 33 |
34-
| wire / open — out of forced scope | 106 |
34+
| wire / open — out of forced scope | 107 |
3535
| no door — no carrier, ADR-0049 territory | 14 |
3636
| no gate — carrier live, no parse | 29 |
3737

@@ -43,12 +43,12 @@ The `strict` column is the one the campaign schedules against; it counts both th
4343

4444
| Dir | Sites | strict | passthrough | catchall | strip |
4545
|---|---|---|---|---|---|
46-
| `ui/` | 170 | 116 | 5 | 0 | 49 |
46+
| `ui/` | 171 | 116 | 5 | 0 | 50 |
4747
| `data/` | 162 | 54 | 1 | 0 | 107 |
4848
| `automation/` | 75 | 49 | 0 | 0 | 26 |
4949
| `security/` | 20 | 7 | 0 | 0 | 13 |
5050
| `studio/` | 27 | 27 | 0 | 0 | 0 |
51-
| **total** | **454** | **253** | **6** | **0** | **195** |
51+
| **total** | **455** | **253** | **6** | **0** | **196** |
5252

5353
## File-level triage — site counts
5454

@@ -60,6 +60,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
6060

6161
| File | Sites |
6262
|---|---|
63+
| `action-params.zod.ts` | 1 |
6364
| `action.zod.ts` | 8 |
6465
| `app.zod.ts` | 18 |
6566
| `bulk-action.zod.ts` | 3 |
@@ -75,7 +76,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
7576
| `theme.zod.ts` | 6 |
7677
| `view.zod.ts` | 53 |
7778
| `widget.zod.ts` | 9 |
78-
| **total** | **170** |
79+
| **total** | **171** |
7980

8081
### `data/` — sites
8182

@@ -155,23 +156,24 @@ over it is here.
155156

156157
### `ui/` — open
157158

158-
**49 strip of 170**, in 6 file(s).
159+
**50 strip of 171**, in 7 file(s).
159160

160161
| File | Strip | Sites |
161162
|---|---|---|
163+
| `action-params.zod.ts` | 1 | 1 |
162164
| `app.zod.ts` | 1 | 18 |
163165
| `chart.zod.ts` | 2 | 8 |
164166
| `component.zod.ts` | 29 | 29 |
165167
| `i18n.zod.ts` | 5 | 6 |
166168
| `view.zod.ts` | 3 | 53 |
167169
| `widget.zod.ts` | 9 | 9 |
168-
| **total** | **49** | **170** |
170+
| **total** | **50** | **171** |
169171

170172
| Bucket | Sites |
171173
|---|---|
172174
| authorable — the ruling's forced scope | 4 |
173175
| unresolved — needs a per-schema verdict | 0 |
174-
| wire / open — out of forced scope | 2 |
176+
| wire / open — out of forced scope | 3 |
175177
| no door — no carrier, ADR-0049 territory | 14 |
176178
| no gate — carrier live, no parse | 29 |
177179

0 commit comments

Comments
 (0)