Skip to content

Commit 9bbd015

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-17032-single-claim-set-is-enumerated
2 parents 860d5c1 + c43bac7 commit 9bbd015

6 files changed

Lines changed: 397 additions & 32 deletions

File tree

packages/qa/dogfood/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,10 @@
1919
"@objectstack/mcp": "workspace:*",
2020
"@objectstack/metadata": "workspace:*",
2121
"@objectstack/metadata-core": "workspace:*",
22+
"@objectstack/formula": "workspace:*",
2223
"@objectstack/objectql": "workspace:*",
2324
"@objectstack/platform-objects": "workspace:*",
25+
"@objectstack/plugin-approvals": "workspace:*",
2426
"@objectstack/plugin-audit": "workspace:*",
2527
"@objectstack/plugin-auth": "workspace:*",
2628
"@objectstack/plugin-email": "workspace:*",
@@ -31,6 +33,7 @@
3133
"@objectstack/service-messaging": "workspace:*",
3234
"@objectstack/service-storage": "workspace:*",
3335
"@objectstack/spec": "workspace:*",
36+
"@objectstack/trigger-record-change": "workspace:*",
3437
"@objectstack/types": "workspace:*",
3538
"@objectstack/verify": "workspace:*"
3639
},
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// #16679 — the composite pin the card's own sentence names as missing:
4+
// "neither is visible from inside this repository's own tests — the
5+
// measurement needed a real app." Each of three gates is individually
6+
// observable somewhere in this repo; the CONJUNCTION — a served `viewer`
7+
// block, served `sys_approval_request` action metadata, and a CEL verdict
8+
// evaluating one against the other, on a real booted app — was pinned
9+
// nowhere. That is the seam through which a change to any one of the three
10+
// could silently break the #3424 platform-admin override with every existing
11+
// test green.
12+
//
13+
// ## What this measures, on the wire, on a real boot
14+
//
15+
// A plain member (never the platform admin) submits a record into a flow
16+
// whose sole node routes to a POSITION NOBODY HOLDS — the "stranded" shape:
17+
// an unresolved approver slate, `lockRecord: true`, otherwise undecidable.
18+
// The platform admin then reads the SAME request the console's approvals
19+
// inbox would, on both faces (`listRequests` / `getRequest`), and the SAME
20+
// `sys_approval_request` action metadata `GET /meta/object` serves — then
21+
// evaluates the served predicates against the served viewer with
22+
// `@objectstack/formula`'s `celEngine`, the engine the console itself uses.
23+
//
24+
// Both halves are asserted, not just the affirmative one (#16679 ⭐): the four
25+
// override levers (approve/reject/reassign/recall) must evaluate `visible ===
26+
// true`, AND the four secondary/submitter levers (send_back/request_info/
27+
// remind/resubmit) must evaluate `visible === false` for this exact viewer.
28+
// A pin asserting only the first half would stay green if the predicate
29+
// degenerated to a constant `true` — precisely the failure a
30+
// security-adjacent lever must not have.
31+
//
32+
// See `test/fixtures/override-composite-fixture.ts` for why this boots a
33+
// purpose-built object + flow rather than the showcase's own
34+
// `showcase_budget_approval` (the flow the #16679 measurement round drove):
35+
// showcase's `onEnable` unconditionally STAFFS the position that flow's
36+
// second rung needs unstaffed.
37+
38+
import { describe, it, expect } from 'vitest';
39+
import { bootStack } from '@objectstack/verify';
40+
import { celEngine } from '@objectstack/formula';
41+
import type { Expression } from '@objectstack/spec';
42+
import { ApprovalsServicePlugin } from '@objectstack/plugin-approvals';
43+
import { RecordChangeTriggerPlugin } from '@objectstack/trigger-record-change';
44+
import { overrideCompositeStack, overrideCompositeSecurity } from './fixtures/override-composite-fixture.js';
45+
46+
/** The four #3424 override-admitted decision levers — served `visible` must OR in `can_override`. */
47+
const OVERRIDE_LEVERS = ['approval_approve', 'approval_reject', 'approval_reassign', 'approval_recall'] as const;
48+
49+
/**
50+
* The secondary approver levers (gate on `can_act` alone) and the submitter
51+
* continuity levers (gate on `is_submitter` alone). Neither ORs in
52+
* `can_override` (deliberate boundary, `action-predicate-sparse-face.test.ts`
53+
* `:140`) — for THIS viewer (`can_act: false`, `is_submitter: false`) every
54+
* one of these must evaluate `visible === false`.
55+
*/
56+
const NON_OVERRIDE_LEVERS = [
57+
'approval_send_back',
58+
'approval_request_info',
59+
'approval_remind',
60+
'approval_resubmit',
61+
] as const;
62+
63+
interface ServedViewer {
64+
can_act: boolean;
65+
is_submitter: boolean;
66+
can_override: boolean;
67+
}
68+
69+
interface ServedAction {
70+
name: string;
71+
visible?: Expression;
72+
}
73+
74+
async function bootFixture() {
75+
return bootStack(overrideCompositeStack as unknown as Parameters<typeof bootStack>[0], {
76+
automation: true,
77+
security: overrideCompositeSecurity(),
78+
extraPlugins: [new RecordChangeTriggerPlugin(), new ApprovalsServicePlugin()],
79+
});
80+
}
81+
82+
describe('approval override composite (#16679)', () => {
83+
it(
84+
'serves an override-only viewer whose CEL-evaluated actions admit exactly the four override levers',
85+
async () => {
86+
const stack = await bootFixture();
87+
try {
88+
// A plain member submits — never the admin — so the admin reading the
89+
// request back is neither the approver nor the submitter: the exact
90+
// `{can_act:false, is_submitter:false, can_override:true}` triple the
91+
// #16679 measurement round read off the wire.
92+
const memberToken = await stack.signUp('override-composite-member@example.com');
93+
const createRes = await stack.apiAs(memberToken, 'POST', '/data/override_composite_request', {
94+
name: 'Stranded request',
95+
amount: 100,
96+
});
97+
expect(createRes.status).toBe(201);
98+
99+
const adminToken = await stack.signIn();
100+
101+
// ── GATE A + the stranded-scene positive control ──────────────────
102+
// A zero-row list would be indistinguishable from "nothing to measure"
103+
// (NOT MEASURED, never a pass) — assert the scene actually opened
104+
// before reading anything off it.
105+
const listRes = await stack.apiAs(adminToken, 'GET', '/approvals/requests?status=pending');
106+
expect(listRes.status).toBe(200);
107+
const listBody = (await listRes.json()) as { data: Array<Record<string, unknown>> };
108+
expect(listBody.data.length).toBe(1);
109+
const listRow = listBody.data[0];
110+
expect(listRow.status).toBe('pending');
111+
expect(listRow.pending_approvers).toEqual(['position:override_composite_unstaffed']);
112+
expect(listRow.lock_record).toBe(true);
113+
expect(listRow.viewer).toBeTruthy();
114+
115+
const requestId = String(listRow.id);
116+
const getRes = await stack.apiAs(adminToken, 'GET', `/approvals/requests/${requestId}`);
117+
expect(getRes.status).toBe(200);
118+
const getRow = (await getRes.json()) as Record<string, unknown>;
119+
expect(getRow.viewer).toBeTruthy();
120+
121+
// ── GATE B — `can_override` true on BOTH faces, and the fail-safe's
122+
// other two flags correctly false for this actor ────────────────
123+
const expectedViewer: ServedViewer = { can_act: false, is_submitter: false, can_override: true };
124+
expect(listRow.viewer).toEqual(expectedViewer);
125+
expect(getRow.viewer).toEqual(expectedViewer);
126+
127+
// ── GATE C — the served action metadata ORs `can_override` in on
128+
// exactly the four core levers, and nowhere else ─────────────────
129+
const metaRes = await stack.apiAs(adminToken, 'GET', '/meta/object/sys_approval_request');
130+
expect(metaRes.status).toBe(200);
131+
const metaBody = (await metaRes.json()) as { item?: { actions?: ServedAction[] } };
132+
const actions = metaBody.item?.actions ?? [];
133+
expect(actions.length).toBe(8);
134+
135+
const byName = new Map(actions.map((a) => [a.name, a] as const));
136+
for (const name of [...OVERRIDE_LEVERS, ...NON_OVERRIDE_LEVERS]) {
137+
const action = byName.get(name);
138+
expect(action, `served actions must include '${name}'`).toBeTruthy();
139+
expect(action?.visible?.dialect).toBe('cel');
140+
}
141+
142+
// ── COMPOSITE — evaluate the SERVED predicates against the SERVED
143+
// viewer with @objectstack/formula's celEngine, the engine the
144+
// console itself uses (`action-predicate-sparse-face.test.ts`
145+
// treats it as the authority). `record` is the served row itself
146+
// — the same binding shape the console evaluates `visible`
147+
// against (`record.status`, `record.viewer.*`). ────────────────
148+
const evaluateVisible = (action: ServedAction | undefined): boolean => {
149+
if (!action?.visible) throw new Error('action carries no visible predicate');
150+
const result = celEngine.evaluate(action.visible, { record: getRow });
151+
if (!result.ok) {
152+
throw new Error(`CEL evaluation of '${action.name}' faulted: ${JSON.stringify(result)}`);
153+
}
154+
return result.value === true;
155+
};
156+
157+
// ⭐ Both halves. A pin asserting only this first loop would stay
158+
// green if `visible` degenerated to a constant `true`.
159+
for (const name of OVERRIDE_LEVERS) {
160+
expect(evaluateVisible(byName.get(name)), `${name} must be visible=true for an override-only viewer`).toBe(
161+
true,
162+
);
163+
}
164+
for (const name of NON_OVERRIDE_LEVERS) {
165+
expect(
166+
evaluateVisible(byName.get(name)),
167+
`${name} must stay visible=false — it must not admit an actor who is neither the approver nor the submitter`,
168+
).toBe(false);
169+
}
170+
} finally {
171+
await stack.stop();
172+
}
173+
},
174+
60_000,
175+
);
176+
});
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Minimal booted-app fixture for the #16679 composite pin.
4+
//
5+
// One object, one autolaunched `approval` flow whose sole node routes to a
6+
// POSITION with no holder at all — the "stranded" shape the #3424 platform
7+
// admin override exists to rescue (an unresolved slate, `lockRecord: true`,
8+
// otherwise undecidable).
9+
//
10+
// ## Why a purpose-built fixture rather than the showcase's own
11+
// `showcase_budget_approval` (the flow the #16679 measurement round drove)
12+
//
13+
// `@objectstack/example-showcase`'s `onEnable` unconditionally runs
14+
// `registerShowcaseApprovalDemo`, which assigns the dev-seeded admin the
15+
// `manager` / `finance` / `legal` / `exec` positions on EVERY boot
16+
// (`ADMIN_APPROVAL_POSITIONS` in `seed-approval-demo.ts`) — i.e. it STAFFS
17+
// exactly the position `showcase_budget_approval`'s second rung needs
18+
// UNSTAFFED for the card's scene. A dogfood pin that re-booted showcase would
19+
// either race that seed step or have to disable it, coupling this pin's
20+
// stability to an app it does not own. A minimal object + flow answerable to
21+
// no app's `onEnable` keeps the "unstaffed position" precondition explicit and
22+
// permanent instead of incidental.
23+
//
24+
// `record-after-create` (not `-after-update`) is the trigger: the record is
25+
// born already routed to the gate, with no `previous` bookkeeping needed.
26+
27+
import { defineStack, defineFlow } from '@objectstack/spec';
28+
import { ObjectSchema, Field } from '@objectstack/spec/data';
29+
import { PermissionSetSchema, type PermissionSet } from '@objectstack/spec/security';
30+
import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security';
31+
32+
/** A position nothing in this fixture ever staffs — the stranded slate. */
33+
export const UNSTAFFED_POSITION = 'override_composite_unstaffed';
34+
35+
export const OverrideCompositeRequest = ObjectSchema.create({
36+
name: 'override_composite_request',
37+
label: 'Override Composite Request',
38+
pluralLabel: 'Override Composite Requests',
39+
sharingModel: 'public_read_write',
40+
fields: {
41+
name: Field.text({ label: 'Name', required: true }),
42+
amount: Field.number({ label: 'Amount', required: false }),
43+
},
44+
});
45+
46+
export const OverrideCompositeFlow = defineFlow({
47+
name: 'override_composite_flow',
48+
label: 'Override Composite Flow',
49+
// The stranded scene, in miniature (see the module header above).
50+
description: 'Fires on insert and routes straight to an UNSTAFFED position.',
51+
type: 'autolaunched',
52+
status: 'active',
53+
nodes: [
54+
{
55+
id: 'start',
56+
type: 'start',
57+
label: 'On Create',
58+
config: { objectName: 'override_composite_request', triggerType: 'record-after-create' },
59+
},
60+
{
61+
id: 'gate',
62+
type: 'approval',
63+
label: 'Gate',
64+
config: {
65+
approvers: [{ type: 'position', value: UNSTAFFED_POSITION }],
66+
behavior: 'first_response',
67+
// Locked, matching the card's own scene (`showcase_budget_approval`'s
68+
// `exec_review` rung) — the record must stay immovable while stuck.
69+
lockRecord: true,
70+
},
71+
},
72+
{ id: 'approved', type: 'end', label: 'Approved' },
73+
{ id: 'rejected', type: 'end', label: 'Rejected' },
74+
],
75+
edges: [
76+
{ id: 'e1', source: 'start', target: 'gate' },
77+
{ id: 'e2', source: 'gate', target: 'approved', label: 'approve' },
78+
{ id: 'e3', source: 'gate', target: 'rejected', label: 'reject' },
79+
],
80+
});
81+
82+
export const overrideCompositeStack = defineStack({
83+
manifest: {
84+
id: 'com.dogfood.override_composite',
85+
namespace: 'override_composite',
86+
version: '0.0.0',
87+
type: 'app',
88+
name: 'Override Composite Fixture',
89+
// The gate-composite pin's fixture app (see the module header above).
90+
description: 'One object, one flow, one permanently-unstaffed position.',
91+
},
92+
// ADR-0097: a `record_change` trigger (the flow's `record-after-create`
93+
// start node) only registers when the app declares it needs the capability.
94+
requires: ['triggers'],
95+
objects: [OverrideCompositeRequest],
96+
flows: [OverrideCompositeFlow],
97+
});
98+
99+
const FIXTURE_SUBMITTER_SET = 'override_composite_submitter';
100+
101+
/**
102+
* The fallback set a fresh (non-admin) member resolves to: create + read on
103+
* `override_composite_request` only. Needed so a plain member — never the
104+
* platform admin — can be the request's SUBMITTER, which is what makes
105+
* `viewer.is_submitter` false for the admin reading it back (the composite
106+
* pin needs `can_act: false, is_submitter: false, can_override: true`, the
107+
* exact triple #16679's measurement round read off the wire).
108+
*/
109+
export const overrideCompositeSubmitterSet: PermissionSet = PermissionSetSchema.parse({
110+
name: FIXTURE_SUBMITTER_SET,
111+
label: 'Override Composite Submitter — create + read on override_composite_request only',
112+
objects: {
113+
override_composite_request: { allowRead: true, allowCreate: true, allowEdit: false, allowDelete: false },
114+
},
115+
});
116+
117+
/** SecurityPlugin whose fresh-member fallback is the submitter-only set above. */
118+
export function overrideCompositeSecurity(): SecurityPlugin {
119+
return new SecurityPlugin({
120+
defaultPermissionSets: [...securityDefaultPermissionSets, overrideCompositeSubmitterSet],
121+
fallbackPermissionSet: overrideCompositeSubmitterSet.name,
122+
});
123+
}

