Skip to content

Commit 61dd96f

Browse files
os-litantclaude
andauthored
spec(ui): subtract the unenforced context key from ActionEngineFacade.find's query envelope (#19315)
Fixes #19237 Clause-②: no ⚠️ Notation: TypeScript angle brackets are written with PARENTHESES throughout this body — `Omit(EngineQueryOptions, 'context')` means the `Omit` utility type. The platform rewrites tag-shaped fragments in a body, and a fence does not protect them, so the real spelling lives in the diff. The action facade's `find` accepted a caller-written `context` that type-checked and the runtime did not honour — ADR-0049's declared-but-unenforced shape on the one key that carries identity and tenant. This takes the **remove** arm, at the declaration layer only: the parameter becomes `Omit(EngineQueryOptions, 'context')`. **No runtime behaviour changes.** ## The premise, measured FIRST — it HOLDS The dispatch made the ruling conditional on a census: *no call site writes a `context` on a facade query and relies on it to narrow identity or tenant*. Measured before a line of fix was written. **Instrument** (`census3.mjs`, three arms, run against the tree at `1739f71879f`): | Arm | What it matches | Why it exists | |:---|:---|:---| | A | `RECV.engine.VERB(` where `RECV` is an action-ctx name | the canonical handler spelling | | B | bare `engine.VERB(` in a file that destructures `engine` out of a ctx | `examples/app-todo` writes it this way | | C | `V.VERB(` where `V` is assigned from `buildActionEngineFacade(...)` | closes arm A/B's blind spot: a facade held in a local variable | Each call's second argument is extracted by **balanced-paren scan**, not a line regex, so a multi-line envelope is read whole. **Radius**: 8292 tracked text files — the whole repository, not the importers of `ActionEngineFacade`. That denominator is deliberate, and it is the one PR #19223 warned about: the facade is reached through `ActionHandlerContext.engine`, so an importer count of the facade type is the wrong population. **Readings**, exit codes captured before any pipe: - facade call sites **123** (armA 86, armB 10, armC 27); of these **47 are `find`** - sites writing a `context` key: **11** - `find` sites writing a `context` key: **1** That one is `packages/runtime/src/action-engine-facade-find-envelope.test.ts:126` — **the pin that asserts the key is NOT honoured**, added by #19223. It is the instrument's **firing control**: arm C demonstrably sees a real facade `find` carrying a `context`. The other 10 are not facade sites, and each was classified by reading the file rather than by name: - 7 in `packages/objectql/src/internal-fields.test.ts` — `ctx` there is `Awaited(ReturnType(typeof buildEngine))`, a **real ObjectQL engine**; sibling calls to `findOne` and `aggregate` are members `ActionEngineFacade` does not declare. - 3 in `action-engine-facade-find-envelope.test.ts:209-211` — a different `engine`, built by the file's own `makeRealEngine()`; they pass a third argument, and the facade's `insert` takes two. **Dark control**: the same instrument with a member and a builder that cannot exist (`.engineZZZQ`, `buildActionEngineFacadeZZZQ`) — exit 1, `FACADE_SITES total=0` on all three arms. **Sibling radius**: `objectui` at `dda8f3815df` — `git grep` for `ActionEngineFacade`, `ActionHandlerContext` and `ctx.engine.` exits **1 / 0 hits**, with a firing control in the same tree (a token that certainly exists) exiting 0. ⇒ **Zero live call sites.** The p0 upgrade trigger does not fire. `priority:p1` stands. ## Mechanism: OVERRIDE, not drop — traced to a named line At `origin/main` = `1739f71879f`, read 2026-09-20T08:42Z: `packages/runtime/src/action-execution.ts:1620` const rows = await ql.find(object, { ...(query ?? {}), context } as any); `context` is spread **last**, after the caller's envelope, so the facade's own elevated `ExecutionContext` (minted at `:1560` by `buildActionExecutionContext(ec)`) replaces whatever the caller put under that key. The key reaches the engine; the caller's **value** does not. PR #19223's body claim holds on today's tree, and it is override rather than drop. ## The third card fact: the sibling arms do NOT share the shape `ActionEngineFacade` declares exactly four members, and only one takes an options bag: insert(object, data) update(object, id, data) delete(object, idOrIds) find(object, query) ← the only bag There is no `findOne` and no `count` on this facade. The write doors have nowhere to carry a `context` at the type level, so there is nothing to price and nothing to widen this diff onto. Reported as measured, per the order. ## What changed - **`packages/spec/src/ui/action-params.zod.ts`** — the declaration. `find(object, query: Omit(EngineQueryOptions, 'context'))`, plus the member doc rewritten: why the key is gone, and the asymmetry it leaves. - **`packages/spec/src/ui/action-params.test.ts`** — #15124's identity pin retargeted to the narrowed shape; a second pin that reds **only** when `context` becomes writable again; a value-level refusal pin with a positive control. - **`packages/runtime/src/action-execution.ts`** — **comment only, zero behaviour.** The arm's docblock now states that the type no longer admits the key while this arm still does, and why closing that half is not a type narrowing's business. - **`content/docs/ui/actions.mdx`** — the callout gains the one subtraction. - Generated: **none**. This PR originally regenerated `api-surface-declarations/ui.txt`; main deleted that whole artefact family (17 shards) in `2277d1fcd10`, so the regeneration was dropped in the merge. The artefact that replaced it, `api-surface-signatures.json`, does **not** move for this narrowing — `gen:api-surface` rewrites it byte-identically (blob `b2099d11828`), because it hashes `checker.typeToString()` of the 27 `defineX` factories, which prints a type reference without expanding it. ## The pin is TYPE-level, and that is deliberate `FindQueryCarriesNoContextKey` asserts the key is absent from the declared slot; the two `@ts-expect-error` directives red if a literal carrying `context` starts compiling. A **runtime** pin would assert a refusal that does not exist and must not: adding one makes the facade throw on an identity key, which is a runtime permission change no ruling covers. The runtime's own pin is untouched and still green. The file is inside the checked zone — `check:test-typecheck` reports `packages/spec/tsconfig.test.json` compiling 54 files — so these are not phantom directives. ## Reverse verification Fix committed first, then the declaration alone reverted to `EngineQueryOptions`: - **on-disk proof** — narrowed spelling 1 → 0, widened 0 → 1, blob `3f73de3ae0f` → `58f5dc6b90e`; a no-op edit would have been caught here and the reading voided. - **ablated** `pnpm --filter @objectstack/spec typecheck` → **exit 1**, `src/ui/action-params.test.ts: 4 type error(s)` — the two asserts plus the two now-unused `@ts-expect-error` directives. - **restored** with `git checkout HEAD -- PATH` (never a bare checkout, which reads the polluted index): `git diff HEAD` empty and `git hash-object` back to `3f73de3ae0f`, byte-identical. A trap on EXIT/INT/TERM carried the restore, with an absolute repo root. Direction predicted before the run and observed: **red**. ## Verification — per consumer package, on the merged head `d55c3d9e772` | Package | Reading | |:---|:---| | `@objectstack/spec` | 500 files / **14644** tests passed · `typecheck` exit 0 | | `@objectstack/runtime` | 268 files / **3705** passed, 1 skipped · `typecheck` exit 0 | | `@objectstack/objectql` | 300 files / **5009** passed · `typecheck` exit 0 | | `@objectstack/example-todo` | 7 files / **238** passed · `typecheck` exit 0 | `@objectstack/objectql` is **not** on the dispatched floor list: the census found it, at `packages/objectql/src/engine-write-not-found-gate.test.ts`, which builds a real facade through `buildActionEngineFacade`. Run because it is a consumer, and said so. ⚠️ One reading was thrown away rather than reported: the first `runtime` run answered *242 test files failed / 7 tests failed*, which was `Cannot find package` on an unbuilt dependency closure — PREREQUISITE NOT MET, not a red. Re-run after `pnpm --filter '@objectstack/example-todo^...' build` and reported above. **Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, derived from this tree, re-derived after the merge (same 107, no families added or dropped): **107 of 107 green**, each exit code redirected to its own file and read back before any pipe, then reconciled with `--ran` carrying the codes — `107 run, 0 NOT-MEASURED (a DERIVED zero)`. Two needed a second run, and both were prerequisite misses rather than reds: `check:skill-examples` (exit 1, `packages/client-react/dist` unbuilt) and `check:dual-build-cjs-loads` (exit 3, its own `PREREQUISITE NOT MET — ⛔ This is NOT a pass`). Both green after building the missing packages. `check:pm-widening-tells` is **green** — the T1 tell that card #19099 records against this shape did not fire, so the `Clause-②: no` declaration needed no over-declaring to get past a gate. **Lint, repo-wide rather than narrowed:** `eslint . --no-inline-config` over all **6916** files eslint's own config judges — **0 errors, 0 warnings**, exit 0, at `d55c3d9e772`. The file count is read from eslint's own `--format json` output, not estimated. No type-aware linting is configured (`eslint.config.mjs` states it carries no `parserOptions.project` and no typed rules), so nothing in this diff can move an untouched file's verdict. ## Declaration `Clause-②: no` — this puts no new key on a published payload; it removes one from a parameter type. The lane charter's line that a narrowing does not trigger clause ② is the criterion, and `check:pm-widening-tells` agrees with it mechanically. The **changeset** separately carries `Clause-②: no (narrowing)`, which is signal (4) to `check-adr-0087-registration`: an accept-set narrowing on a published type is exactly what #16421 built that signal for, so it is declared rather than left to prose, with an `already-registered` disposition naming `action-engine-facade-find-query-envelope` — the entry #19223 landed, which already tells an upgrader that a caller-supplied `context` is ignored. That gate is green. ## Acceptance notes **Noted, not filed — the asymmetry this leaves, stated so nobody reads it as an oversight.** After this diff the facade's `find` arm refuses (at runtime) every top-level key the envelope does not carry, accepts-and-honours the ones it does, and accepts-and-**overrides** exactly one: `context`, for untyped callers only. Closing that last cell means a runtime refusal on an identity key — the maintainer's floor, not a dev's and not a seat's, and the dispatch prohibited taking it here. It is recorded on both halves of the contract (the spec member doc and the runtime arm's docblock, the latter with an explicit "do not finish the job here without a ruling"). **Carrier: whoever holds the next ruling on this surface** — there is no PR or person this file is waiting on today, so it is written down where the next editor of either half will read it, rather than filed as a card nobody is dispatched to. ⚠️ **Not a finding, but worth one line for the next census on this surface:** a receiver-name heuristic over `.engine.` is not sound here — 10 of the 11 `context` writers it flags are the data engine, which honours the key. Only a type or construction anchor (`buildActionEngineFacade`, or the `findOne`/`aggregate` members the facade lacks) separates the two populations. --- _Generated by [Claude Code](https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho)_ --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4b58dcf commit 61dd96f

