Skip to content

Commit 6e2caec

Browse files
claude[bot]claude
andauthored
feat(spec): declare AutomationContext.recordLoadDenied, the flow face of the caller-scope record-load signal (#15143)
* feat(spec): declare AutomationContext.recordLoadDenied, the flow face of the record-load signal Additive key `recordLoadDenied?: true` on `AutomationContext`, mirroring the producer's spelling (`actionRecordLoadSignal` in the runtime's `action-execution.ts` returns `{ recordLoadDenied?: true }`), with a type-level pin (`true | undefined`, additive positive control, `false` refused) and a minor changeset. Declared, not yet populated on the flow face — the runtime half is a separate card. Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i Co-authored-by: Claude <noreply@anthropic.com> * docs(ui): note the flow face of ctx.recordLoadDenied — declared on AutomationContext, not yet populated Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i Co-authored-by: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 19965da commit 6e2caec

4 files changed

Lines changed: 201 additions & 0 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): declare `AutomationContext.recordLoadDenied` — the flow face of the caller-scope record-load signal (#14244)
6+
7+
An action of `type: 'flow'` hands its target flow the same `record` object the
8+
script/body face receives, and the dispatcher stamps the requested `recordId`
9+
onto `record.id` whether or not the caller's own scope could read the row —
10+
new-record / record-less actions depend on that stamp. So a flow started on a
11+
row its invoker cannot read receives `record = { id: <recordId> }`, shaped
12+
exactly like a legitimate record-less start, with nothing on the run's context
13+
distinguishing the two. The script/body face got its distinguishing key in the
14+
previous release (`ctx.recordLoadDenied`, `@objectstack/runtime`); the flow face
15+
had no equivalent on its contract.
16+
17+
**The addition: `AutomationContext.recordLoadDenied?: true`.** The exact shape
18+
the runtime's one shared producer emits (`actionRecordLoadSignal` in
19+
`action-execution.ts` returns `{ recordLoadDenied?: true }`), mirrored rather
20+
than respelled: `true` exactly when the dispatcher's caller-scope load did not
21+
deliver the row; **absent** — never `false` — otherwise, so a consumer reads
22+
`recordLoadDenied === true`. It reports "the row did not resolve for this
23+
caller", not "an authorization error was caught": a row hidden by row-level
24+
security and an id that names nothing both arrive as `RECORD_NOT_FOUND`,
25+
deliberately, and the key does not pretend to separate them.
26+
27+
A `runAs: 'user'` flow re-derives the caller's scope on its own reads, so the
28+
stub resolves to nothing there. The key exists for the `runAs: 'system'` flow:
29+
it runs elevated **and** receives the stub, and this is what it guards on before
30+
acting on a row its invoker has not demonstrated read access to.
31+
32+
- **Purely additive.** The key is optional; every existing `AutomationContext`
33+
literal type-checks unchanged and no existing key changes value. Pinned at
34+
the type level (`true | undefined`, `false` refused at compile time).
35+
- **Declared, not yet populated on the flow face.** This release declares the
36+
key on the contract. `dispatchFlowAction` (both action doors — REST
37+
`POST /api/v1/actions/...` and the MCP `run_action` bridge) does not yet pass
38+
the producer's signal into the run's context; that is a separate runtime
39+
change. Until it lands, a flow run never sees this key, so a guard on it is
40+
inert (never `true`), never wrong.
41+
- **Nothing narrows.** `dispatchFlowAction` still starts the run;
42+
`runAs: 'system'` stays a declared, documented authoring decision.

