Skip to content

Commit 4eb9f8c

Browse files
os-zhuangCopilot
andcommitted
test(objectql): overlay precedence + hash dry-run fixtures (ADR-0008 PR-10a)
Pins down two invariants that PR-10b/c (LayeredRepository overlay refactor) must preserve: 1. Whitelist enforcement — only allowOrgOverride:true types may be saved as per-org overlays in project-kernel mode. Verifies: - 5 accepted (view, dashboard, report, email_template, plural form) - 11 denied with code='not_overridable', status=403 (object, field, flow, workflow, agent, permission, role, profile, datasource, objects, flows) - Single-kernel mode (no projectId) bypasses the gate - Registry-as-source-of-truth: every allowed/denied type traces back to DEFAULT_METADATA_TYPE_REGISTRY.allowOrgOverride (Prime Directive #8 — no parallel allow-list) 2. Canonical hash stability — precondition for backfilling _hash on existing sys_metadata rows in PR-10b. Verifies canonicalize/ hashSpec are insensitive to key order, undefined-vs-absent, and deeply-nested optional fields; positional semantics preserved for arrays; distinct payloads → distinct hashes. Adds @objectstack/metadata-core as a workspace dep of @objectstack/ objectql to import canonicalize/hashSpec. Test count: objectql 258 → 286. All green. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 8494fe8 commit 4eb9f8c

4 files changed

Lines changed: 365 additions & 0 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
ADR-0008 M0 PR-10a: pin overlay-whitelist + canonical-hash invariants
6+
before re-expressing the overlay path as a LayeredRepository. No
7+
runtime change — adds 28 regression tests that fail loud if a future
8+
PR weakens the shared-DB tenancy contract or breaks hash stability.

‎packages/objectql/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"dependencies": {
2020
"@objectstack/core": "workspace:*",
2121
"@objectstack/formula": "workspace:*",
22+
"@objectstack/metadata-core": "workspace:*",
2223
"@objectstack/spec": "workspace:*",
2324
"@objectstack/types": "workspace:*",
2425
"zod": "^4.4.3"
Lines changed: 353 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,353 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* ADR-0008 PR-10a — Overlay precedence + hash dry-run fixtures.
5+
*
6+
* Pins down two invariants that must survive the PR-10b/c refactor
7+
* (re-expressing the overlay path as a LayeredRepository):
8+
*
9+
* 1. **Whitelist enforcement** — only metadata types whose registry
10+
* entry sets `allowOrgOverride: true` may be persisted as
11+
* per-organization overlays. Everything else (object, field, flow,
12+
* agent, permission, …) MUST throw with `code='not_overridable'`,
13+
* `status=403`. This is the shared-DB tenancy invariant
14+
* (ADR-0005 amendment §"Tenant-customizable type whitelist").
15+
*
16+
* 2. **Canonical hash stability** — every overlay row will carry a
17+
* content hash once PR-10b lands. The hash must be insensitive
18+
* to key order, whitespace, undefined-vs-absent, and otherwise
19+
* stable across structurally-equivalent payloads. This is the
20+
* dry-run precondition: if these properties hold, we can backfill
21+
* `_hash` for existing sys_metadata rows without surprises.
22+
*
23+
* No production code is touched by this file — it exists to fail loud
24+
* if a future PR weakens the contract.
25+
*/
26+
27+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
28+
import { ObjectStackProtocolImplementation } from './protocol.js';
29+
import { SchemaRegistry } from './registry.js';
30+
import { canonicalize, hashSpec } from '@objectstack/metadata-core';
31+
import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel';
32+
33+
// ──────────────────────────────────────────────────────────────────────
34+
// Shared test fixtures
35+
// ──────────────────────────────────────────────────────────────────────
36+
37+
const validView = {
38+
name: 'case_grid',
39+
label: 'Cases',
40+
object: 'case',
41+
columns: [
42+
{ field: 'name', label: 'Name' },
43+
{ field: 'status', label: 'Status' },
44+
],
45+
};
46+
47+
const validDashboard = {
48+
name: 'sales_overview',
49+
label: 'Sales Overview',
50+
widgets: [],
51+
};
52+
53+
const validReport = {
54+
name: 'monthly_revenue',
55+
label: 'Monthly Revenue',
56+
object: 'invoice',
57+
};
58+
59+
function makeProtocol(opts: { projectId?: string } = {}) {
60+
const registry = new SchemaRegistry({ multiTenant: false });
61+
const mockEngine: any = {
62+
registry,
63+
find: vi.fn().mockResolvedValue([]),
64+
findOne: vi.fn().mockResolvedValue(null),
65+
insert: vi.fn().mockResolvedValue({ id: 'new-uuid' }),
66+
update: vi.fn().mockResolvedValue({ id: 'existing-uuid' }),
67+
delete: vi.fn().mockResolvedValue({ deleted: 1 }),
68+
count: vi.fn().mockResolvedValue(0),
69+
aggregate: vi.fn().mockResolvedValue([]),
70+
};
71+
const protocol = new ObjectStackProtocolImplementation(
72+
mockEngine,
73+
undefined, // getServicesRegistry
74+
undefined, // getFeedService
75+
opts.projectId,
76+
);
77+
return { protocol, mockEngine, registry };
78+
}
79+
80+
// ══════════════════════════════════════════════════════════════════════
81+
// 1. Whitelist enforcement (ADR-0005 amendment §"Tenant-customizable …")
82+
// ══════════════════════════════════════════════════════════════════════
83+
84+
describe('overlay whitelist enforcement (shared-DB invariant)', () => {
85+
let protocol: ObjectStackProtocolImplementation;
86+
87+
beforeEach(() => {
88+
// projectId must be defined to engage the gate — single-kernel
89+
// deployments (no projectId) intentionally bypass it.
90+
({ protocol } = makeProtocol({ projectId: 'env_prod' }));
91+
});
92+
93+
afterEach(() => vi.clearAllMocks());
94+
95+
// ── allowed types: pure render-time, safe per-org override ──
96+
describe('allowed (allowOrgOverride: true) — must accept', () => {
97+
it('accepts view', async () => {
98+
const result = await protocol.saveMetaItem({
99+
type: 'view',
100+
name: 'case_grid',
101+
item: validView,
102+
organizationId: 'org_alpha',
103+
});
104+
expect(result.success).toBe(true);
105+
});
106+
107+
it('accepts dashboard', async () => {
108+
const result = await protocol.saveMetaItem({
109+
type: 'dashboard',
110+
name: 'sales_overview',
111+
item: validDashboard,
112+
organizationId: 'org_alpha',
113+
});
114+
expect(result.success).toBe(true);
115+
});
116+
117+
it('accepts report (flipped to allowOrgOverride:true on 2026-05-22)', async () => {
118+
// This test pins the user-requested change from 8494fe8e —
119+
// if someone flips report back to false, this fails loud.
120+
const result = await protocol.saveMetaItem({
121+
type: 'report',
122+
name: 'monthly_revenue',
123+
item: validReport,
124+
organizationId: 'org_alpha',
125+
});
126+
expect(result.success).toBe(true);
127+
});
128+
129+
it('accepts email_template', async () => {
130+
const result = await protocol.saveMetaItem({
131+
type: 'email_template',
132+
name: 'welcome',
133+
item: { name: 'welcome', subject: 'Hi', body: 'Hello' },
134+
organizationId: 'org_alpha',
135+
});
136+
expect(result.success).toBe(true);
137+
});
138+
139+
it('accepts plural form of allowed type (views)', async () => {
140+
const result = await protocol.saveMetaItem({
141+
type: 'views',
142+
name: 'case_grid',
143+
item: validView,
144+
organizationId: 'org_alpha',
145+
});
146+
expect(result.success).toBe(true);
147+
});
148+
});
149+
150+
// ── denied types: would break shared schema / behaviour ──
151+
describe('denied (allowOrgOverride: false) — must throw 403 not_overridable', () => {
152+
// Anything in this set, if accepted, can corrupt cross-tenant invariants.
153+
const denied: Array<{ type: string; reason: string; item: any }> = [
154+
{
155+
type: 'object',
156+
reason: 'per-org overlay would diverge the table schema',
157+
item: { name: 'case', fields: { extra: { type: 'text' } } },
158+
},
159+
{
160+
type: 'field',
161+
reason: 'per-org field overlay = unshared columns',
162+
item: { name: 'extra', type: 'text' },
163+
},
164+
{
165+
type: 'flow',
166+
reason: 'per-org flow overlay = silent automation drift',
167+
item: { name: 'on_create', triggers: [] },
168+
},
169+
{
170+
type: 'workflow',
171+
reason: 'same as flow',
172+
item: { name: 'approval', states: [] },
173+
},
174+
{
175+
type: 'agent',
176+
reason: 'per-org agent overlay = unsanctioned model calls',
177+
item: { name: 'helper', model: 'gpt-4' },
178+
},
179+
{
180+
type: 'permission',
181+
reason: 'overlays would create silent privilege drift',
182+
item: { name: 'admin', grants: [] },
183+
},
184+
{
185+
type: 'role',
186+
reason: 'authorization correctness',
187+
item: { name: 'editor', permissions: [] },
188+
},
189+
{
190+
type: 'profile',
191+
reason: 'authorization correctness',
192+
item: { name: 'sales_rep', permissions: [] },
193+
},
194+
{
195+
type: 'datasource',
196+
reason: 'wiring level; must be code, not metadata',
197+
item: { name: 'analytics', driver: 'sql' },
198+
},
199+
{
200+
type: 'objects', // plural
201+
reason: 'plural form of denied must also be denied',
202+
item: { name: 'case', fields: {} },
203+
},
204+
{
205+
type: 'flows', // plural
206+
reason: 'plural form of denied must also be denied',
207+
item: { name: 'on_create' },
208+
},
209+
];
210+
211+
for (const { type, reason, item } of denied) {
212+
it(`rejects ${type} — ${reason}`, async () => {
213+
await expect(
214+
protocol.saveMetaItem({
215+
type,
216+
name: item.name,
217+
item,
218+
organizationId: 'org_alpha',
219+
}),
220+
).rejects.toMatchObject({
221+
code: 'not_overridable',
222+
status: 403,
223+
});
224+
});
225+
}
226+
});
227+
228+
// ── single-kernel deployments: gate disengaged ──
229+
describe('single-kernel mode (no projectId) — gate bypassed', () => {
230+
it('allows object overlay when projectId is undefined', async () => {
231+
// No projectId => not project-kernel mode => legacy "anything goes"
232+
// path used by control-plane bootstrap. ADR-0005 §"Whitelist".
233+
const { protocol: localProto } = makeProtocol({ projectId: undefined });
234+
const result = await localProto.saveMetaItem({
235+
type: 'object',
236+
name: 'case',
237+
item: { name: 'case', label: 'Case', fields: {} },
238+
});
239+
expect(result.success).toBe(true);
240+
});
241+
});
242+
243+
// ── registry invariant: whitelist derives from spec, no parallel list ──
244+
describe('registry-as-source-of-truth (Prime Directive #8)', () => {
245+
it('every type in OVERLAY_ALLOWED_TYPES has allowOrgOverride:true in the registry', () => {
246+
// The protocol's whitelist is derived from
247+
// DEFAULT_METADATA_TYPE_REGISTRY. If anyone introduces a parallel
248+
// list, this test catches it: every accepted type must trace back
249+
// to a registry entry that opted in.
250+
const allowedFromRegistry = new Set<string>();
251+
for (const entry of DEFAULT_METADATA_TYPE_REGISTRY) {
252+
if (entry.allowOrgOverride) allowedFromRegistry.add(entry.type);
253+
}
254+
// Render-time types must be in the set; if any of these drop out,
255+
// the shared-DB contract is broken.
256+
expect(allowedFromRegistry.has('view')).toBe(true);
257+
expect(allowedFromRegistry.has('dashboard')).toBe(true);
258+
expect(allowedFromRegistry.has('report')).toBe(true);
259+
expect(allowedFromRegistry.has('email_template')).toBe(true);
260+
// DB-affecting types must NOT be in the set.
261+
expect(allowedFromRegistry.has('object')).toBe(false);
262+
expect(allowedFromRegistry.has('field')).toBe(false);
263+
expect(allowedFromRegistry.has('flow')).toBe(false);
264+
expect(allowedFromRegistry.has('workflow')).toBe(false);
265+
expect(allowedFromRegistry.has('agent')).toBe(false);
266+
expect(allowedFromRegistry.has('permission')).toBe(false);
267+
expect(allowedFromRegistry.has('role')).toBe(false);
268+
expect(allowedFromRegistry.has('profile')).toBe(false);
269+
});
270+
});
271+
});
272+
273+
// ══════════════════════════════════════════════════════════════════════
274+
// 2. Canonical hash stability (ADR-0008 PR-10b backfill precondition)
275+
// ══════════════════════════════════════════════════════════════════════
276+
277+
describe('canonical hash stability (PR-10b backfill precondition)', () => {
278+
it('canonicalize: key order does not change output', () => {
279+
const a = canonicalize({ b: 1, a: 2, c: 3 });
280+
const b = canonicalize({ c: 3, a: 2, b: 1 });
281+
expect(a).toBe(b);
282+
});
283+
284+
it('canonicalize: nested key order does not change output', () => {
285+
const a = canonicalize({ outer: { z: 1, a: 2 }, top: true });
286+
const b = canonicalize({ top: true, outer: { a: 2, z: 1 } });
287+
expect(a).toBe(b);
288+
});
289+
290+
it('hashSpec: returns "sha256:..." prefix with 64-hex digest', () => {
291+
const h = hashSpec({ name: 'case_grid' });
292+
expect(h).toMatch(/^sha256:[0-9a-f]{64}$/);
293+
});
294+
295+
it('hashSpec: stable across key reorder for a view payload', () => {
296+
const reordered = {
297+
columns: validView.columns,
298+
object: validView.object,
299+
label: validView.label,
300+
name: validView.name,
301+
};
302+
expect(hashSpec(validView)).toBe(hashSpec(reordered));
303+
});
304+
305+
it('hashSpec: undefined fields collapse to "absent" (typical PUT shape)', () => {
306+
// Studio PUTs frequently include `undefined` for optional fields the
307+
// user cleared. Canonicalize must drop these so the hash matches the
308+
// body as it would be re-read from the DB (where NULL columns vanish).
309+
const withUndef = { ...validView, description: undefined };
310+
expect(hashSpec(validView)).toBe(hashSpec(withUndef));
311+
});
312+
313+
it('hashSpec: dashboard payload deterministic', () => {
314+
const h1 = hashSpec(validDashboard);
315+
const h2 = hashSpec({ ...validDashboard });
316+
expect(h1).toBe(h2);
317+
});
318+
319+
it('hashSpec: report payload deterministic', () => {
320+
const h1 = hashSpec(validReport);
321+
const h2 = hashSpec({ ...validReport });
322+
expect(h1).toBe(h2);
323+
});
324+
325+
it('hashSpec: distinct payloads produce distinct hashes', () => {
326+
expect(hashSpec(validView)).not.toBe(hashSpec(validDashboard));
327+
expect(hashSpec(validView)).not.toBe(
328+
hashSpec({ ...validView, label: 'Different' }),
329+
);
330+
});
331+
332+
it('hashSpec: array order IS significant (positional semantics preserved)', () => {
333+
// Columns in a view are ordered — swapping them is a real change.
334+
const swapped = {
335+
...validView,
336+
columns: [validView.columns[1], validView.columns[0]],
337+
};
338+
expect(hashSpec(validView)).not.toBe(hashSpec(swapped));
339+
});
340+
341+
it('hashSpec: handles deeply-nested optional fields without throwing', () => {
342+
const deep = {
343+
...validView,
344+
filters: {
345+
where: { status: 'open', priority: undefined },
346+
sort: [{ field: 'name', dir: 'asc' }],
347+
},
348+
};
349+
expect(() => hashSpec(deep)).not.toThrow();
350+
// Same value re-evaluated yields same hash.
351+
expect(hashSpec(deep)).toBe(hashSpec(JSON.parse(JSON.stringify(deep))));
352+
});
353+
});

‎pnpm-lock.yaml‎

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)