Skip to content

Commit 8cf030f

Browse files
committed
feat(plugin-approvals): sys_approval_request declares its per-caller viewer block under attachedOnRead
The object's 8 action `visible` predicates read `record.viewer.*`, a block `ApprovalService.attachViewers` attaches per caller on read. Declared under `ObjectSchema.attachedOnRead`, the shared build validator now resolves `record.viewer` and judges its second segment against the declared leaves. A conformance test drives the real service on a real ObjectQL over SqlDriver, for every caller shape `attachViewers` serves, through `listRequests` and `getRequest`, and holds the served keys and each value's runtime type to the declaration read from the object. Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ Co-authored-by: Claude <noreply@anthropic.com>
1 parent c512c25 commit 8cf030f

2 files changed

Lines changed: 240 additions & 1 deletion

File tree

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#22211 ruling A, #22387] The `viewer` block `ApprovalService` serves is the
5+
* block `sys_approval_request` declares under `attachedOnRead`.
6+
*
7+
* The declaration's second reader (ADR-0049: a declaration has a reader from
8+
* landing day). The shared build validator reads the declared leaf NAMES to
9+
* judge the object's action predicates; nothing there reads the declared leaf
10+
* TYPES. This file reads both: for every caller shape `attachViewers` serves,
11+
* through both doors that call it (`listRequests` and `getRequest`), the keys
12+
* of the served `viewer` equal the declared leaves, and each served value's
13+
* runtime type is the type its leaf declares.
14+
*
15+
* The expected set is read from the object, never written here: a leaf the
16+
* service starts emitting without a declaration, a leaf declared that the
17+
* service never emits, or a value of another type is red with no edit to
18+
* this file.
19+
*
20+
* Driven through the real `ApprovalService` on a real `ObjectQL` over
21+
* `SqlDriver` (better-sqlite3, in-memory, real DDL), with the package's own
22+
* object definitions registered: no engine double stands between the service
23+
* and the rows it decorates.
24+
*
25+
* Each caller shape is held to being the shape it names, by the one leaf that
26+
* marks it (or by none, for the caller who holds no capability), and the
27+
* battery as a whole must observe both values of every boolean leaf, so a
28+
* type check that only ever saw `false` cannot pass for conformance.
29+
*/
30+
31+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
32+
import { ObjectQL } from '@objectstack/objectql';
33+
import { SqlDriver } from '@objectstack/driver-sql';
34+
import type { ServiceObject } from '@objectstack/spec/data';
35+
import { ADMIN_FULL_ACCESS } from '@objectstack/spec/identity';
36+
import { ApprovalService } from './approval-service.js';
37+
import { ApprovalsServicePlugin } from './approvals-plugin.js';
38+
import { SysApprovalRequest } from './sys-approval-request.object.js';
39+
40+
/** The value types a read-attachment leaf may declare (the spec's closed set). */
41+
type LeafType = NonNullable<ServiceObject['attachedOnRead']>[string][string];
42+
43+
/**
44+
* The runtime test for each declared value type. Typed against the spec's
45+
* vocabulary, so a fifth type added there fails to compile here until it has
46+
* a test of its own.
47+
*/
48+
const HOLDS: Record<LeafType, (value: unknown) => boolean> = {
49+
boolean: (v) => typeof v === 'boolean',
50+
number: (v) => typeof v === 'number' && Number.isFinite(v),
51+
text: (v) => typeof v === 'string',
52+
date: (v) => (v instanceof Date && !Number.isNaN(v.getTime()))
53+
|| (typeof v === 'string' && !Number.isNaN(Date.parse(v))),
54+
};
55+
56+
/** The block under test, read from the object the plugin ships. */
57+
const DECLARED: Readonly<Record<string, LeafType>> = SysApprovalRequest.attachedOnRead?.viewer ?? {};
58+
59+
const SYSTEM = { isSystem: true, positions: [], permissions: [] } as any;
60+
const asUser = (userId: string, permissions: string[] = []) =>
61+
({ userId, positions: [], permissions }) as any;
62+
63+
const sysUser = {
64+
name: 'sys_user',
65+
label: 'User',
66+
fields: {
67+
id: { name: 'id', label: 'Id', type: 'text' as const, primaryKey: true },
68+
name: { name: 'name', label: 'Name', type: 'text' as const },
69+
email: { name: 'email', label: 'Email', type: 'text' as const },
70+
},
71+
};
72+
const deal = {
73+
name: 'crm_deal',
74+
label: 'Deal',
75+
fields: {
76+
id: { name: 'id', label: 'Id', type: 'text' as const, primaryKey: true },
77+
title: { name: 'title', label: 'Title', type: 'text' as const },
78+
},
79+
};
80+
81+
/** The plugin's own object definitions, as its manifest registers them. */
82+
async function packageObjects(): Promise<any[]> {
83+
let manifest: any;
84+
const ctx: any = {
85+
getService: (name: string) => {
86+
if (name === 'manifest') return { register: (m: any) => { manifest = m; } };
87+
throw new Error(`no service '${name}'`);
88+
},
89+
logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
90+
};
91+
await new ApprovalsServicePlugin().init(ctx);
92+
return manifest.objects;
93+
}
94+
95+
/**
96+
* A caller shape `attachViewers` serves: who is reading, which request, and
97+
* the one declared leaf that marks the shape (`null` — the caller who reads
98+
* the request but holds none of the three capabilities).
99+
*/
100+
interface Shape {
101+
label: string;
102+
context: () => any;
103+
request: () => string;
104+
marks: string | null;
105+
}
106+
107+
describe('sys_approval_request — the served viewer block conforms to attachedOnRead.viewer', () => {
108+
let engine: ObjectQL;
109+
let svc: ApprovalService;
110+
const ids: Record<string, string> = {};
111+
let now = Date.parse('2026-10-09T09:00:00.000Z');
112+
113+
const SHAPES: Shape[] = [
114+
{ label: 'the submitter, on a pending request', context: () => asUser('u_sub'), request: () => ids.pending, marks: 'is_submitter' },
115+
{ label: 'a current pending approver', context: () => asUser('u_app'), request: () => ids.pending, marks: 'can_act' },
116+
{
117+
label: 'an override actor (platform admin), on a pending request',
118+
context: () => asUser('u_root', [ADMIN_FULL_ACCESS]),
119+
request: () => ids.pending,
120+
marks: 'can_override',
121+
},
122+
{ label: 'a system context, on a pending request', context: () => SYSTEM, request: () => ids.pending, marks: 'can_override' },
123+
{ label: 'the approver who decided, on the finalized request', context: () => asUser('u_app'), request: () => ids.decided, marks: null },
124+
];
125+
126+
beforeAll(async () => {
127+
engine = new ObjectQL();
128+
engine.registerDriver(new SqlDriver({
129+
client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true,
130+
}), true);
131+
await engine.init();
132+
for (const def of [sysUser, deal, ...(await packageObjects())]) {
133+
engine.registry.registerObject(def as any, 'approvals-test', 'approvals-test');
134+
}
135+
await engine.syncSchemas();
136+
for (const id of ['u_sub', 'u_app', 'u_root']) {
137+
await engine.insert('sys_user', { id, name: id, email: `${id}@example.com` }, { context: SYSTEM } as any);
138+
}
139+
svc = new ApprovalService({
140+
engine: engine as any,
141+
clock: { now: () => new Date(now += 1000) },
142+
automation: {
143+
resume: async () => ({ status: 'completed' }),
144+
cancelRun: async () => undefined,
145+
getRun: async (runId: string) => ({ id: runId, status: 'paused' }),
146+
getFlow: async () => ({ name: 'deal_review', nodes: [{ id: 'review', type: 'approval' }], edges: [] }),
147+
} as any,
148+
logger: { warn: () => {}, info: () => {}, error: () => {}, debug: () => {} } as any,
149+
});
150+
const open = async (key: string) => {
151+
await engine.insert('crm_deal', { id: `D_${key}`, title: key }, { context: SYSTEM } as any);
152+
const row: any = await svc.openNodeRequest({
153+
object: 'crm_deal', recordId: `D_${key}`, runId: `run_${key}`, nodeId: 'review', flowName: 'deal_review',
154+
config: { approvers: [{ type: 'user', value: 'u_app' }], behavior: 'first_response' } as any,
155+
submitterId: 'u_sub', record: { id: `D_${key}`, title: key },
156+
}, asUser('u_sub'));
157+
ids[key] = row.id;
158+
};
159+
await open('pending');
160+
await open('decided');
161+
await svc.decideNode(ids.decided, { decision: 'approve' } as any, asUser('u_app'));
162+
});
163+
164+
afterAll(async () => {
165+
try { await engine?.destroy(); } catch { /* noop */ }
166+
});
167+
168+
it('the object declares the block this battery reads', () => {
169+
// The battery below compares against DECLARED; an empty declaration would
170+
// make every key comparison trivially about nothing.
171+
expect(Object.keys(DECLARED).length).toBeGreaterThan(0);
172+
});
173+
174+
/** Both doors that attach the block, for one caller shape. */
175+
async function served(shape: Shape): Promise<Array<{ door: string; viewer: unknown }>> {
176+
const id = shape.request();
177+
const one = await svc.getRequest(id, shape.context());
178+
const listed = (await svc.listRequests({ object: 'crm_deal' }, shape.context())).find((r) => r.id === id);
179+
expect(one, `${shape.label}: getRequest serves the request`).not.toBeNull();
180+
expect(listed, `${shape.label}: listRequests serves the request`).toBeDefined();
181+
return [
182+
{ door: 'getRequest', viewer: (one as any)?.viewer },
183+
{ door: 'listRequests', viewer: (listed as any)?.viewer },
184+
];
185+
}
186+
187+
it.each(SHAPES)('$label: the served keys are the declared leaves, each value of its declared type', async (shape) => {
188+
for (const { door, viewer } of await served(shape)) {
189+
const where = `${shape.label} via ${door}`;
190+
expect(viewer, `${where}: a viewer block is attached`).toBeTypeOf('object');
191+
const block = viewer as Record<string, unknown>;
192+
expect(Object.keys(block).sort(), `${where}: emitted keys`).toEqual(Object.keys(DECLARED).sort());
193+
for (const [leaf, type] of Object.entries(DECLARED)) {
194+
expect(HOLDS[type](block[leaf]), `${where}: \`${leaf}\` declared ${type}, served ${JSON.stringify(block[leaf])}`)
195+
.toBe(true);
196+
}
197+
// The shape is the shape it names: its marking leaf is the only true one.
198+
for (const leaf of Object.keys(DECLARED)) {
199+
expect(block[leaf], `${where}: \`${leaf}\``).toBe(leaf === shape.marks);
200+
}
201+
}
202+
});
203+
204+
it('the battery observes both values of every boolean leaf (the type check is not vacuous)', async () => {
205+
const seen = new Map<string, Set<unknown>>();
206+
for (const shape of SHAPES) {
207+
for (const { viewer } of await served(shape)) {
208+
for (const [leaf, value] of Object.entries(viewer as Record<string, unknown>)) {
209+
if (!seen.has(leaf)) seen.set(leaf, new Set());
210+
seen.get(leaf)!.add(value);
211+
}
212+
}
213+
}
214+
for (const [leaf, type] of Object.entries(DECLARED)) {
215+
if (type !== 'boolean') continue;
216+
expect([...(seen.get(leaf) ?? [])].sort(), `\`${leaf}\` across the battery`).toEqual([false, true]);
217+
}
218+
});
219+
});