content/docs/ui/actions.mdx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,17 @@ Set `ctx.recordLoadDenied` aside only when your action is *meant* to run without
374374
a readable subject row (an "import this id from elsewhere" action, say). The
375375
default for a row-scoped action on a `private` object is to refuse.
376376
377+
**Flow actions.** A `type: 'flow'` action hands its target flow the same
378+
`record``record.id` stamped the same way — and the flow run's context
379+
(`AutomationContext`, the `@objectstack/spec` contract) declares the same key
380+
in the same spelling, `recordLoadDenied?: true`, so a `runAs: 'system'` flow
381+
has something to guard on before it acts on a row its invoker could not read.
382+
On the flow face the key is **declared but not yet populated**: the flow
383+
dispatcher does not pass the signal into the run's context yet, so until that
384+
lands a flow run never sees it and a guard on it is inert (never `true`), never
385+
wrong. A `runAs: 'user'` flow needs no guard — it re-derives the caller's
386+
scope on its own reads, and the stub resolves to nothing.
387+
377388
## Call it over REST
378389
379390
Every action is also an endpoint — the Console button and the API call run
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#14244] `AutomationContext.recordLoadDenied` is exactly the producer's
5+
* shape — `{ recordLoadDenied?: true }`, the return type of
6+
* `actionRecordLoadSignal` (`@objectstack/runtime`, `action-execution.ts`) —
7+
* the flow face of #14143's handler-face signal, MIRRORED rather than
8+
* respelled (triage ruling on #14244, 2026-09-02: the key must mirror the
9+
* producer's spelling rather than invent a second one).
10+
*
11+
* Four things are pinned, because each drifts on its own:
12+
*
13+
* 1. **The key's type, at the type level.** Exactly `true | undefined` — a
14+
* widening to `boolean` would let a producer emit `false`, which the
15+
* handler face documents it never does (`ctx.recordLoadDenied === true`,
16+
* absent otherwise). A TypeScript interface member has no Zod schema, so
17+
* the only assertion is a compile-time identity (`Eq`, the
18+
* `automation-result-status.pin.test.ts` form), read by
19+
* `check:test-typecheck` under `tsconfig.test.json`.
20+
* 2. **Additive.** A context literal WITHOUT the key still type-checks, so no
21+
* existing caller of `IAutomationService.execute` moves.
22+
* 3. **`false` is refused at compile time** (`@ts-expect-error`). A phantom
23+
* unless this file is compiled — and it is: `tsconfig.test.json` includes
24+
* `src/**`, and its ledger (`test-typecheck-debt.json`) lists this file
25+
* nowhere, so the file must compile with exactly zero errors.
26+
* 4. **The JSDoc says who sets it and that the flow face does not yet
27+
* populate it.** Prose is unassertable except by reading it; the contract
28+
* source is read and the doc block above the key is required to name the
29+
* producer and the not-yet-populated state, so the day the runtime half
30+
* lands, this test tells its author which sentence to retire.
31+
*
32+
* ⛔ Not pinned, deliberately: that any flow run actually RECEIVES the key.
33+
* That is the runtime half (`dispatchFlowAction` / the REST `/actions` door
34+
* passing the producer's signal into the run's context), a separate card.
35+
*/
36+
37+
import { readFileSync } from 'node:fs';
38+
import { fileURLToPath } from 'node:url';
39+
40+
import { describe, it, expect } from 'vitest';
41+
42+
import type { AutomationContext, IAutomationService } from './automation-service';
43+
44+
/** Type-level identity: true iff A and B are the same type. */
45+
type Eq< A, B > = (< T >() => T extends A ? 1 : 2) extends (< T >() => T extends B ? 1 : 2) ? true : false;
46+
/** Compile error when the argument is not `true`. */
47+
type Assert< T extends true > = T;
48+
49+
/**
50+
* Exported deliberately — an unread alias inside a test body is TS6196, and a
51+
* pin no program compiles is no pin at all.
52+
*/
53+
export type RecordLoadDeniedIsExactlyTrueOrUndefined = Assert< Eq< AutomationContext['recordLoadDenied'], true | undefined > >;
54+
/** The producer's return shape, spelled here as the runtime spells it. */
55+
type ProducerSignal = { recordLoadDenied?: true };
56+
/** The contract key is assignable FROM the producer's signal — the mirror holds in the direction that matters. */
57+
export type ProducerSignalSpreadsIntoContext = Assert< Eq< ProducerSignal['recordLoadDenied'], AutomationContext['recordLoadDenied'] > >;
58+
59+
/** Positive control (additive): the key is optional, so a pre-#14244 context still type-checks. */
60+
export const contextWithoutTheKey: AutomationContext = { record: { id: 'rec-1', name: 'Alice' }, object: 'crm_deal', userId: 'u1' };
61+
/** The one value the key may carry. */
62+
export const contextWithTheKey: AutomationContext = { record: { id: 'rec-1' }, object: 'crm_deal', userId: 'u1', recordLoadDenied: true };
63+
// @ts-expect-error — `false` is not a member: the key is ABSENT, never `false` (handler-face convention, mirrored).
64+
export const contextWithFalse: AutomationContext = { record: { id: 'rec-1' }, object: 'crm_deal', recordLoadDenied: false };
65+
66+
describe('[#14244] AutomationContext.recordLoadDenied mirrors the producer signal', () => {
67+
it('reads the key back as exactly `true`, and its absence as `undefined` (anti-vacuity)', () => {
68+
expect(contextWithTheKey.recordLoadDenied).toBe(true);
69+
expect(contextWithoutTheKey.recordLoadDenied).toBeUndefined();
70+
expect('recordLoadDenied' in contextWithoutTheKey).toBe(false);
71+
});
72+
73+
it('a service implementation can guard on it WITHOUT a cast, the way a runAs:system flow would', async () => {
74+
const seen: Array<true | undefined> = [];
75+
const service: IAutomationService = {
76+
execute: async (_flowName, context?) => {
77+
seen.push(context?.recordLoadDenied);
78+
// The documented predicate, verbatim: `=== true`, never a truthiness of `false`.
79+
if (context?.recordLoadDenied === true) return { success: false, error: 'RECORD_NOT_FOUND' };
80+
return { success: true };
81+
},
82+
listFlows: async () => [],
83+
};
84+
expect((await service.execute('guarded', contextWithTheKey)).success).toBe(false);
85+
expect((await service.execute('guarded', contextWithoutTheKey)).success).toBe(true);
86+
expect(seen).toEqual([true, undefined]);
87+
});
88+
89+
it('the contract JSDoc names the producer, both doors, and the not-yet-populated flow face', () => {
90+
const source = readFileSync(fileURLToPath(new URL('./automation-service.ts', import.meta.url)), 'utf8');
91+
const declaration = 'recordLoadDenied?: true;';
92+
const at = source.indexOf(declaration);
93+
expect(at).toBeGreaterThan(-1);
94+
// Exactly one declaration of the key on the contract — a second spelling
95+
// anywhere in this file is the drift the ruling forbids.
96+
expect(source.indexOf('recordLoadDenied', at + declaration.length)).toBe(-1);
97+
// The doc block immediately above the declaration — from its last `/**`.
98+
const docStart = source.lastIndexOf('/**', at);
99+
const doc = source.slice(docStart, at);
100+
expect(doc).toContain('loadActionSubjectRecord');
101+
expect(doc).toContain('actionRecordLoadSignal');
102+
expect(doc).toContain('{ recordLoadDenied?: true }');
103+
expect(doc).toMatch(/both doors/i);
104+
expect(doc).toMatch(/run_action/);
105+
expect(doc).toMatch(/runAs: 'system'/);
106+
// The honesty clause: declared, not yet populated on the flow face.
107+
expect(doc).toMatch(/NOT YET POPULATED/);
108+
expect(doc).toContain('dispatchFlowAction');
109+
// Absence semantics, in the handler face's own words.
110+
expect(doc).toMatch(/never\s+\*?\s*`false`/);
111+
});
112+
});

packages/spec/src/contracts/automation-service.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,42 @@ import type { ConversionNotice, ConversionConflictNotice } from '../conversions/
2525
export interface AutomationContext {
2626
/** Record that triggered the automation (if applicable) */
2727
record?: Record<string, unknown>;
28+
/**
29+
* `true` exactly when the dispatcher's caller-scope load of the subject row
30+
* did NOT deliver it — so {@link record} is the stamped `{ id }` stub the
31+
* dispatcher puts in place of the row, not the row (#14244; the flow face of
32+
* #14143's handler-face signal, `ctx.recordLoadDenied`). Absent — never
33+
* `false` — otherwise, including for a record-less start that attempted no
34+
* load, so a consumer reads `recordLoadDenied === true`.
35+
*
36+
* Who sets it: the runtime's action dispatch, both doors — REST
37+
* `POST /api/v1/actions/...` and the MCP `run_action` bridge — through ONE
38+
* producer, `loadActionSubjectRecord` in `@objectstack/runtime`'s
39+
* `action-execution.ts`, whose `actionRecordLoadSignal(load)` returns
40+
* exactly `{ recordLoadDenied?: true }` (line 1278 on `2cc46103`). This key
41+
* IS that producer's spelling, mirrored — not a second one.
42+
*
43+
* What a flow guards on: a `type: 'flow'` action's dispatcher stamps the
44+
* requested `recordId` onto `record.id` whether or not the caller could
45+
* read the row (new-record / record-less actions depend on the stamp), so
46+
* `record.id` is present either way and a node branching on it never
47+
* refuses. A `runAs: 'user'` flow re-derives the caller's scope on its own
48+
* reads and the stub resolves to nothing; a `runAs: 'system'` flow runs
49+
* elevated AND receives the stub — this key is what such a flow guards on
50+
* before acting on a row its invoker has not demonstrated read access to.
51+
* It reports "the row did not resolve for this caller", not "an
52+
* authorization error was caught": an RLS-hidden row and an id that names
53+
* nothing both arrive as `RECORD_NOT_FOUND`, deliberately, and the key does
54+
* not pretend to separate them.
55+
*
56+
* Declared, NOT YET POPULATED on the flow face: the handler face carries it
57+
* today, but `dispatchFlowAction` still hands `automation.execute` a context
58+
* without it, so until the runtime half lands a flow run never sees this key
59+
* and a guard on it is inert (never `true`), never wrong. Additive — no
60+
* existing key changes value. Callers other than the action dispatcher do
61+
* NOT set this.
62+
*/
63+
recordLoadDenied?: true;
2864
/**
2965
* Prior state of the record for update triggers (the "old" row). Lets
3066
* record-change-triggered flows gate on transitions (e.g.

0 commit comments

Comments
 (0)