5 files changed

Lines changed: 159 additions & 19 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
spec(ui): `ActionEngineFacade.find` no longer accepts a `context` on its query envelope
6+
7+
Clause-②: no (narrowing)
8+
9+
`ctx.engine.find(object, query)` takes `Omit<EngineQueryOptions, 'context'>` — the
10+
engine's query envelope with exactly one key subtracted. Every other key is
11+
unchanged and still read off the engine's own type by reference.
12+
13+
**Why.** The action facade is trusted and context-less by design: the runtime
14+
stamps its own elevated `ExecutionContext` last, so a caller-supplied `context`
15+
was overridden, never honoured. The key was nonetheless *declared* on the
16+
parameter, which made this a declared-but-unenforced key on the one thing
17+
`context` carries — identity and tenant. A handler could write
18+
`context: { tenantId: 'org_acme' }`, type-check clean, and get the facade's
19+
context instead: a read its author believes is tenant-scoped, silently broader
20+
than intended. ADR-0049 admits enforce or remove; removal is the exit that
21+
changes no runtime behaviour.
22+
23+
**Migration.** Delete the key. There is nothing to replace it with, because it
24+
never did anything: a `find` that carried one returned exactly the rows it
25+
returns without one. To scope a read, put the scope in `where`.
26+
27+
| You wrote | Write instead |
28+
| --- | --- |
29+
| `ctx.engine.find('task', { where: { … }, context: { tenantId } })` | `ctx.engine.find('task', { where: { … } })` |
30+
| `ctx.engine.find('task', { where: { … } })` | unchanged |
31+
32+
`tsc --noEmit` over a consumer's handlers finds every occurrence, because the
33+
key is now an excess property on a fresh literal. ⚠️ Only where the handler is
34+
annotated with the published `ActionHandlerContext`: an untyped handler (a JS
35+
config body, a local copy of the context type, `(ctx: any)`) still passes the
36+
key and still has it overridden, silently, exactly as before. The runtime arm is
37+
deliberately unchanged — refusing an identity key there is a runtime behaviour
38+
change, not a declaration narrowing.
39+
40+
<!-- adr-0087: not-required (already-registered action-engine-facade-find-query-envelope) that entry announces this parameter's shape and already states the rule this diff makes the compiler enforce — "A caller-supplied `context` is ignored: the facade is trusted and stamps its own elevated one." A consumer that followed it has no `context` left to remove, so this narrowing adds no migration step to the ledger. -->

