Skip to content

Commit 3403be8

Browse files
fix(plugin-approvals): sys_approval_request declares its per-caller viewer block under attachedOnRead (#22387) (#22479)
Fixes #22387 Clause-②: no Part of #22211 ## What this does `sys_approval_request` declares its per-caller `viewer` block (#3310) under `ObjectSchema.attachedOnRead` (#22386, PR #22425), in the shape ruling `6070963704` on #22211 names: ```ts attachedOnRead: { viewer: { can_act: 'boolean', can_override: 'boolean', is_submitter: 'boolean' }, }, ``` With the block declared, the shared build validator resolves `record.viewer` on this object and judges `record.viewer.LEAF` against the declared leaves. The object's own 8 action `visible` predicates pass. A misspelt leaf is still refused, and the refusal names the declared leaves. The declaration's second reader (ADR-0049) is a conformance test. It drives the real `ApprovalService` and holds the served block's keys, and the runtime type of each value, to the declaration read from the object. | file | change | |---|---| | `packages/plugins/plugin-approvals/src/sys-approval-request.object.ts` | the declaration and its comment; the action design note now points at it | | `packages/plugins/plugin-approvals/src/sys-approval-request-viewer.conformance.test.ts` | new: the conformance test | | `packages/plugins/plugin-approvals/src/sys-approval-request-attached-on-read.test.ts` | new: the validator pin | | `packages/plugins/plugin-approvals/package.json`, `vitest.config.ts`, `pnpm-lock.yaml` | `@objectstack/lint` joins the devDependencies, aliased to source for vitest (anchored regex, the `platform-objects` precedent), so the pin reads the current rule and not a `lint/dist` build (`check:test-source-alias`) | | `.changeset/22387-plugin-approvals-viewer-attached-on-read.md` | `patch` for `@objectstack/plugin-approvals` | Not touched: `approval-service.ts`, so the per-caller `viewer` semantics (#3310) do not change. No other read attachment is declared (`decision_progress`, `pending_approver_groups`, the flow steps). No `packages/spec`, `lint`, `formula`, `mcp` or `content/docs` edit. ## Reproduction The object was judged on a stack of `{ objects: [SysApprovalRequest] }`, through `validateStackExpressions` and `runAuthoringRules('build')`. This was a one-off tsx probe, deleted after the run. Before = the branch base `c512c255c`; after = this branch. | reading | before | after | |---|---|---| | `validateStackExpressions` | 8 issues, one per action: ``unknown field `viewer` on `sys_approval_request` `` at `object 'sys_approval_request' · action 'NAME' visible` for `approval_approve`, `approval_reject`, `approval_reassign`, `approval_send_back`, `approval_request_info`, `approval_remind`, `approval_recall`, `approval_resubmit` | 0 | | `runAuthoringRules('build')` | 29 findings: 8 errors (all `expression-invalid`, the same 8 sites), 21 warnings | 21 findings: 0 errors, the same 21 warnings (1 `title-format-retired`, 20 `field-group-undeclared`, pre-existing) | | `approval_send_back` rewritten to `record.viewer.can_actt` | (refused as above) | 1 issue: ``unknown field `viewer.can_actt` on `sys_approval_request` (the read attachment `viewer` declares `can_act`, `can_override`, `is_submitter`) — did you mean `viewer.can_act`?`` | The same reading through the public door, `os validate` (the built CLI on this branch). The probe was a scratch `defineStack` config holding this object as shipped, plus one record-change flow on it (see F1); its control leg drops the `attachedOnRead` key from the object, which is the base state: | `os validate` | object as base (no `attachedOnRead`) | object as this branch | |---|---|---| | exit | 1, `Author-time rules failed (9 issues)`: the 8 action `visible` predicates plus the flow condition, each ``unknown field `viewer` on `sys_approval_request` `` | 0, `Validation passed`, warnings only | The PM's mechanism assumptions, as measured: 1 holds (8 refusals on `origin/main`). 2 holds (strict parse and the collision refusal pass, the 8 pass, and the misspelt leaf is refused naming the leaves). 3 holds: `attachViewers` emits exactly `can_act`, `can_override`, `is_submitter`, all `boolean`, for all 5 caller shapes on both doors, and no extra key. 4 holds for the resolved type, with a nuance in the Acceptance notes. 5 is the door table below. ## The two field-existence doors (scope note `6075461181`) Both doors were measured with a probe on a real `ObjectQL` over `SqlDriver` (better-sqlite3, in memory). The plugin's own objects were registered from its manifest. The probe was deleted after the run. | door | builds `record.*` from | reaches an expression on `sys_approval_request`? | reading | threaded? | |---|---|---|---|---| | `service-automation`: the flow-registration resolver, `setObjectSchemaResolver` (`plugin.ts:1185`) | `registry.getObject(name).fields` | **Yes.** Every flow whose start node names `objectName: 'sys_approval_request'` is checked against it, and nothing refuses a record-change flow on this object. No shipped flow is one: `git grep` finds none under `packages/` or `examples/`. | The resolver was wired exactly as `plugin.ts` wires it, with the start condition `record.viewer.can_act == true`. The door logs ``unknown field `viewer` on `sys_approval_request` `` as an advisory, before and after this PR, and registration is not refused. The `record` such a flow binds is a stored row. The probe checked three rows: the `afterUpdate` hook row the record-change trigger reads as `ctx.result`; an `engine.find` row, which is what the generic data door and a `type: 'flow'` action's subject load read; and `getRequest`'s row. The first two carry no `viewer` key; `getRequest`'s row does. Executing the flow over the hook row fails: `condition failed to evaluate as CEL: No such key: viewer`. | **No, on purpose.** The door's verdict is the true one for a flow, because the flow's `record` never carries `viewer`. Threading the block would make the door accept a condition that fails on every run. The build-side disagreement this leaves is Acceptance note F1. | | `packages/mcp`: the `validate_expression` tool (`mcp-http-tools.ts:676`) | `describeObject(name).fields` | **No, in every shipped composition.** The tool's guard refuses any `sys_` object before it reads fields, unless the host registers the tools with `allowSystemObjects: true`. No shipped host does: the runtime's HTTP door passes only `grantedScopes` (`packages/runtime/src/domains/mcp.ts:134`), and the stdio plugin passes no options (`packages/mcp/src/plugin.ts:656`). | Through `MCPServerRuntime.handleHttpRequest`, with a bridge that maps the object the way the runtime's `describeObject` does. With the shipped options it answers `Object "sys_approval_request" is a system object and is not exposed via MCP`. Only with `allowSystemObjects: true` does it reach field existence, answering ``unknown field `viewer` `` (`unknown-field`). | No. `packages/mcp` is `domain:cli`'s and is not edited here. See Acceptance note F2. | ## Pins and ablations Every ablation went through `node scripts/ablation-replace.mjs`, in its wrap mode, under `os-verify-lock`. Each mutation was proven to land on disk by anchor count and blob change, and each restore was proven by blob equal to HEAD plus an empty `git diff HEAD`. The subject is this package's own `src/`, imported relatively. `@objectstack/lint` is aliased to source, so no rebuild sits between a mutation and the run. | pin | holds | ablation | red, as observed | restore | |---|---|---|---|---| | `sys-approval-request-attached-on-read.test.ts` (4 cases) | The object's `visible` predicates that read `record.viewer` pass `validateStackExpressions`, `runAuthoringRules('build')` (stack prepared with `normalizeStackInput` and `ObjectStackDefinitionSchema`, as `os build` / `os validate` prepare it) and the object save door's `runRuntimeAuthoringRules({ type: 'object' })`. A misspelt leaf is refused at all three, naming every declared leaf. Control: with the declaration taken away, every reader is refused again. | The `attachedOnRead` block deleted from the object (anchor 1 → 0, blob `c4b45c28b39d` → `fc171e176107`) | 3 of 4 red. "every predicate passes" fails with 8 ``unknown field `viewer` on `sys_approval_request` `` issues, where it expects `[]`. The misspelt-leaf case expects 1 refusal and gets 8. The population case gets 0 declared leaves. The control stays green, as it must: its verdict is the same either way. | blob `c4b45c28b39d` == HEAD, `git diff HEAD` empty | | `sys-approval-request-viewer.conformance.test.ts` (7 cases) | For 5 caller shapes (submitter, pending approver, override actor = platform admin, system context, the approver on the finalized request), through both `getRequest` and `listRequests`: the served `viewer` keys equal `Object.keys(SysApprovalRequest.attachedOnRead.viewer)`; each value passes the runtime test for its declared type; each shape is marked by its one true leaf; and across the battery every boolean leaf is observed both `true` and `false`. | the same deletion, same run | 6 of 7 red ("declares the block", then each of the 5 shapes on emitted keys). The both-values case passes vacuously with no declared leaf, which is why the "declares the block" case exists. | as above | | conformance | as above | `attachViewers` serves `can_act` as `heldSlot(...)`, dropping `!== undefined` (blob `9a1822845a2f` → `86ee0cb9a41a`) | 4 of 7 red, for example ``a current pending approver via getRequest: `can_act` declared boolean, served "u_app"`` and ``can_act across the battery: expected [ false, 'u_app', undefined ]`` | blob `9a1822845a2f` == HEAD, `git diff HEAD` empty | | conformance | as above | `attachViewers` also emits `can_comment: true` | 5 of 7 red, every shape: `emitted keys: expected [ 'can_act', 'can_comment', … ] to deeply equal [ 'can_act', 'can_override', … ]` | blob `9a1822845a2f` == HEAD, `git diff HEAD` empty | The first attempt at the extra-key ablation was refused by `ablation-replace` before it measured anything: its replacement contained the anchor, so the anchor count did not drop. The tool restored the file to HEAD. The rerun above used a replacement that does not contain the anchor. ## Tests All runs below are at HEAD `506350e9f`, the last commit on this branch. Heavy runs went through `os-verify-lock`; its `VERDICT command-exit` line is quoted. - **Build.** `pnpm --filter '@objectstack/plugin-approvals^...' --filter '@objectstack/lint...' build` and `pnpm --filter @objectstack/plugin-approvals build` both gave `VERDICT command-exit 0`. The dist-reading gates ran after `turbo run build --filter=!@objectstack/docs`, which built 72 tasks with 71 cached (`VERDICT command-exit 0`). - **Package tests.** `pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2`: `Test Files 64 passed (64)`, `Tests 916 passed (916)`, `VERDICT command-exit 0`. The two new files contribute 4 and 7 cases. - **Typecheck.** `pnpm --filter @objectstack/plugin-approvals typecheck`: `VERDICT command-exit 0`, with `check:test-typecheck: OK — … 8 file(s) / 324 error(s) / 27 pinned signature(s) held in test-typecheck-debt.json`. The ledger is unchanged, and the two new test files compile with zero errors. - **Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 77 families from this diff. All 77 ran, each with its exit code captured before any pipe, and all exited 0. `check:i18n` and `check:dual-build-cjs-loads` first exited 3 (`PREREQUISITE NOT MET`, no `dist/`) and exited 0 when rerun after the build. Reconciliation (`--ran`): `77 derived famil(ies) accounted for — 77 run, 0 NOT-MEASURED (a DERIVED zero — all 77 recorded an exit code and none of them is 3)`. Among them: `check:test-source-alias` OK (the new alias), `check:engine-double-contract` exit 0 (no engine double was added; the conformance test runs on a real `ObjectQL`), `check:workspace-manifest-cycles` OK (517 edges, no cycle), `check:type-check-debt` OK, and `check:nul-bytes` OK. - **Lint, a declared narrowing.** `pnpm lint` (`eslint . --no-inline-config`) is CI's run. Locally I ran the same binary and flags over the diff's lintable files. (1) Which files are lintable comes from eslint itself: the 4 changed `.ts` files are lintable, while `package.json`, `pnpm-lock.yaml` and the changeset answer `File ignored because no matching configuration was supplied`. (2) The `--format json` output reports `files 4, errors 0, warnings 0`. (3) This diff cannot change any untouched file's verdict: `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`, no typed rules) and no cross-file `import/` rules. - **Not run locally.** The full `turbo` test sweep belongs to CI. `main` has moved 4 commits since the base `c512c255c` (rest, auth, fleet-write). None touches this diff's packages, so the branch was not merged. ## Acceptance notes - **F1. The build pair now accepts `record.viewer.*` in a flow condition on `sys_approval_request`, and that flow's `record` never carries the block.** `@objectstack/lint` applies an object's declared blocks to every expression site bound to that object, including a flow's start condition (scope `flattened`). Measured at the public door: `os validate` over a `defineStack` config holding this object plus a record-change flow on it (`requires: ['automation', 'triggers']`, start condition `record.viewer.can_act == true`) answers `Validation passed`, exit 0. With the object's `attachedOnRead` key dropped, the same config answers exit 1 and names that condition with ``unknown field `viewer` ``. Meanwhile the flow-registration door still warns, and the flow fails at runtime with `No such key: viewer`. The lint test written for #22386 records this as a ruled boundary: the ruling places the block in the field-existence set, which every surface reads. This PR does not change it, because there is no `packages/lint` edit on this card. Whether `attachedOnRead` should be judged only where the bound row is a served row (object action predicates) is a question for the `domain:spec` seat that owns #22386. A field formula or a validation rule on this object reads a stored row as well. No flow in the repository reads `record.viewer`. Both #22386's changesets and this one are still pending release, so nothing has shipped this behaviour yet. - **F2. The MCP `validate_expression` tool.** It is unreachable for `sys_approval_request` in every shipped composition (table above). A host that sets `allowSystemObjects: true` would get ``unknown field `viewer` `` for `record.viewer.can_act` at the `validation` site. That is the same expression the build now accepts on this object's action predicates. The tool has no action-predicate site, and its field set reads `fields` only. Not threaded here, because it is `domain:cli`'s file. - **The exported type.** The resolved type of `SysApprovalRequest` is unchanged. `ObjectSchema.create` returns `ServiceObject` with `Omit` applied to `fields`, intersected with `Pick` of the literal's `fields`, so only `fields` comes from the literal. The emitted declaration text does change. A declaration-only `tsc` emit of the object file, with and without the block, differs by 7 lines (`readonly attachedOnRead: { readonly viewer: { … } }`). Those lines sit inside the literal that `Pick` keeps only `fields` of (lines 1017–4776 of the emitted file), so `Pick` discards them. The served object definition, which is a value, gains the `attachedOnRead` key. The `metadata-core` field-level-security masker passes that key through unchanged (`OBJECT_REFERENCE_POSITIONS.attachedOnRead`, #22386). - **Stale prose outside this lane, once this lands.** `packages/spec/liveness/object.json` (the `attachedOnRead` row's note) says the leaf types are "a declaration awaiting its named reader" until this test lands. `packages/lint/src/authoring-rules.ts` (the #22032 pass-4 comment) records "the build refuses 8 … the producer fix is #22211" as a measurement. Both are `domain:spec` files, so they are not edited here. - The 21 build warnings on this object (`title-format-retired`, and 20 `field-group-undeclared` for groups the object never declares in `fieldGroups`) are pre-existing and unchanged. They are not this card's. - **Card #22211 is closed at landing by the seat, not by a keyword.** `check:closing-target-claim` refuses a closing keyword on card #22211: the governing `Claim:` on #22211 is `domain:spec` seat 3's (`6062089126`, branch `claude/issue-22211-approval-viewer-declaration`), and an execution seat claims only in its own lane. `domain:spec` seat 2 recorded "It closes when #22387's PR lands." (`6072125030`), so the `domain:services` seat 2 will close card #22211 by hand, citing that comment, once this merges. F1 is filed as #22481 for triage (`domain:spec` by its file). - `@objectstack/lint` is a devDependency only. The published `files` (`dist`, `README.md`, `CHANGELOG.md`) and the runtime dependencies are unchanged. --- _Generated by [Claude Code](https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b2549a8 commit 3403be8

7 files changed

Lines changed: 391 additions & 1 deletion
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/plugin-approvals": patch
3+
---
4+
5+
fix(plugin-approvals): `sys_approval_request` declares its per-caller `viewer` block under `attachedOnRead`, so the shared validator accepts the object's own action predicates (#22211, #22387)
6+
7+
`sys_approval_request` ships eight action `visible` predicates that read `record.viewer.can_act`, `record.viewer.can_override` or `record.viewer.is_submitter`. `viewer` is the block the approvals service attaches to every request it serves through `listRequests` and `getRequest`, computed from the caller. The object never declared it, so `os build` / `os validate` refused all eight with ``unknown field `viewer` on `sys_approval_request` ``, and the object save door's authoring gate refused a save of the shipped body the same way.
8+
9+
The object now declares `attachedOnRead: { viewer: { can_act: 'boolean', can_override: 'boolean', is_submitter: 'boolean' } }`. The eight predicates pass the build and the save door's gate. A misspelt leaf (`record.viewer.can_actt`) is still refused, and the refusal names the three declared leaves.
10+
11+
Unchanged: who sees which decision button. `viewer` is computed as before and served on the same two reads only; the generic data door, a record-change flow's `record` and a flow action's subject row do not carry it. The served object definition gains the `attachedOnRead` key. The exported `SysApprovalRequest` type resolves to the same type.

‎packages/plugins/plugin-approvals/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
},
3030
"devDependencies": {
3131
"@objectstack/driver-sql": "workspace:*",
32+
"@objectstack/lint": "workspace:*",
3233
"@objectstack/metadata-protocol": "workspace:*",
3334
"@objectstack/objectql": "workspace:*",
3435
"@objectstack/service-automation": "workspace:*",
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#22211 ruling A, #22387] `sys_approval_request`'s own action predicates pass
5+
* the shared validator, because the object declares the block they read.
6+
*
7+
* Eight shipped `visible` predicates gate on `record.viewer.*` — the per-caller
8+
* block `ApprovalService.attachViewers` sets on every row it serves (#3310).
9+
* Until the object declared that block under `attachedOnRead`, the validator
10+
* `os build` / `os validate` run refused all eight as naming an unknown field,
11+
* and so did the object save door once it judged action predicates.
12+
*
13+
* The object is judged here the way those commands judge it: the stack is
14+
* normalized and parsed (`normalizeStackInput`, `ObjectStackDefinitionSchema`),
15+
* then read by `validateStackExpressions` and `runAuthoringRules('build')`;
16+
* the save door is `runRuntimeAuthoringRules({ type: 'object' })`. The rule
17+
* under test is the expression rule (`expression-invalid`); the object's other
18+
* build findings are not this file's question.
19+
*
20+
* Every assertion reads the object's own predicates and its own declaration —
21+
* no predicate or leaf list is copied here. Two controls keep the green honest:
22+
* the same call refuses a misspelt leaf, naming the leaves the object declares,
23+
* and refuses every `viewer` read once the declaration is taken away.
24+
*/
25+
26+
import { describe, it, expect } from 'vitest';
27+
import { ObjectStackDefinitionSchema, normalizeStackInput } from '@objectstack/spec';
28+
import {
29+
EXPRESSION_INVALID,
30+
runAuthoringRules,
31+
runRuntimeAuthoringRules,
32+
validateStackExpressions,
33+
} from '@objectstack/lint';
34+
import { SysApprovalRequest } from './sys-approval-request.object.js';
35+
36+
const MANIFEST = {
37+
id: 'com.objectstack.test.approvals-attached-on-read',
38+
name: 'approvals_attached_on_read',
39+
version: '1.0.0',
40+
type: 'app',
41+
} as const;
42+
43+
type Action = { name: string; visible?: unknown };
44+
45+
/** An action predicate's source text — `ObjectSchema.create` stores the CEL envelope. */
46+
const sourceOf = (visible: unknown): string =>
47+
typeof visible === 'string' ? visible : String((visible as { source?: unknown } | undefined)?.source ?? '');
48+
49+
const ACTIONS = ((SysApprovalRequest as { actions?: Action[] }).actions ?? []);
50+
/** The shipped predicates that read the block: the population this file judges. */
51+
const VIEWER_READERS = ACTIONS.filter((a) => sourceOf(a.visible).includes('record.viewer.'));
52+
const DECLARED_LEAVES = Object.keys(SysApprovalRequest.attachedOnRead?.viewer ?? {});
53+
54+
const whereOf = (action: string) => `object 'sys_approval_request' · action '${action}' visible`;
55+
56+
/** The object, judged by the build pair and by the object save door. */
57+
function judge(object: Record<string, unknown>) {
58+
const normalized = normalizeStackInput({ manifest: MANIFEST, objects: [object] }) as Record<string, unknown>;
59+
const result = ObjectStackDefinitionSchema.safeParse(normalized);
60+
if (!result.success) {
61+
throw new Error(
62+
'fixture is not spec-valid: '
63+
+ result.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`).join('; '),
64+
);
65+
}
66+
const parsed = result.data as Record<string, unknown>;
67+
return {
68+
expressions: validateStackExpressions(parsed),
69+
build: runAuthoringRules('build', { normalized, parsed }).filter((f) => f.rule === EXPRESSION_INVALID),
70+
door: runRuntimeAuthoringRules({ type: 'object', item: object }).errors.filter((f) => f.rule === EXPRESSION_INVALID),
71+
};
72+
}
73+
74+
/** The object with one action's predicate rewritten. */
75+
function withPredicate(action: string, rewrite: (source: string) => string): Record<string, unknown> {
76+
return {
77+
...SysApprovalRequest,
78+
actions: ACTIONS.map((a) => (a.name === action ? { ...a, visible: rewrite(sourceOf(a.visible)) } : a)),
79+
};
80+
}
81+
82+
describe('sys_approval_request — its action predicates against its attachedOnRead declaration', () => {
83+
it('the population: shipped predicates read the block, and the object declares its leaves', () => {
84+
expect(VIEWER_READERS.length).toBeGreaterThan(0);
85+
expect(DECLARED_LEAVES.length).toBeGreaterThan(0);
86+
});
87+
88+
it('every predicate passes the build pair and the object save door', () => {
89+
const { expressions, build, door } = judge(SysApprovalRequest as Record<string, unknown>);
90+
expect(expressions, JSON.stringify(expressions, null, 2)).toEqual([]);
91+
expect(build, JSON.stringify(build, null, 2)).toEqual([]);
92+
expect(door, JSON.stringify(door, null, 2)).toEqual([]);
93+
});
94+
95+
it('a misspelt leaf is still refused at all three, naming the leaves the object declares', () => {
96+
const target = VIEWER_READERS[0].name;
97+
const leaf = DECLARED_LEAVES[0];
98+
const misspelt = `${leaf}_x`;
99+
const { expressions, build, door } = judge(
100+
withPredicate(target, (s) => s.replaceAll(`record.viewer.${leaf}`, `record.viewer.${misspelt}`)),
101+
);
102+
for (const [surface, found] of [['validateStackExpressions', expressions], ['build', build], ['door', door]] as const) {
103+
expect(found, `${surface}: one refusal`).toHaveLength(1);
104+
const [issue] = found as Array<{ message: string; where?: string; path?: string; severity?: string }>;
105+
expect(issue.severity ?? 'error', surface).toBe('error');
106+
// The subject is the misspelt path, and the remedy names every declared leaf.
107+
expect(issue.message, surface).toContain(`viewer.${misspelt}`);
108+
for (const declared of DECLARED_LEAVES) expect(issue.message, `${surface} names \`${declared}\``).toContain(`\`${declared}\``);
109+
}
110+
expect(expressions[0].where).toBe(whereOf(target));
111+
expect(build[0].where).toBe(whereOf(target));
112+
});
113+
114+
it('control: without the declaration, every predicate that reads the block is refused again', () => {
115+
const undeclared = Object.fromEntries(
116+
Object.entries(SysApprovalRequest).filter(([key]) => key !== 'attachedOnRead'),
117+
);
118+
const { expressions, build, door } = judge(undeclared);
119+
const readers = VIEWER_READERS.map((a) => whereOf(a.name)).sort();
120+
expect(expressions.map((i) => i.where).sort()).toEqual(readers);
121+
expect(build.map((f) => f.where).sort()).toEqual(readers);
122+
expect(door).toHaveLength(VIEWER_READERS.length);
123+
for (const issue of [...expressions, ...build, ...door]) expect(issue.message).toContain('`viewer`');
124+
});
125+
});
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+
});

0 commit comments

Comments
 (0)