Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/28099-door-runs-carry-posture-rung.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/spec': patch
'@objectstack/runtime': patch
'@objectstack/service-automation': patch
---

fix(runtime): a flow started through the REST or action door carries its caller's posture rung beside their positions and permission sets, so a platform administrator's manual run is served the ledger's deployment-level rows their request is served (#28099)

Clause-②: yes (widening) — `AutomationContext` (`@objectstack/spec/contracts`) gains one optional member, `posture` (typed `ExecutionContext['posture']`); the REST trigger door and the action door fill it from the request's resolved execution context, and a run carrying it is served what that rung admits.
Metadata-protocol: none

A request carries the posture rung its user resolves to (`ExecutionContext.posture`, ADR-0095 D2/D3). Since #28070 a `runAs: 'user'` run whose grants the engine resolves (a record-change run, the segment after an approval decision) carries that rung too. A run started through a door did not: the trigger door (`buildAutomationContext`, `POST /api/v1/automation/:name/trigger`, the legacy trigger route and a declared `type: 'flow'` endpoint) and the action door (`dispatchFlowAction`, REST `/actions` and MCP `run_action`) forward the caller's positions and permission sets, so run setup does not re-resolve them, and they dropped the rung. A platform administrator's manually-run flow was then served the organization's ledger rows only. That failed closed (fewer rows, never more).

