Skip to content

Commit 896a434

Browse files
fix(plugin-approvals, plugin-audit)!: a lookup title is served only for a target whose declared exposure serves get (#22766)
Fixes #22738 Clause-②: no (narrowing) Census rows 5 and 6 of #22661. Classes, positions and functions only. ## What changed Two reads followed a lookup to the TARGET object's title under a system context and never asked the target's declared exposure. Each now asks the spec's one decision, `canServeApiOperation` (`@objectstack/spec/data`, ADR-0049), for `get` before it reads: the operation the data door's `$expand` and the dataset door's labels already ask of the same target (#22735). No second rule, no new error code, and no new package dependency (both packages already depend on `@objectstack/spec`). - **Row 5, the approvals inbox** (`plugin-approvals`, `approval-service.ts`). `ApprovalService.servesPayloadDisplayTarget` is asked in `ApprovalService.enrichRows` before the referenced-title read. A refused target is not read. Its key gets no `payload_display` entry, and the snapshot's stored id stands, which is what a deleted target already answers. - **Row 6, the activity summary** (`plugin-audit`, `audit-writers.ts`). `servesSummaryTitleTarget` is asked in `resolveLookupTitles`, where both the tracked-change branch and the milestone branch end. A refused target is not read, and the summary names the record by its stored id, which is what an unresolvable reference already answers. Only rows written from now on change. No stored row is rewritten, and no other activity column changes. The audit lane only tightens what it serves here and widens nothing. - **Where the declaration comes from (measured).** Both read the target's `enable` block through `engine.getSchema(target)`, which is `ObjectQL.getSchema`, the schema registry's `getObject`. That is the registry the data door's exposure gate and the analytics door's declaration provider read. A throwing read withholds the title and logs one `warn` line (fail-closed, as `servesLabelTarget` does). An engine without `getSchema` resolves no lookup field in either package (`resolveLookupFields` and the summary's read plan come back empty), so nothing is served on that branch either. - **The enumeration pin** (`packages/core/src/security/second-object-read-exposure.pin.test.ts`). Rows 5 and 6 move from `open` to `decided`. Each row is held to its decision function, its call site and its behaviour pin. ## Cross-domain path The fix lands in two `domain:services` packages, `plugin-approvals` and `plugin-audit`. The path was declared in the claim and posted to the services seat (#6021) before any edit. Nothing in either package outside the title resolution is touched. ## Deviation from the suggested route For a refused key, the inbox sends NO `payload_display` entry rather than writing the stored id into `payload_display`. `payload_display` maps a key to a resolved display value, and the console's inbox card drops an unresolved reference rather than render its id. Writing the id there would make the card render an opaque id as if it were a resolved title. The stored id still stands in `payload`, which is the answer a deleted target gets. ## Measurement (real stack, fixture objects, administrator and member) - **Before** (`bf515e724d`): both positions served the title of every unexposed target shape (the off switch, a whitelist without `get`, and the deny-all whitelist) to both personas. The exposed control served its title. - **After:** the unexposed targets answer the stored id at both positions for both personas, and the control is unchanged. - **Armed** before anything is believed: the data door answers each target's `get` as the decision does (404, 405, 405, 200), and the request and the activity row exist at rest. - **Producers (measured on `bf515e724d`).** Ten in-repo objects refuse `get` by declaration. Exactly one lookup points into any of them, from an object that is itself `apiEnabled: false` (control: 88 lookups into `sys_user`). No shipped object's served title changes. ## Pins and ablation - `plugin-approvals/src/payload-display-target-exposure.test.ts`: 2 cases. A refused target is not read, and the served ones keep their titles. An unreadable declaration withholds, with a `warn` line. - `plugin-audit/src/audit-lookup-summary.test.ts`: 2 new cases, the same two properties, through a real `ObjectQL` (19/19 in the file). - `packages/qa/dogfood/test/lookup-title-exposure.dogfood.test.ts`: 4 cases (inbox list and item, activity summary; administrator and member). 4/4 pass at `fb7da543d0` after a post-merge rebuild of the closure. - **Ablation**, via `scripts/ablation-replace.mjs` (anchor hit 1 to 0, blob changed; every restore proved blob equal to HEAD with an empty `git diff HEAD`): - **A.** Approvals decision bypassed: approvals pin 2 red of 2. - **A2.** Approvals fail-closed branch opened: 1 red, 1 green (both re-run on `4437a3219f`). - **B.** Audit decision bypassed: audit file 1 red, 18 green. - **B2.** Audit fail-closed branch opened: 1 red, 18 green. - **C.** Both bypassed with a marker that survives the build, both packages rebuilt, and `ablation-dist-preflight` found the audit marker in 2 built files: dogfood 4 red of 4. - **C-appr.** Approvals alone, with the marker in 2 built files: dogfood inbox 2 red, activity 2 green. - **Restore legs:** rebuilt, both markers `--absent`, tree clean, dogfood 4/4 green. - **D.** Each new decision function replaced by a hand-spelled rule: the enumeration pin goes red, naming the function. - **Note on the builds.** The first leg-C approvals build failed at the DTS step (TS6133, the import left unused by the mutation), so the approvals preflight never ran in that leg. C-appr re-ran it: the JS was emitted before the DTS step, and the preflight found the marker in `dist`. `@objectstack/plugin-approvals` also resolves to source in the dogfood project (alias). ## Verification (final head `4437a3219f` unless noted) - **Gates.** `dispatch-gates --commands --repo objectstack-ai/objectstack` derived 73 families (they include all 53 named in the dispatch). All 73 exit 0, each exit captured before any pipe. `--ran` reports 73 derived, 73 run, 0 NOT-MEASURED, 0 UNRUN, a derived zero. `check:dual-build-cjs-loads` answered PREREQUISITE NOT MET until a full build (72 tasks). On the first run, `check:objectql-double-limit` flagged the approvals pin's engine double as unjudged. The double now answers a table whole, because what the pin holds is which objects are read. - **Package tests.** `plugin-approvals` 72 files / 1025 tests and `plugin-audit` 45 / 716 at `c4f5cfa94a`, before merging `origin/main`, which touches neither package. `core` repo project 5 files / 55 tests. - **Typecheck.** Exit 0 for `plugin-approvals`, `plugin-audit`, `core` and `dogfood`, each new test present in its program (`--listFiles`). - **Lint (narrowed).** `eslint --no-inline-config --format json` over the 6 changed `.ts` files: 6 files, 0 errors, 0 warnings (5 to 6 active rules each, read from `--print-config`). `eslint.config.mjs` enables no type-aware linting, so no untouched file's verdict can move. The repo-wide `pnpm lint` is CI's. - **Line budget.** 7 files, +509 / -15. Source is +68 / -6. The real-stack pin is 220 of the lines: it measures the served doors for both personas, which a unit double cannot reach because each read runs under a system context. ## Acceptance notes - This narrowing owes one contract-review-tier review before the queue. The seat arranges it. - The `sys_user` display names (submitter, approvers, the activity actor) are fixed-target reads. The enumeration pin names them and does not hold them. `sys_user` serves `get`, so a `user` reference field is unchanged. --- _Generated by [Claude Code](https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 490cb6d commit 896a434

7 files changed

Lines changed: 509 additions & 15 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@objectstack/plugin-approvals': minor
3+
'@objectstack/plugin-audit': minor
4+
---
5+
6+
fix(plugin-approvals,plugin-audit)!: a lookup target's title is read only when the TARGET object's declared exposure serves `get` — the approvals inbox's `payload_display` and the activity summary (#22738)
7+
8+
Clause-②: no (narrowing)
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, export or stored shape is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is two served titles: each stops naming a lookup's target object whose existing `enable` declaration already refuses `get` on every data route, and the remedy is the declaration the author already wrote. The other categories are closed on facts: both packages publish (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and no exported declaration is removed or narrowed (not runtime-interface-only or type-surface-only). -->
11+
12+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
13+
14+
The data routes judge an object's `enable` block through the spec's one exposure decision (`apiExposureDenialReason` / `canServeApiOperation`), and the data door's `$expand` and the dataset door's labels already ask it of a lookup's TARGET. Two more reads follow a lookup to its target's title under a system context and never asked it.
15+
16+
**FROM.** For an administrator and a member alike:
17+
18+
- **Approvals inbox** (`@objectstack/plugin-approvals`): `GET /api/v1/approvals/requests` and `GET /api/v1/approvals/requests/:id` carried, in `payload_display`, the title of a snapshot lookup's target whose declaration refuses `get`.
19+
- **Activity summary** (`@objectstack/plugin-audit`): an update's tracked-change summary, and a fired milestone's summary, named such a target's records by title in the `sys_activity` row served by `GET /api/v1/data/sys_activity`.
20+
21+
**TO.** Each title read first asks the decision of the TARGET object, for `get` (turning an id into the record it names, the read `GET /api/v1/data/:target/:id` performs):
22+
23+
- A refused target is not read. The inbox carries no `payload_display` entry for that key, so the snapshot's stored id stands, which is what a deleted target already answers. The activity summary names the record by its stored id, which is what an unresolvable reference already answers.
24+
- A declaration that cannot be read withholds the title too (fail-closed, at `warn`).
25+
- Only activity rows written from now on change. A written row is a snapshot and is never rewritten, and no other activity column changes.
26+
27+
A target declaring `apiEnabled: false`, the deny-all `apiMethods: []`, or a whitelist without `get` (for example `['list']`) is withheld; a target with no `enable` block, or a whitelist that grants `get`, is served exactly as before. The decision takes no caller.
28+
29+
**Measured producers.** Read on `origin/main` `bf515e724d` over every non-test `.ts` source under `packages/` and `examples/`: ten objects refuse `get` by declaration, and exactly one lookup points into any of them, from an object that is itself `apiEnabled: false` (control: 88 lookups into `sys_user`). So no shipped object's served title changes. Deployed and cloud-held object definitions were NOT MEASURED.

‎packages/core/src/security/second-object-read-exposure.pin.test.ts‎

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -141,16 +141,20 @@ const CALLERS: Record<string, Classification> = {
141141
'provenance row for the door and touches a filter position; carried by a follow-up of #22661',
142142
},
143143
'packages/plugins/plugin-approvals/src/approval-service.ts': {
144-
kind: 'open',
144+
kind: 'decided',
145145
read: "the approvals inbox's `payload_display` (`ApprovalService.enrichRows` reads referenced records' titles under a system context)",
146-
measured: "an unexposed target's title is served on the inbox list, to an administrator and a member",
147-
carrier: 'outside the card that took the census (another package and lane); carried by a follow-up of #22661',
146+
operation: 'get',
147+
decision: { file: 'packages/plugins/plugin-approvals/src/approval-service.ts', fn: 'servesPayloadDisplayTarget' },
148+
wiring: { file: 'packages/plugins/plugin-approvals/src/approval-service.ts', call: 'this.servesPayloadDisplayTarget(' },
149+
pin: 'packages/plugins/plugin-approvals/src/payload-display-target-exposure.test.ts',
148150
},
149151
'packages/plugins/plugin-audit/src/audit-writers.ts': {
150-
kind: 'open',
151-
read: "the activity timeline's tracked-change summary (`resolveLookupTitles` writes referenced records' titles at write time)",
152-
measured: "an unexposed target's title is served in the summary on the activity read, to an administrator and a member",
153-
carrier: 'outside the card that took the census (another package and lane); carried by a follow-up of #22661',
152+
kind: 'decided',
153+
read: "the activity timeline's tracked-change and milestone summaries (`resolveLookupTitles` writes referenced records' titles at write time)",
154+
operation: 'get',
155+
decision: { file: 'packages/plugins/plugin-audit/src/audit-writers.ts', fn: 'servesSummaryTitleTarget' },
156+
wiring: { file: 'packages/plugins/plugin-audit/src/audit-writers.ts', call: 'servesSummaryTitleTarget(objectName' },
157+
pin: 'packages/plugins/plugin-audit/src/audit-lookup-summary.test.ts',
154158
},
155159
'packages/lint/src/object-graph.ts': {
156160
kind: 'not-a-served-read',
@@ -280,7 +284,12 @@ describe('[#22661] every read that reaches a second object asks that object its
280284
const decided = Object.values(CALLERS).filter(
281285
(c): c is Extract<Classification, { kind: 'decided' }> => c.kind === 'decided',
282286
);
283-
expect(decided.map((d) => d.decision.fn).sort()).toEqual(['servesExpansionTarget', 'servesLabelTarget']);
287+
expect(decided.map((d) => d.decision.fn).sort()).toEqual([
288+
'servesExpansionTarget',
289+
'servesLabelTarget',
290+
'servesPayloadDisplayTarget',
291+
'servesSummaryTitleTarget',
292+
]);
284293

285294
for (const d of decided) {
286295
const body = functionBody(d.decision.file, d.decision.fn);

‎packages/plugins/plugin-approvals/src/approval-service.ts‎

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,8 @@ import type {
5656
// fields the caller had already supplied.
5757
import type { ExecutionContext } from '@objectstack/spec/kernel';
5858
import { RESUME_AUTHORITY_SERVICE } from '@objectstack/spec/contracts';
59-
import { isFileIdToken, referenceTargetOf } from '@objectstack/spec/data';
60-
import type { ObjectCapabilitiesParsed } from '@objectstack/spec/data';
59+
import { canServeApiOperation, isFileIdToken, referenceTargetOf } from '@objectstack/spec/data';
60+
import type { EnableLike, ObjectCapabilitiesParsed } from '@objectstack/spec/data';
6161
// [#11993] The SANCTIONED renderer for OPERATION-level refusal copy. The
6262
// Operation Message Catalog is the ONE seat for these sentences — its own
6363
// header bars both a package-local string table and a second rendering
@@ -6391,6 +6391,31 @@ export class ApprovalService implements IApprovalService {
63916391
} catch { return []; }
63926392
}
63936393

6394+
/**
6395+
* [#22738] May `payload_display` carry a title read from `target`, the object
6396+
* a snapshot's lookup points at? Asked of the spec's one exposure decision
6397+
* (ADR-0049) for `get`, the read `GET /data/{target}/{id}` performs and the
6398+
* operation the data door's `$expand` and the dataset door's labels ask of
6399+
* the same target. It takes no caller: the read runs under a system context.
6400+
*
6401+
* A refused target is not read, so its key gets no display value and the
6402+
* snapshot's stored id stands, the answer a deleted target already gets. A
6403+
* declaration that cannot be read withholds too (fail-closed, at `warn`).
6404+
*/
6405+
private servesPayloadDisplayTarget(target: string): boolean {
6406+
let enable: EnableLike | null | undefined;
6407+
try {
6408+
enable = (this.engine as any).getSchema?.(target)?.enable;
6409+
} catch (err: any) {
6410+
this.logger?.warn?.(
6411+
`[approvals] ApprovalService.enrichRows: the API exposure declaration of "${target}" could not be read, `
6412+
+ `so its titles are not resolved and the stored ids stand (fail-closed): ${err?.message ?? err}`,
6413+
);
6414+
return false;
6415+
}
6416+
return canServeApiOperation(enable, 'get');
6417+
}
6418+
63946419
/**
63956420
* Field key → display label for an object's schema. Lets the inbox summary
63966421
* show a human field name ("考核状态") instead of a title-cased machine key
@@ -6419,7 +6444,8 @@ export class ApprovalService implements IApprovalService {
64196444
* Batched: one query per distinct object (target + referenced) plus one
64206445
* `sys_user` lookup. Best-effort — a deleted record falls back to the
64216446
* payload snapshot, and any failure leaves the field unset rather than
6422-
* failing the list.
6447+
* failing the list. [#22738] A referenced object whose declared exposure
6448+
* does not serve `get` is not read ({@link servesPayloadDisplayTarget}).
64236449
*/
64246450
private async enrichRows(rows: ApprovalRequestRow[]): Promise<void> {
64256451
if (!rows.length) return;
@@ -6477,6 +6503,7 @@ export class ApprovalService implements IApprovalService {
64776503
}
64786504
const refTitles = new Map<string, string>();
64796505
for (const [object, idSet] of refIds) {
6506+
if (!this.servesPayloadDisplayTarget(object)) continue;
64806507
const ids = Array.from(idSet);
64816508
const displayField = this.resolveDisplayField(object);
64826509
try {
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license.
2+
//
3+
// [#22738] The inbox's `payload_display` reads a referenced record's title only
4+
// from a target whose declared exposure (ADR-0049) serves `get`, asked through
5+
// the spec's one decision. A refused target is NOT READ, so its key carries no
6+
// display value and the snapshot's stored id stands; a declaration that cannot
7+
// be read withholds too, and says so at `warn`. The exposed targets are the
8+
// control. The read is asserted as well as the value: "not served" could
9+
// otherwise be met by reading the title and dropping it.
10+
11+
import { describe, it, expect, vi } from 'vitest';
12+
import { ApprovalService } from './approval-service.js';
13+
14+
type Row = Record<string, unknown>;
15+
type Schema = { label?: string; enable?: Record<string, unknown>; fields: Record<string, unknown> };
16+
17+
/** One target per shape the decision tells apart; `open` declares no `enable` block. */
18+
const TARGETS: Record<string, Record<string, unknown> | undefined> = {
19+
hidden: { apiEnabled: false },
20+
listonly: { apiMethods: ['list'] },
21+
denyall: { apiMethods: [] },
22+
getonly: { apiMethods: ['get'] },
23+
open: undefined,
24+
};
25+
const REFUSED = ['hidden', 'listonly', 'denyall'];
26+
const SERVED = ['getonly', 'open'];
27+
28+
const schemas: Record<string, Schema> = {
29+
deal: {
30+
label: 'Deal',
31+
fields: { name: {}, ...Object.fromEntries(Object.keys(TARGETS).map((k) => [k, { type: 'lookup', reference: `t_${k}` }])) },
32+
},
33+
...Object.fromEntries(Object.entries(TARGETS).map(([k, enable]) => [`t_${k}`, { fields: { name: {} }, ...(enable ? { enable } : {}) }])),
34+
};
35+
const tables: Record<string, Row[]> = {
36+
deal: [{ id: 'd1', name: 'Deal one' }],
37+
...Object.fromEntries(Object.keys(TARGETS).map((k) => [`t_${k}`, [{ id: `${k}_1`, name: `${k} title` }]])),
38+
};
39+
40+
/** The two engine members the enrichment reads; `unreadable` names a target whose declaration throws. */
41+
function makeEngine(rows: Record<string, Row[]>, unreadable?: string) {
42+
const reads: string[] = [];
43+
const engine = {
44+
getSchema(object: string) {
45+
if (object === unreadable) throw new Error('declaration unavailable');
46+
return schemas[object];
47+
},
48+
// Each table holds the one row a read can ask for, so the double answers the
49+
// table whole: what is pinned is WHICH objects are read, not the query.
50+
async find(object: string) {
51+
reads.push(object);
52+
return rows[object] ?? [];
53+
},
54+
};
55+
return { engine, reads };
56+
}
57+
58+
function makeService(unreadable?: string) {
59+
const warn = vi.fn();
60+
const { engine, reads } = makeEngine(tables, unreadable);
61+
const service = new ApprovalService({ engine: engine as any, logger: { info() {}, warn, error() {}, debug() {} } });
62+
// `enrichRows` is private; its public callers drag the whole request lifecycle in.
63+
const enrich = (rows: Row[]) => (service as unknown as { enrichRows(r: Row[]): Promise<void> }).enrichRows(rows);
64+
return { enrich, reads, warn };
65+
}
66+
67+
const inboxRow = (): Row => ({
68+
object_name: 'deal',
69+
record_id: 'd1',
70+
payload: { name: 'Deal one', ...Object.fromEntries(Object.keys(TARGETS).map((k) => [k, `${k}_1`])) },
71+
});
72+
73+
describe('[#22738] payload_display serves a referenced title only for a target whose exposure serves `get`', () => {
74+
it('does not read a refused target; its stored id stands, and the served targets keep their titles', async () => {
75+
const { enrich, reads } = makeService();
76+
const row = inboxRow();
77+
await enrich([row]);
78+
79+
expect(row.payload_display).toEqual(Object.fromEntries(SERVED.map((k) => [k, `${k} title`])));
80+
for (const k of REFUSED) {
81+
expect(reads, `t_${k} was read`).not.toContain(`t_${k}`);
82+
expect((row.payload as Row)[k]).toBe(`${k}_1`);
83+
}
84+
for (const k of SERVED) expect(reads).toContain(`t_${k}`);
85+
});
86+
87+
it('withholds a target whose declaration cannot be read (fail-closed), and says so at warn', async () => {
88+
const { enrich, reads, warn } = makeService('t_open');
89+
const row = inboxRow();
90+
await enrich([row]);
91+
92+
expect(row.payload_display).toEqual({ getonly: 'getonly title' });
93+
expect(reads).not.toContain('t_open');
94+
expect(warn.mock.calls.some(([message]) => String(message).includes('"t_open"'))).toBe(true);
95+
});
96+
});

‎packages/plugins/plugin-audit/src/audit-lookup-summary.test.ts‎

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
* separately to confirm it goes red on its own defect.
4848
*/
4949

50-
import { describe, it, expect } from 'vitest';
50+
import { describe, it, expect, vi } from 'vitest';
5151
import { ObjectQL } from '@objectstack/objectql';
5252
import { createMemoryI18n } from '@objectstack/core';
5353
import { installAuditWriters } from './audit-writers.js';
@@ -520,3 +520,81 @@ describe('[#7230] the resolution is batched per target object, and free when unu
520520
expect(lastSummary(storeFor)).toBe('Account: Acme Corp → Globex');
521521
});
522522
});
523+
524+
// ---------------------------------------------------------------------------
525+
// 4. [#22738] Exposure — a title is read only from a target that serves `get`
526+
// ---------------------------------------------------------------------------
527+
528+
/**
529+
* One target per shape the spec's exposure decision tells apart (ADR-0049):
530+
* the off switch, a whitelist without `get`, a deny-all whitelist, and a
531+
* whitelist that grants `get`; `crm_account` (no `enable` block) is the open
532+
* control. `lte_deal` tracks a lookup into each, keyed by the target's name.
533+
*/
534+
const exposureTarget = (name: string, enable: Record<string, unknown>) => ({
535+
name, label: name, enable,
536+
fields: { id: f('id', 'text', { primaryKey: true }), name: f('name', 'text') },
537+
});
538+
const REFUSED = ['lte_hidden', 'lte_listonly', 'lte_denyall'] as const;
539+
const exposureTargets = [
540+
exposureTarget('lte_hidden', { apiEnabled: false }),
541+
exposureTarget('lte_listonly', { apiMethods: ['list'] }),
542+
exposureTarget('lte_denyall', { apiMethods: [] }),
543+
exposureTarget('lte_getonly', { apiMethods: ['get'] }),
544+
];
545+
const lteDeal = {
546+
name: 'lte_deal', label: 'Deal',
547+
fields: {
548+
id: f('id', 'text', { primaryKey: true }),
549+
title: f('title', 'text'),
550+
...Object.fromEntries([...exposureTargets.map((t) => t.name), 'crm_account'].map((t) => [
551+
t, f(t, 'lookup', { label: t, reference: t, trackHistory: true }),
552+
])),
553+
},
554+
};
555+
556+
async function bootExposure() {
557+
const booted = await boot();
558+
for (const o of [...exposureTargets, lteDeal]) booted.engine.registry.registerObject(o as any, OWNER_PACKAGE);
559+
await booted.engine.insert('crm_account', { id: 'acc_1', name: 'Acme Corp' });
560+
for (const t of exposureTargets) await booted.engine.insert(t.name, { id: `${t.name}_1`, name: `${t.name} title` });
561+
await booted.engine.insert('lte_deal', { id: 'd1', title: 'Deal' });
562+
return booted;
563+
}
564+
565+
describe('[#22738] a tracked reference into a target whose exposure refuses `get` is named by its stored id', () => {
566+
it('reads and titles only the targets that serve `get`; a refused target is not read', async () => {
567+
const { engine, reads, storeFor } = await bootExposure();
568+
const before = { ...reads.findOn };
569+
await engine.update(
570+
'lte_deal',
571+
Object.fromEntries([...exposureTargets.map((t) => [t.name, `${t.name}_1`]), ['crm_account', 'acc_1']]),
572+
{ where: { id: 'd1' } } as any,
573+
);
574+
575+
const summary = String(lastSummary(storeFor));
576+
for (const t of REFUSED) {
577+
expect(summary).toContain(`${t}: ∅ → ${t}_1`);
578+
expect(summary).not.toContain(`${t} title`);
579+
expect((reads.findOn[t] ?? 0) - (before[t] ?? 0), `${t} was read`).toBe(0);
580+
}
581+
expect(summary).toContain('lte_getonly: ∅ → lte_getonly title');
582+
expect(summary).toContain('crm_account: ∅ → Acme Corp');
583+
});
584+
585+
it('withholds a target whose declaration cannot be read (fail-closed), and says so at warn', async () => {
586+
const { engine, reads, storeFor } = await bootExposure();
587+
const getSchema = engine.getSchema.bind(engine);
588+
(engine as any).getSchema = (name: string) => {
589+
if (name === 'crm_account') throw new Error('declaration unavailable');
590+
return getSchema(name);
591+
};
592+
const warn = vi.spyOn((engine as any).logger, 'warn');
593+
const before = reads.findOn['crm_account'] ?? 0;
594+
await engine.update('lte_deal', { crm_account: 'acc_1' }, { where: { id: 'd1' } } as any);
595+
596+
expect(lastSummary(storeFor)).toBe('crm_account: ∅ → acc_1');
597+
expect((reads.findOn['crm_account'] ?? 0) - before).toBe(0);
598+
expect(warn.mock.calls.some(([message]) => String(message).includes('"crm_account"'))).toBe(true);
599+
});
600+
});

0 commit comments

Comments
 (0)