‎content/docs/ui/actions.mdx‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,16 @@ The parameter is typed `EngineQueryOptions` (`ActionEngineFacade` in
189189
a `FilterCondition` variable. A hand-written test double must honour the envelope
190190
too.
191191

192+
**One envelope key is not on this parameter: `context`.** A handler's
193+
`ctx.engine` is trusted and mints its own elevated `ExecutionContext`, so a
194+
`context` you write here would be overridden, never honoured — and writing one
195+
reads as narrowing a query to an identity or a tenant when it does nothing of
196+
the kind. It is subtracted from the type, so `ctx.engine.find('todo_task', {
197+
where: { … }, context: { tenantId: 'org_acme' } })` is a compile error rather
198+
than a silent no-op. To scope a read, put the scope in `where`. (An *untyped*
199+
handler still passes the key and still has it overridden, with no error — one
200+
more reason to annotate `ctx`.)
201+
192202
If you do **not** annotate it — a handler in an `objectstack.config.js` /
193203
`.mjs`, your own copy of the context type, or `(ctx: any)` — the facade refuses
194204
the bare filter at **runtime** instead, naming the stray key and prescribing the

‎packages/runtime/src/action-execution.ts‎

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1602,15 +1602,31 @@ export function buildActionEngineFacade(_deps: ActionExecutionDeps, ql: any, ec?
16021602
// across every customer's data model to refuse it — one platform, one
16031603
// query shape. The spec member (`ActionEngineFacade.find`,
16041604
// `packages/spec/src/ui/action-params.zod.ts`) now declares
1605-
// `EngineQueryOptions` by identity, so the handler writes what the
1605+
// `EngineQueryOptions` minus `context`, so the handler writes what the
16061606
// engine reads and this arm only adds the identity.
16071607
//
16081608
// `context` is spread LAST on purpose: the facade is trusted and
16091609
// context-less by design (#3914, ADR-0096), so the elevated context it
1610-
// built wins over any `context` a caller put in the envelope. The
1611-
// envelope admits the key because every engine option bag does; it is
1612-
// not an authorization the caller gets to choose. Pinned in
1610+
// built wins over any `context` a caller put in the envelope. It is not
1611+
// an authorization the caller gets to choose. Pinned in
16131612
// `action-engine-facade-find-envelope.test.ts`.
1613+
//
1614+
// ⚠️ [#19237] The TYPE no longer admits the key — the spec member
1615+
// subtracts it with `Omit`, ADR-0049's remove arm — but THIS ARM IS
1616+
// UNCHANGED and still accepts it. Two halves, deliberately asymmetric:
1617+
//
1618+
// - a TYPED caller now gets a compile error at the call site, which
1619+
// is the whole of the #19237 remedy;
1620+
// - an UNTYPED one (a JS config handler, a local copy of the context
1621+
// type, `(ctx: any)`) still passes a `context` and still has it
1622+
// overridden here, silently, exactly as before.
1623+
//
1624+
// Closing the second half means making this arm THROW on an identity
1625+
// key, which is a runtime permission behaviour change and not a thing a
1626+
// type narrowing gets to smuggle in. `findEnvelopeKeys()` therefore
1627+
// still reads `context` off `EngineQueryOptionsSchema` as legal, and
1628+
// the override — not a refusal — is what the untyped channel gets.
1629+
// ⛔ Do not "finish the job" here without a ruling that covers it.
16141630
async find(object: string, query?: Record<string, unknown>): Promise<Array<Record<string, unknown>>> {
16151631
// …and the withdrawn shape is refused HERE, before the engine, so
16161632
// the untyped channel gets the same answer the type gives

‎packages/spec/src/ui/action-params.test.ts‎

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -414,16 +414,43 @@ type FindQuery = Parameters<ActionEngineFacade['find']>[1];
414414
// the strict mutual-assignability test, so neither `Record<string, unknown>`
415415
// nor `FilterCondition` — the type this slot carried between #14175 and
416416
// #15124, and the one it must not drift back to — satisfies it. Reading the
417-
// engine's published type BY IDENTITY is the whole point of the ruling ("one
417+
// engine's published type BY REFERENCE is the whole point of the ruling ("one
418418
// platform, one query shape"): a structural copy of the envelope would pass a
419419
// weaker pin and then drift the moment the engine's own options grow a key.
420420
// Exported, as the sibling pins are, so `noUnusedLocals` does not read a type
421421
// that exists only to be checked as one that is never used.
422-
export type FindQueryIsEngineQueryOptions = Assert< Eq< FindQuery, EngineQueryOptions > >;
422+
//
423+
// #19237 subtracts exactly ONE key from that reference. The pin is written as
424+
// `Omit<EngineQueryOptions, 'context'>` rather than a spelled-out key list for
425+
// the same reason the slot is: the envelope's OTHER keys stay by reference, so
426+
// a key the engine grows tomorrow is reachable from a handler on the same day
427+
// without touching this line, and the only thing this file asserts about the
428+
// envelope's content is the subtraction itself.
429+
export type FindQueryIsEngineQueryOptionsWithoutContext =
430+
Assert< Eq< FindQuery, Omit< EngineQueryOptions, 'context' > > >;
431+
432+
// The subtraction, stated as its own fact rather than inferred from the `Eq`
433+
// above — because the two fail differently and a reader needs to know WHICH
434+
// moved. `Eq` reds for any drift at all (a re-widening, a re-narrowing, a
435+
// rename of the type behind the slot); this one reds only when `context`
436+
// becomes writable again, which is the ADR-0049 regression #19237 closed.
437+
//
438+
// ⚠️ This is a TYPE-level pin, deliberately, and not a runtime one. The card's
439+
// remedy is a pure narrowing of a declaration: the runtime's behaviour is
440+
// UNCHANGED (`buildActionEngineFacade` still spreads its own context last, and
441+
// its `assertActionEngineFindEnvelope` still reads `context` off
442+
// `EngineQueryOptionsSchema` as a legal key for the untyped channel). A
443+
// runtime pin here would assert a refusal that does not exist and must not:
444+
// adding one is a runtime permission change, which is not this card's to make.
445+
// The runtime side of the contract keeps its own pin, unchanged, in
446+
// `packages/runtime/src/action-engine-facade-find-envelope.test.ts`.
447+
export type FindQueryCarriesNoContextKey =
448+
Assert< Eq< 'context' extends keyof FindQuery ? true : false, false > >;
423449

424450
describe('#15124 — ActionEngineFacade.find takes the engine query envelope, never a bare filter', () => {
425451
it('types the second parameter as the published `EngineQueryOptions` (the tsc channel)', () => {
426-
// The value-level half of `FindQueryIsEngineQueryOptions` above: a literal
452+
// The value-level half of `FindQueryIsEngineQueryOptionsWithoutContext`
453+
// above: a literal
427454
// annotated with the slot type, so the runtime run exercises the same
428455
// declaration the type pin reads.
429456
const query: FindQuery = { where: { position_code: 'qa_lead', active: true } };
@@ -486,6 +513,29 @@ describe('#15124 — ActionEngineFacade.find takes the engine query envelope, ne
486513
expect([whereNotFilter, fieldsNotArray, limitNotNumber]).toHaveLength(3);
487514
});
488515

516+
it('#19237 REFUSAL PIN — `context` is not an envelope key on THIS facade (ADR-0049 remove arm)', () => {
517+
// The key the engine honours and this facade does not. It was declared
518+
// here and unenforced between #15124 and #19237: the write below
519+
// type-checked, and the runtime stamped the facade's own elevated context
520+
// over it with no signal — so an author who wrote `context: { tenantId }`
521+
// believing they had NARROWED the read got a broader one.
522+
//
523+
// `@ts-expect-error` is the whole assertion: if the slot ever re-admits
524+
// the key, the directive goes unused and `tsc -p tsconfig.test.json` reds.
525+
// @ts-expect-error — `context` is subtracted from this parameter; the facade mints its own and a caller-supplied one is never honoured.
526+
const narrowingAttempt: FindQuery = { where: { status: 'open' }, context: { tenantId: 'org_acme' } };
527+
// @ts-expect-error — the same on its own, with no other key to carry the literal.
528+
const contextAlone: FindQuery = { context: { isSystem: true } };
529+
530+
// POSITIVE CONTROL, in the same test: the rest of the envelope is
531+
// untouched by the subtraction. Without this, a pin that "passes" because
532+
// the whole parameter degenerated to `never` would read exactly the same.
533+
const rest: FindQuery = { where: { status: 'open' }, fields: ['id'], orderBy: [{ field: 'id', order: 'asc' }], limit: 5, offset: 0 };
534+
535+
expect([narrowingAttempt, contextAlone]).toHaveLength(2);
536+
expect(Object.keys(rest)).toEqual(['where', 'fields', 'orderBy', 'limit', 'offset']);
537+
});
538+
489539
it('the refusal survives the VARIABLE path too — not just the object-literal check', () => {
490540
// The obvious worry about narrowing an all-optional target is that only
491541
// FRESH object literals get the excess-property check, so a filter reaching

‎packages/spec/src/ui/action-params.zod.ts‎

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -233,9 +233,10 @@ export function validateActionParams(
233233
*
234234
* Two members carry an argument contract the signature alone does not settle,
235235
* and both state it on the member: `find` takes the engine's own query
236-
* ENVELOPE — {@link EngineQueryOptions}, by identity, the same type
237-
* `IDataEngine.find` takes — and the bare-filter parameter shape #14175 chose
238-
* is withdrawn (#15124); `delete` accepts a single id OR an array of them,
236+
* ENVELOPE — {@link EngineQueryOptions} minus its `context` key, the same type
237+
* `IDataEngine.find` takes with the one key this facade will not honour
238+
* subtracted (#15124, #19237) — and the bare-filter parameter shape #14175
239+
* chose is withdrawn (#15124); `delete` accepts a single id OR an array of them,
239240
* both as declared contract, served one row at a time (#15117). Read those doc
240241
* comments before writing a handler or a test double against either.
241242
*/
@@ -282,7 +283,9 @@ export interface ActionEngineFacade {
282283
*
283284
* `query` is the ENGINE's query envelope — {@link EngineQueryOptions}, the
284285
* very type `IDataEngine.find` and ObjectQL's own `engine.find` take, named
285-
* here by identity rather than restated. The filter goes under `where`, and
286+
* here by reference rather than restated, with exactly ONE key subtracted:
287+
* `context`, which this facade does not honour (the section at the bottom of
288+
* this comment). The filter goes under `where`, and
286289
* the rest of the envelope (`fields`, `orderBy`, `limit`, `offset`,
287290
* `expand`, `search`, …) means exactly what it means on the engine:
288291
*
@@ -347,19 +350,40 @@ export interface ActionEngineFacade {
347350
* straight through would be dropped unexecuted and the read would widen to
348351
* EVERY row, silently, to a caller whose next line is often a delete.
349352
*
350-
* ## `context` is the caller's to pass and NOT the caller's to choose
353+
* ## `context` is not on this parameter — ADR-0049 enforce-or-remove (#19237)
351354
*
352-
* The envelope carries `context` because every engine option bag does. This
353-
* facade is TRUSTED and context-less by design (#2849, ADR-0096): the
354-
* runtime stamps its own elevated `ExecutionContext` last, so a
355-
* caller-supplied `context` is overridden rather than honoured. Do not write
356-
* one — it reads as authorization and is none.
355+
* The engine's envelope carries `context` because every engine option bag
356+
* does, and on the engine it is honoured: it is where identity and tenant
357+
* live. On THIS facade it is not. The facade is TRUSTED and context-less by
358+
* design (#2849, ADR-0096) — the runtime stamps its own elevated
359+
* `ExecutionContext` last (`buildActionEngineFacade`,
360+
* `packages/runtime/src/action-execution.ts`), so a caller-supplied
361+
* `context` is overridden, never honoured.
362+
*
363+
* Between #15124 and #19237 the key was therefore DECLARED here and
364+
* unenforced: a handler could write `context: { tenantId: … }`, type-check
365+
* clean, and get the facade's context instead — a read the author believes
366+
* is tenant-scoped, silently broader than intended. ADR-0049 admits two
367+
* exits for a declared-but-unenforced key, enforce or remove, and removal is
368+
* the one that changes no runtime behaviour: the key is subtracted from this
369+
* parameter with `Omit`, so writing one is a compile error at the call site
370+
* instead of a no-op at runtime.
371+
*
372+
* ⚠️ The RUNTIME still tolerates the key, deliberately and unchanged. The
373+
* facade's arm reads its legal key set off `EngineQueryOptionsSchema`, which
374+
* still declares `context`, so an UNTYPED caller (a handler in an
375+
* `objectstack.config.js` / `.mjs`, a local copy of the context type, a
376+
* `(ctx: any)` handler) still passes one and still has it overridden rather
377+
* than refused. Refusing it there would be a new runtime refusal on an
378+
* identity key — a behaviour change, out of this card's scope by ruling, and
379+
* the asymmetry is recorded rather than silently closed.
357380
*
358381
* Every clause above is pinned in `action-params.test.ts`, and the
359-
* pass-through is pinned against the runtime in
382+
* pass-through — including the untyped channel's surviving tolerance — is
383+
* pinned against the runtime in
360384
* `packages/runtime/src/action-engine-facade-find-envelope.test.ts`.
361385
*/
362-
find(object: string, query: EngineQueryOptions): Promise<Array<Record<string, unknown>>>;
386+
find(object: string, query: Omit<EngineQueryOptions, 'context'>): Promise<Array<Record<string, unknown>>>;
363387
}
364388

365389
/**

0 commit comments

Comments
 (0)