‎packages/plugins/plugin-approvals/src/sys-approval-request.object.ts‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,25 @@ export const SysApprovalRequest = ObjectSchema.create({
365365
updated_at: Field.datetime({ label: 'Updated At', required: false, group: 'System' }),
366366
},
367367

368+
// [#22211 ruling A] The per-caller `viewer` block (#3310), declared as a read
369+
// attachment: `ApprovalService.attachViewers` computes it from the CALLER's
370+
// identity and sets it on every row `listRequests` / `getRequest` serve. It
371+
// is not a field — no column, and never stored — so the rows the data engine
372+
// itself returns (the generic data door, a record-change flow's `record`, a
373+
// flow action's subject load) never carry it.
374+
//
375+
// Two readers hold this declaration to the truth:
376+
// - the shared build validator judges the `visible` predicates below
377+
// against it: `record.viewer` resolves, and `record.viewer.<leaf>` only
378+
// to a leaf declared here (`sys-approval-request-attached-on-read.test.ts`);
379+
// - the conformance test pins that the keys `attachViewers` emits, and the
380+
// runtime type of each value, equal this block, for every caller shape it
381+
// serves (`sys-approval-request-viewer.conformance.test.ts`). A leaf added
382+
// to the service without this block, or here without the service, is red.
383+
attachedOnRead: {
384+
viewer: { can_act: 'boolean', can_override: 'boolean', is_submitter: 'boolean' },
385+
},
386+
368387
indexes: [
369388
// Look up "is there a pending request for this record?" — common
370389
// guard on submit and on edit-while-locked checks.
@@ -398,7 +417,8 @@ export const SysApprovalRequest = ObjectSchema.create({
398417
// (the lever that releases the record without recording a decision nobody
399418
// made). `viewer` is
400419
// attached by getRequest/listRequests; where it is absent the predicate fails
401-
// closed.
420+
// closed. Its leaves are declared under `attachedOnRead` above, which is what
421+
// the build validator judges these predicates against.
402422
//
403423
// Every predicate below is guarded for the SPARSE action face (#8990). This
404424
// binding is a list row or a record read carrying only what the caller

0 commit comments

Comments
 (0)