packages/qa/dogfood/tsconfig.json

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,20 @@
4747
// answer this suite would want the day it really imports the package. Same
4848
// ONE bare-name shape as the entry above: that package's `exports` map
4949
// carries only `"."`.
50+
//
51+
// [#16679] `approval-override-composite-pin.dogfood.test.ts` and its
52+
// fixture import `@objectstack/formula`, `@objectstack/plugin-approvals`
53+
// and `@objectstack/trigger-record-change` as values (the CEL engine, the
54+
// approvals plugin, the record-change trigger plugin) — same
55+
// dist-resolved-type hazard as the driver above, this time caught by
56+
// `check:type-source-resolution` rather than its vitest counterpart. Same
57+
// ONE bare-name shape: each package's `exports` map carries only `"."`.
5058
"paths": {
5159
"@objectstack/driver-turso": ["../../drivers/driver-turso/src/index.ts"],
52-
"@objectstack/organizations": ["../../plugins/organizations/src/index.ts"]
60+
"@objectstack/organizations": ["../../plugins/organizations/src/index.ts"],
61+
"@objectstack/formula": ["../../formula/src/index.ts"],
62+
"@objectstack/plugin-approvals": ["../../plugins/plugin-approvals/src/index.ts"],
63+
"@objectstack/trigger-record-change": ["../../triggers/trigger-record-change/src/index.ts"]
5364
}
5465
},
5566
"include": ["test/**/*"],

0 commit comments

Comments
 (0)