Skip to content

Commit 6295f02

Browse files
committed
feat(storage): verifyFileReferences — the executable R4 acceptance gate
ADR-0104 D3 wave 2 planned to gate enabling collection on "reference counting is verified", which is aspirational rather than checkable. This makes it runnable. verifyFileReferences() compares GROUND TRUTH (what records' file-class fields actually hold) against RECORDED OWNERSHIP (sys_file.ref_object/ref_id/ref_field) and classifies every disagreement by whether it can cause DATA LOSS once collection is on, because only that class should block a release: blocking unowned_reference a record holds a file nothing owns — with collection on it looks free and its bytes go foreign_owner a record holds a file owned by a different slot; that reference is invisible to the lifecycle, so the bytes go when the recorded owner releases shared_reference one file held by two slots — exclusivity was violated, so copy-on-claim did not run, and one file's ACL is serving two parents advisory stale_owner owned but no longer held; fails toward retention unreferenced_file committed and unpointed-at — storage cost, not risk The scan is read-only: it never writes, tombstones or deletes. The point is that a ledger may not be handed authority over an irreversible delete until it has been shown to agree with reality, so this is meant to be run repeatedly, on real tenant data, and to report zero blocking discrepancies on consecutive runs before the gated collection change may merge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHpGw3GBA9aFpfwVArRWfd
1 parent f70b28b commit 6295f02

4 files changed

Lines changed: 635 additions & 0 deletions

File tree

.changeset/adr-0104-d3w2-pr4-governed-download.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,22 @@ an inline blob or an external URL, never a `sys_file` id, so no existing file
3131
has an owner recorded and none of them start being gated. The gate engages only
3232
for files a record's field has actually claimed, and disengages again when
3333
ownership is released.
34+
35+
---
36+
37+
Also adds `verifyFileReferences()` — the executable form of ADR-0104's R4
38+
acceptance gate. It compares ground truth (what records' file fields actually
39+
hold) against recorded ownership, and classifies disagreements by whether they
40+
could cause data loss once collection is enabled:
41+
42+
- **blocking**`unowned_reference` (a held file nothing owns), `foreign_owner`
43+
(a record holds a file owned by another slot), `shared_reference` (one file
44+
held by two slots, i.e. exclusivity was violated). Each would let a later reap
45+
delete bytes a record still points at.
46+
- **advisory**`stale_owner` (owned but no longer held; fails toward
47+
retention) and `unreferenced_file` (storage cost, not a correctness problem).
48+
49+
The scan is read-only — it never writes, tombstones, or deletes. A ledger may
50+
not be given authority over irreversible deletes until it has been shown to
51+
agree with reality, so this must report zero blocking discrepancies on real
52+
tenant data, on consecutive runs, before the gated collection change may merge.

packages/services/service-storage/src/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,17 @@ export { installAttachmentLifecycleHooks, createSysFileReapGuard, createUploadSe
1616
export type { AttachmentLifecycleEngine, AttachmentLifecycleLogger } from './attachment-lifecycle.js';
1717
export { installFileReferenceHooks, FileReferenceCopyError } from './file-reference-lifecycle.js';
1818
export type { FileReferenceEngine, FileReferenceLogger } from './file-reference-lifecycle.js';
19+
export {
20+
verifyFileReferences,
21+
formatFileReferenceReport,
22+
BLOCKING_ISSUE_KINDS,
23+
} from './verify-file-references.js';
24+
export type {
25+
FileReferenceIssue,
26+
FileReferenceIssueKind,
27+
FileReferenceReport,
28+
VerifyReferencesEngine,
29+
VerifyReferencesOptions,
30+
} from './verify-file-references.js';
1931
export { installAttachmentAccessHooks, installAttachmentReadVisibility } from './attachment-access-hooks.js';
2032
export type { AttachmentSharingLike } from './attachment-access-hooks.js';
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import {
5+
verifyFileReferences,
6+
formatFileReferenceReport,
7+
BLOCKING_ISSUE_KINDS,
8+
type VerifyReferencesEngine,
9+
} from './verify-file-references.js';
10+
11+
const REGISTRY: Record<string, any> = {
12+
sys_file: { fields: { id: { type: 'text' } } },
13+
product: {
14+
fields: {
15+
id: { type: 'text' },
16+
name: { type: 'text' },
17+
image: { type: 'image' },
18+
gallery: { type: 'image', multiple: true },
19+
},
20+
},
21+
tag: { fields: { id: { type: 'text' }, label: { type: 'text' } } },
22+
};
23+
24+
function fakeEngine(tables: Record<string, Array<Record<string, unknown>>>): VerifyReferencesEngine {
25+
const matches = (row: Record<string, unknown>, where: Record<string, unknown>) =>
26+
Object.entries(where).every(([k, v]) => {
27+
if (v && typeof v === 'object' && '$ne' in (v as any)) return row[k] !== (v as any).$ne;
28+
return row[k] === v;
29+
});
30+
return {
31+
getObject: (name) => REGISTRY[name],
32+
getConfigs: () => REGISTRY,
33+
async find(object, options: any) {
34+
const rows = (tables[object] ?? []).filter((r) => matches(r, options?.where ?? {}));
35+
const start = typeof options?.offset === 'number' ? options.offset : 0;
36+
const end = typeof options?.limit === 'number' ? start + options.limit : undefined;
37+
return rows.slice(start, end);
38+
},
39+
};
40+
}
41+
42+
const file = (id: string, owner?: { object: string; recordId: string; field: string }) => ({
43+
id,
44+
status: 'committed',
45+
scope: 'user',
46+
ref_object: owner?.object ?? null,
47+
ref_id: owner?.recordId ?? null,
48+
ref_field: owner?.field ?? null,
49+
});
50+
51+
describe('verifyFileReferences (ADR-0104 D3 wave 2 — R4 gate)', () => {
52+
it('reports a clean bill when ownership matches what records hold', async () => {
53+
const engine = fakeEngine({
54+
product: [{ id: 'p1', image: 'file_a' }],
55+
sys_file: [file('file_a', { object: 'product', recordId: 'p1', field: 'image' })],
56+
});
57+
58+
const report = await verifyFileReferences(engine);
59+
60+
expect(report.ok).toBe(true);
61+
expect(report.blocking).toBe(0);
62+
expect(report.issues).toEqual([]);
63+
expect(report.scannedObjects).toEqual(['product']);
64+
expect(report.heldReferences).toBe(1);
65+
expect(report.ownedFiles).toBe(1);
66+
expect(formatFileReferenceReport(report)).toContain('No discrepancies');
67+
});
68+
69+
it('only scans objects that declare a file-class field', async () => {
70+
const engine = fakeEngine({
71+
product: [{ id: 'p1' }],
72+
tag: [{ id: 't1', label: 'x' }],
73+
sys_file: [],
74+
});
75+
76+
const report = await verifyFileReferences(engine);
77+
78+
expect(report.scannedObjects).toEqual(['product']);
79+
expect(report.scannedRecords).toBe(1);
80+
});
81+
82+
// ── Blocking: these would delete bytes something still holds ──────
83+
it('BLOCKS on a held file with no recorded owner', async () => {
84+
const engine = fakeEngine({
85+
product: [{ id: 'p1', image: 'file_a' }],
86+
sys_file: [file('file_a')], // uploaded, never claimed
87+
});
88+
89+
const report = await verifyFileReferences(engine);
90+
91+
expect(report.ok).toBe(false);
92+
expect(report.counts.unowned_reference).toBe(1);
93+
expect(report.issues[0]).toMatchObject({
94+
kind: 'unowned_reference',
95+
fileId: 'file_a',
96+
object: 'product',
97+
recordId: 'p1',
98+
field: 'image',
99+
});
100+
expect(formatFileReferenceReport(report)).toContain('BLOCKING');
101+
});
102+
103+
it('BLOCKS when a record holds a file owned by a different slot', async () => {
104+
const engine = fakeEngine({
105+
product: [
106+
{ id: 'p1', image: 'file_a' },
107+
{ id: 'p2', image: 'file_a' },
108+
],
109+
sys_file: [file('file_a', { object: 'product', recordId: 'p1', field: 'image' })],
110+
});
111+
112+
const report = await verifyFileReferences(engine);
113+
114+
expect(report.ok).toBe(false);
115+
// p2's reference is invisible to the lifecycle: when p1 releases, the
116+
// bytes go while p2 still points at them.
117+
expect(report.counts.foreign_owner).toBe(1);
118+
expect(report.issues.find((i) => i.kind === 'foreign_owner')).toMatchObject({ recordId: 'p2' });
119+
// …and the exclusivity violation itself is reported too.
120+
expect(report.counts.shared_reference).toBe(1);
121+
});
122+
123+
it('BLOCKS on a file held by two slots (exclusivity violated)', async () => {
124+
const engine = fakeEngine({
125+
product: [{ id: 'p1', image: 'file_a', gallery: ['file_a'] }],
126+
sys_file: [file('file_a', { object: 'product', recordId: 'p1', field: 'image' })],
127+
});
128+
129+
const report = await verifyFileReferences(engine);
130+
131+
expect(report.counts.shared_reference).toBe(1);
132+
expect(report.ok).toBe(false);
133+
});
134+
135+
// ── Advisory: safe directions ─────────────────────────────────────
136+
it('reports a stale owner as advisory, not blocking', async () => {
137+
const engine = fakeEngine({
138+
product: [{ id: 'p1' }], // field cleared without the release landing
139+
sys_file: [file('file_a', { object: 'product', recordId: 'p1', field: 'image' })],
140+
});
141+
142+
const report = await verifyFileReferences(engine);
143+
144+
expect(report.counts.stale_owner).toBe(1);
145+
// Fails toward retention — the file is simply never collected.
146+
expect(report.blocking).toBe(0);
147+
expect(report.ok).toBe(true);
148+
});
149+
150+
it('reports unreferenced committed files only when asked, and never as blocking', async () => {
151+
const tables = {
152+
product: [{ id: 'p1' }],
153+
sys_file: [file('file_orphan')],
154+
};
155+
156+
const without = await verifyFileReferences(fakeEngine(tables));
157+
expect(without.counts.unreferenced_file).toBe(0);
158+
159+
const withSweep = await verifyFileReferences(fakeEngine(tables), { includeUnreferenced: true });
160+
expect(withSweep.counts.unreferenced_file).toBe(1);
161+
expect(withSweep.ok).toBe(true);
162+
});
163+
164+
it('ignores attachments-scope files, which sys_attachment governs', async () => {
165+
const engine = fakeEngine({
166+
product: [{ id: 'p1' }],
167+
sys_file: [{ ...file('file_att'), scope: 'attachments' }],
168+
});
169+
170+
const report = await verifyFileReferences(engine, { includeUnreferenced: true });
171+
172+
expect(report.counts.unreferenced_file).toBe(0);
173+
});
174+
175+
// ── Dual-mode ─────────────────────────────────────────────────────
176+
it('ignores inline blobs and URL values — a pre-cutover tenant reads clean', async () => {
177+
const engine = fakeEngine({
178+
product: [
179+
{ id: 'p1', image: { url: 'https://cdn.example.com/a.png', name: 'a.png' } },
180+
{ id: 'p2', image: 'https://cdn.example.com/b.png' },
181+
{ id: 'p3', image: 'data:image/svg+xml,<svg/>' },
182+
],
183+
sys_file: [],
184+
});
185+
186+
const report = await verifyFileReferences(engine);
187+
188+
expect(report.heldReferences).toBe(0);
189+
expect(report.issues).toEqual([]);
190+
expect(report.ok).toBe(true);
191+
});
192+
193+
// ── Mechanics ─────────────────────────────────────────────────────
194+
it('pages through records rather than reading one unbounded page', async () => {
195+
const many = Array.from({ length: 1200 }, (_, i) => ({ id: `p${i}`, image: `file_${i}` }));
196+
const engine = fakeEngine({
197+
product: many,
198+
sys_file: many.map((r, i) => file(`file_${i}`, { object: 'product', recordId: `p${i}`, field: 'image' })),
199+
});
200+
201+
const report = await verifyFileReferences(engine);
202+
203+
expect(report.scannedRecords).toBe(1200);
204+
expect(report.ownedFiles).toBe(1200);
205+
expect(report.ok).toBe(true);
206+
});
207+
208+
it('marks the verdict truncated when a scan bound is hit', async () => {
209+
const many = Array.from({ length: 1200 }, (_, i) => ({ id: `p${i}` }));
210+
const engine = fakeEngine({ product: many, sys_file: [] });
211+
212+
const report = await verifyFileReferences(engine, { maxRecordsPerObject: 500 });
213+
214+
expect(report.truncated).toBe(true);
215+
expect(formatFileReferenceReport(report)).toContain('truncated');
216+
});
217+
218+
it('can be scoped to specific objects', async () => {
219+
const engine = fakeEngine({
220+
product: [{ id: 'p1', image: 'file_a' }],
221+
sys_file: [file('file_a')],
222+
});
223+
224+
const report = await verifyFileReferences(engine, { objects: ['tag'] });
225+
226+
expect(report.scannedObjects).toEqual([]);
227+
expect(report.ok).toBe(true);
228+
});
229+
230+
it('classifies exactly the data-loss kinds as blocking', () => {
231+
expect([...BLOCKING_ISSUE_KINDS].sort()).toEqual([
232+
'foreign_owner',
233+
'shared_reference',
234+
'unowned_reference',
235+
]);
236+
});
237+
});

0 commit comments

Comments
 (0)