- **Now.** Both doors copy the request's own `ec.posture` onto the run context, as they copy its positions, permission sets, organization and locale. Run setup keeps it beside the forwarded grants and does not re-derive it, and every data node carries it to the data engine. The platform administrator's door-started run is served the deployment-level rows, as their request is.
- **The caller's own rung, as-is.** It is read off the execution context only (a request body key named `posture` stays a flow param), never derived from the flow's author, never raised, and never filled in when the request carries none: such a run carries no rung, as before.
- **Only beside the grants it came from.** A door forwards positions and permission sets only when they are non-empty, so a rung can arrive without them. Run setup then drops the forwarded rung and resolves the grants itself, taking the rung off that same resolution; a run whose grants stay unresolved (the resolver has no rung, rejects, answers nothing, or is not wired) carries none.
- **Docs.** The trigger route's *Identity* paragraph in `flows.mdx` now names every fact the doors forward (user, positions, permissions, tenant, posture rung, language), and `record-view-auditing.mdx` no longer says a door-started run is "not, yet" served the deployment-level rows.
- **Unchanged.** A run whose grants the engine resolves (#28070), the approval decision switch, and a `runAs: 'system'` run.
10 changes: 7 additions & 3 deletions content/docs/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2515,9 +2515,13 @@ unbound unless its declaration carries a
[`defaultValue`](#defaultvalue--declaring-a-variable-is-not-the-same-as-binding-it);
a param you send explicitly always wins, `false` and `null` included.

**Identity.** The caller's identity (user, positions, permissions, tenant) is
forwarded into the run, so a `runAs: 'user'` flow executes under that caller's
row-level security. See [API Authentication](/docs/api#authentication) for
**Identity.** The caller's identity (user, positions, permissions, tenant,
posture rung) and language are forwarded into the run, as the caller's request
resolved them, so a `runAs: 'user'` flow executes under that caller's
row-level security and reads what the caller's own request reads. The rung is
the caller's own, never the flow author's and never raised; a request without
one starts a run without one. The run's setup adds the user's email and
organization peers. See [API Authentication](/docs/api#authentication) for
credentials.

<Callout type="warn">
Expand Down
3 changes: 2 additions & 1 deletion content/docs/permissions/record-view-auditing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,8 @@ reader is served one, this capability's holders included. A `runAs: 'user'`
flow whose grants the engine resolves for its user (a record-change run, or the
segment after an approval decision) carries that user's posture, so one run as
the platform administrator is served these rows as their request is; a run
started through the REST or action door is not, yet.
started through the REST or action door carries the posture its caller's
request carries, and is served them the same way.
Rows carry the
ADR-0057 `audit` lifecycle class: retained hot for
90 days, then archived for seven years where an `archive` datasource is
Expand Down
4 changes: 4 additions & 0 deletions packages/runtime/src/action-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2077,6 +2077,10 @@ export async function dispatchFlowAction(deps: ActionExecutionDeps,
...(Array.isArray(ec?.positions) && ec.positions.length ? { positions: ec.positions } : {}),
...(Array.isArray(ec?.permissions) && ec.permissions.length ? { permissions: ec.permissions } : {}),
...(ec?.tenantId ? { tenantId: ec.tenantId } : {}),
// [#28099] The caller's already-resolved posture rung, the trigger
// door's twin (`buildAutomationContext`): never re-derived or raised,
// absent when the request carries none. See `AutomationContext.posture`.
...(typeof ec?.posture === 'string' && ec.posture.length > 0 ? { posture: ec.posture } : {}),
// [#22450] The caller's already-resolved language, the trigger door's
// twin (`buildAutomationContext`): a refused run's message renders in
// it. See `AutomationContext.locale`.
Expand Down
6 changes: 6 additions & 0 deletions packages/runtime/src/domains/automation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,12 @@ export function buildAutomationContext(body: any, context: HttpProtocolContext):
if (Array.isArray(ec?.positions) && ec.positions.length) automationContext.positions = ec.positions;
if (Array.isArray(ec?.permissions) && ec.permissions.length) automationContext.permissions = ec.permissions;
if (ec?.tenantId) automationContext.tenantId = ec.tenantId;
// [#28099] The caller's posture rung, already resolved beside the grants
// above — forwarded, never re-derived or raised, absent when the request
// carries none. The run carries those grants, so its setup does not
// re-resolve them and this is the only way it gets one.
// `AutomationContext.posture` names its readers.
if (typeof ec?.posture === 'string' && ec.posture.length > 0) automationContext.posture = ec.posture;
// [#22450] The caller's language, already resolved (the request's
// `Accept-Language` first, then the `localization` settings) — forwarded,
// never re-derived, so a refused run's message renders in the language of
Expand Down
135 changes: 135 additions & 0 deletions packages/runtime/src/flow-run-posture.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The two flow doors forward the caller's posture rung —
* `AutomationContext.posture` (#28099, the door half of #28070).
*
* A request carries the rung its user resolves to (`ExecutionContext.posture`,
* ADR-0095 D2/D3), derived once by `resolveAuthzContext`. The doors already
* hold it beside the caller's positions and permission sets, which they
* forward; a run that carries those grants is not re-resolved, so a rung the
* door drops is a rung the run never has. The door forwards the caller's OWN
* rung as-is — ⛔ never derived from the flow's author, never raised, never
* filled in when the request carries none.
*
* What this file pins — the PRODUCER half. The consumer half, on the real
* automation engine, is `runas-user-posture-rung.integration.test.ts` in
* `@objectstack/service-automation`, which runs a door-shaped context (carried
* grants plus a carried rung) to a data node.
*
* 1. The trigger door (`buildAutomationContext`) forwards `ec.posture`, and
* both of its routes hand it to the engine.
* 2. The action door (`dispatchFlowAction`) forwards it.
* 3. A request with no rung (anonymous, or a principal the resolver gave
* none) adds no key — absent, never a default rung.
* 4. The request body cannot name one: `buildAutomationContext` reads the
* rung off the execution context only, and a body key named `posture`
* stays a flow param.
*/

import { describe, it, expect, vi } from 'vitest';

import { HttpDispatcher } from './http-dispatcher.js';
import { dispatchFlowAction, loadActionSubjectRecord } from './action-execution.js';
import { buildAutomationContext } from './domains/automation.js';

const FLOW_NAME = 'quote_generation';

const QUOTE = {
name: 'generate_quote',
label: 'Generate quote',
objectName: 'crm_opportunity',
type: 'flow',
target: FLOW_NAME,
};

function ec(posture?: string) {
return {
userId: 'usr_1', tenantId: 'org_1', positions: ['platform_admin'], permissions: ['admin_full_access'], systemPermissions: [],
...(posture !== undefined ? { posture } : {}),
};
}

/** An automation double that records the context it is handed. */
function makeAutomation() {
const execute = vi.fn(async (_flow: string, _context?: any) => ({ success: true, output: {} }));
const getFlow = vi.fn(async (name: string) => (name === FLOW_NAME ? { name } : null));
return { execute, getFlow };
}
const contextOf = (automation: { execute: any }) => automation.execute.mock.calls[0]?.[1];
const flowDeps = (automation: any): any => ({
resolveService: async (_ctx: any, name: string) => (name === 'automation' ? automation : undefined),
});
const REQUEST: any = { request: {}, environmentId: 'platform' };

describe('[#28099] the trigger door forwards the caller\'s posture rung', () => {
it('`buildAutomationContext` carries `executionContext.posture` as `posture`, beside the grants it carries', () => {
const ctx: any = buildAutomationContext(
{ recordId: 'opp_1', objectName: 'crm_opportunity', params: {} },
{ request: {}, executionContext: ec('PLATFORM_ADMIN') } as any,
);
expect(ctx).toMatchObject({ permissions: ['admin_full_access'], posture: 'PLATFORM_ADMIN' });
});

it('a request with no rung adds no key: absent, never a default rung', () => {
const ctx: any = buildAutomationContext(
{ recordId: 'opp_1', objectName: 'crm_opportunity', params: {} },
{ request: {}, executionContext: ec() } as any,
);
expect('posture' in ctx).toBe(false);
const anonymous: any = buildAutomationContext({ params: {} }, { request: {} } as any);
expect('posture' in anonymous).toBe(false);
});

it('the body cannot name a rung: a flat `posture` key stays a flow param, a wrapped one never leaves `params`', () => {
const flat: any = buildAutomationContext(
{ posture: 'PLATFORM_ADMIN' },
{ request: {}, executionContext: ec('MEMBER') } as any,
);
expect(flat.posture).toBe('MEMBER');
expect(flat.params.posture).toBe('PLATFORM_ADMIN');
const noRung: any = buildAutomationContext(
{ posture: 'PLATFORM_ADMIN', params: { posture: 'PLATFORM_ADMIN' } },
{ request: {}, executionContext: ec() } as any,
);
expect('posture' in noRung).toBe(false);
});

for (const route of [
{ label: 'POST /:name/trigger', path: `/${FLOW_NAME}/trigger` },
{ label: 'legacy POST /trigger/:name', path: `/trigger/${FLOW_NAME}` },
]) {
it(`${route.label} hands the engine the rung`, async () => {
const automation = makeAutomation();
const resolve = (n: string) => (n === 'automation' ? automation : undefined);
const kernel: any = { getService: resolve, getServiceAsync: async (n: string) => resolve(n), context: { getService: resolve } };
await new HttpDispatcher(kernel).handleAutomation(
route.path, 'POST',
{ recordId: 'opp_1', objectName: 'crm_opportunity', params: {} },
{ request: {}, executionContext: ec('PLATFORM_ADMIN') } as any,
);
expect(automation.execute).toHaveBeenCalledTimes(1);
expect(contextOf(automation).posture).toBe('PLATFORM_ADMIN');
});
}
});

describe('[#28099] the action door forwards the caller\'s posture rung', () => {
it('`dispatchFlowAction` carries `ec.posture` as `posture`, beside the grants it carries', async () => {
const automation = makeAutomation();
const subject = await loadActionSubjectRecord('crm_opportunity', 'opp_1', async () => ({ record: { id: 'opp_1' } }));
await dispatchFlowAction(flowDeps(automation), REQUEST, QUOTE, {
objectName: 'crm_opportunity', subject, params: {}, recordId: 'opp_1', ec: ec('PLATFORM_ADMIN'), envId: 'platform',
});
expect(contextOf(automation)).toMatchObject({ permissions: ['admin_full_access'], posture: 'PLATFORM_ADMIN' });
});

it('a request with no rung adds no key: absent, never a default rung', async () => {
const automation = makeAutomation();
const subject = await loadActionSubjectRecord('crm_opportunity', 'opp_1', async () => ({ record: { id: 'opp_1' } }));
await dispatchFlowAction(flowDeps(automation), REQUEST, QUOTE, {
objectName: 'crm_opportunity', subject, params: {}, recordId: 'opp_1', ec: ec(), envId: 'platform',
});
expect('posture' in contextOf(automation)).toBe(false);
});
});
5 changes: 5 additions & 0 deletions packages/services/service-automation/src/run-resume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,11 @@ export async function seedRunGrants(
const needsGrants = !Array.isArray(runContext.permissions);
const needsPeers = runOrgUserIds(runContext) === undefined;
const needsEmail = !runContext.email;
// A rung a door forwarded without grants (#28099) is never kept beside
// grants it was not derived from: the resolution below re-seeds the rung
// with the grants, and a run whose grants stay unresolved (the resolver
// throws, answers nothing, or is not wired) carries none.
if (needsGrants) delete (runContext as SeededRunContext).posture;
if (
runContext.runAs !== 'system' &&
runContext.userId &&
Expand Down
Loading
Loading