Skip to content

Commit c609cf6

Browse files
Closes #206: harden portable recovery contract
Synchronize recovery documentation with the current portable export layout and record the separate artifact-binary backup-proof boundary.
1 parent 0102101 commit c609cf6

5 files changed

Lines changed: 268 additions & 31 deletions

File tree

backend/src/export/archive.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ interface RestoreEvidenceReport {
6464
evidence_timestamp: string;
6565
smoke_check_checklist: Array<{ check: string; result: 'passed' | 'not_run' }>;
6666
production_write_gate: string;
67+
artifact_binary_backup_proof: string;
6768
}
6869

6970
interface RestoreEvidenceResult {
@@ -110,6 +111,8 @@ const RESTORE_SMOKE_CHECKS = [
110111
'List files for task',
111112
'Export target data and compare counts',
112113
];
114+
const ARTIFACT_BINARY_BACKUP_PROOF =
115+
'Not performed or verified by this drill; external artifact-binary backup proof remains the separate privately retained responsibility of the authorized artifact-storage operator.';
113116

114117
function normalizeRestoreTarget(targetEnvironment: string): string {
115118
return targetEnvironment.trim().replace(/[A-Z]/g, (character) =>
@@ -560,6 +563,7 @@ async function writeRestoreEvidence(options: RestoreEvidenceOptions): Promise<Re
560563
result: options.smokeChecksPassed ? 'passed' : 'not_run',
561564
})),
562565
production_write_gate: 'No restore/import/write action is performed by this drill. Production writes require a separate human-approved command.',
566+
artifact_binary_backup_proof: ARTIFACT_BINARY_BACKUP_PROOF,
563567
};
564568
const evidencePath = path.join(options.outputDir, `restore-evidence-${timestamp.replace(/[:.]/g, '-')}.json`);
565569
await fs.writeFile(evidencePath, JSON.stringify(report, null, 2) + '\n', 'utf8');

backend/tests/export-archive.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,14 @@ describe('offsite portable export archives', () => {
186186
assert.strictEqual(evidence.report.target_environment, 'Restore Drill');
187187
assert.ok(evidence.report.smoke_check_checklist.every((item) => item.result === 'passed'));
188188
assert.match(evidence.report.production_write_gate, /human-approved/);
189+
assert.match(
190+
evidence.report.artifact_binary_backup_proof,
191+
/Not performed or verified by this drill/,
192+
);
193+
assert.match(
194+
evidence.report.artifact_binary_backup_proof,
195+
/authorized artifact-storage operator/,
196+
);
189197
await fs.access(evidence.evidencePath);
190198
});
191199

backend/tests/export-portable.test.ts

Lines changed: 133 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,19 @@ import path from 'path';
77
import { DynamoDBDocumentClient, ScanCommand } from '@aws-sdk/lib-dynamodb';
88

99
import { getClient } from '../src/db/client';
10-
import { TABLE_NOTIFICATIONS } from '../src/db/tableNames';
10+
import {
11+
TABLE_ARTIFACTS,
12+
TABLE_ASSISTANT_JOBS,
13+
TABLE_AUDIT_EVENTS,
14+
TABLE_CARDS,
15+
TABLE_CONVERSATIONAL_STATE,
16+
TABLE_FILES,
17+
TABLE_INTAKE,
18+
TABLE_NOTIFICATIONS,
19+
TABLE_TASKS,
20+
TABLE_TEMPLATES,
21+
TABLE_USERS,
22+
} from '../src/db/tableNames';
1123
import { startLocal, stopLocal } from '../scripts/local-dynamodb';
1224
import { createTables } from '../scripts/local-dynamodb';
1325
import { appendAssistantJobEvent, createAssistantJob, updateAssistantJob } from '../src/db/assistantJobs';
@@ -27,6 +39,32 @@ import {
2739
writePortableExport,
2840
} from '../src/export/portable';
2941

42+
async function documentedRequiredLayouts(markdownPath: string): Promise<string[][]> {
43+
const markdown = await fs.readFile(markdownPath, 'utf8');
44+
const layouts = [...markdown.matchAll(/```text\n(manifest\.json\n(?:[a-z0-9_]+\.jsonl\n)+)```/g)]
45+
.map((match) => match[1].trimEnd().split('\n'));
46+
47+
assert.ok(layouts.length > 0, `${markdownPath} must contain a required-layout block`);
48+
return layouts;
49+
}
50+
51+
function documentedDurableTableInventories(markdown: string): string[][] {
52+
return markdown
53+
.split(/\n\s*\n/)
54+
.map((block) => [...block.matchAll(/^\s*- `<stack>-([a-z0-9-]+)`$/gm)].map((match) => match[1]))
55+
.filter((tables) => tables.length > 0);
56+
}
57+
58+
function documentedPostgresMappings(markdown: string): Array<[string, string]> {
59+
const section = markdown
60+
.split(/^## /m)
61+
.find((candidate) => candidate.startsWith('Postgres Migration Path'));
62+
63+
assert.ok(section, 'execution data safety must contain Postgres Migration Path');
64+
return [...section.matchAll(/^- `([a-z0-9_]+\.jsonl)` to `([a-z0-9_]+)`$/gm)]
65+
.map((match) => [match[1], match[2]]);
66+
}
67+
3068
describe('portable execution data export', () => {
3169
let client: DynamoDBDocumentClient;
3270
let exportDir: string;
@@ -1084,4 +1122,98 @@ describe('portable export timestamp anchoring', () => {
10841122
await fs.rm(brokenDir, { recursive: true, force: true });
10851123
}
10861124
});
1125+
1126+
it('keeps recovery documentation synchronized with the generated export contract', async () => {
1127+
const expectedFiles = [
1128+
'manifest.json',
1129+
...ENTITY_SPECS.map((spec) => spec.filename),
1130+
];
1131+
assert.strictEqual(ENTITY_SPECS.length, 24);
1132+
assert.strictEqual(new Set(expectedFiles).size, expectedFiles.length);
1133+
1134+
const durableTableSuffixBySourceName = new Map<string, string>([
1135+
[TABLE_TASKS, 'tasks'],
1136+
[TABLE_CARDS, 'cards'],
1137+
[TABLE_TEMPLATES, 'templates'],
1138+
[TABLE_USERS, 'users'],
1139+
[TABLE_FILES, 'files'],
1140+
[TABLE_ARTIFACTS, 'artifacts'],
1141+
[TABLE_ASSISTANT_JOBS, 'assistant-jobs'],
1142+
[TABLE_AUDIT_EVENTS, 'audit-events'],
1143+
[TABLE_INTAKE, 'intake'],
1144+
[TABLE_NOTIFICATIONS, 'notifications'],
1145+
[TABLE_CONVERSATIONAL_STATE, 'conversational-state'],
1146+
]);
1147+
const expectedDurableTables = [
1148+
'tasks',
1149+
'cards',
1150+
'templates',
1151+
'users',
1152+
'files',
1153+
'artifacts',
1154+
'assistant-jobs',
1155+
'audit-events',
1156+
'intake',
1157+
'notifications',
1158+
'conversational-state',
1159+
];
1160+
const actualDurableTables = [
1161+
...new Set(ENTITY_SPECS.map((spec) => {
1162+
const tableSuffix = durableTableSuffixBySourceName.get(spec.tableName);
1163+
assert.ok(tableSuffix, `unexpected portable-export source table ${spec.tableName}`);
1164+
return tableSuffix;
1165+
})),
1166+
].sort();
1167+
assert.deepStrictEqual(actualDurableTables, expectedDurableTables.slice().sort());
1168+
1169+
const recoveryDocuments = [
1170+
path.join(__dirname, '..', '..', 'docs', 'restore-drill.md'),
1171+
path.join(__dirname, '..', '..', 'docs', 'v1-execution-data-safety.md'),
1172+
];
1173+
for (const documentPath of recoveryDocuments) {
1174+
const layouts = await documentedRequiredLayouts(documentPath);
1175+
for (const layout of layouts) {
1176+
assert.deepStrictEqual(layout, expectedFiles);
1177+
}
1178+
const markdown = await fs.readFile(documentPath, 'utf8');
1179+
if (documentPath.endsWith('restore-drill.md')) {
1180+
const durableTableInventories = documentedDurableTableInventories(markdown);
1181+
assert.strictEqual(durableTableInventories.length, 2);
1182+
for (const inventory of durableTableInventories) {
1183+
assert.deepStrictEqual(inventory, expectedDurableTables);
1184+
}
1185+
}
1186+
assert.doesNotMatch(markdown, /npm --prefix work-engine/);
1187+
}
1188+
1189+
const safetyMarkdown = await fs.readFile(recoveryDocuments[1], 'utf8');
1190+
assert.deepStrictEqual(
1191+
documentedPostgresMappings(safetyMarkdown),
1192+
ENTITY_SPECS.map((spec) => [spec.filename, spec.name]),
1193+
);
1194+
assert.match(
1195+
await fs.readFile(recoveryDocuments[0], 'utf8'),
1196+
/byte-for-byte local copy[\s\S]*not infer it from bucket metadata/,
1197+
);
1198+
1199+
const outputDir = projectTmpDir('documented-layout-export');
1200+
try {
1201+
const result = await writePortableExport(client, outputDir, {
1202+
generatedAt: '2026-06-27T00:00:00.000Z',
1203+
sourceEnvironment: 'test',
1204+
});
1205+
assert.deepStrictEqual(Object.keys(result.manifest.entity_files).sort(), ENTITY_SPECS.map((spec) => spec.name).sort());
1206+
assert.deepStrictEqual(
1207+
Object.values(result.manifest.entity_files).sort(),
1208+
ENTITY_SPECS.map((spec) => spec.filename).sort(),
1209+
);
1210+
assert.deepStrictEqual((await fs.readdir(outputDir)).sort(), expectedFiles.slice().sort());
1211+
1212+
const validation = await validatePortableExport(outputDir);
1213+
assert.deepStrictEqual(validation.errors, []);
1214+
assert.strictEqual(validation.valid, true);
1215+
} finally {
1216+
await fs.rm(outputDir, { recursive: true, force: true });
1217+
}
1218+
});
10871219
});

docs/restore-drill.md

Lines changed: 76 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,23 @@ AWS-native backups and portable exports.
2727
Before any restore, make sure both layers are active:
2828

2929
1. **DynamoDB PITR** - point-in-time recovery is enabled on all durable
30-
execution tables (tasks, cards, templates, users, files, notifications).
31-
This protects against accidental deletes and bad updates.
30+
execution tables:
31+
32+
- `<stack>-tasks`
33+
- `<stack>-cards`
34+
- `<stack>-templates`
35+
- `<stack>-users`
36+
- `<stack>-files`
37+
- `<stack>-artifacts`
38+
- `<stack>-assistant-jobs`
39+
- `<stack>-audit-events`
40+
- `<stack>-intake`
41+
- `<stack>-notifications`
42+
- `<stack>-conversational-state`
43+
44+
This protects against accidental deletes and bad updates. Ephemeral session
45+
state is excluded. Separately governed bookkeeping, sponsor-CRM, calendar,
46+
and newsletter-slot domains are outside the portable execution boundary.
3247
2. **Portable export archive** - an application-level JSONL snapshot bundled as
3348
a retained offsite archive that does not depend on DynamoDB internals. This
3449
is the migration path to Postgres or another store.
@@ -40,19 +55,32 @@ scripts, or risky releases:
4055

4156
```bash
4257
aws dynamodb create-backup \
43-
--table-name dataops-v1-tasks \
44-
--backup-name dataops-v1-tasks-pre-migration-$(date +%Y%m%d%H%M%S)
58+
--table-name <stack>-tasks \
59+
--backup-name <stack>-tasks-pre-migration-$(date +%Y%m%d%H%M%S)
4560
```
4661

47-
Repeat for each durable table: `cards`, `templates`, `users`, `files`,
48-
`notifications`. Tag or name backups with the environment, date, and reason.
62+
Repeat for each durable execution table:
63+
64+
- `<stack>-tasks`
65+
- `<stack>-cards`
66+
- `<stack>-templates`
67+
- `<stack>-users`
68+
- `<stack>-files`
69+
- `<stack>-artifacts`
70+
- `<stack>-assistant-jobs`
71+
- `<stack>-audit-events`
72+
- `<stack>-intake`
73+
- `<stack>-notifications`
74+
- `<stack>-conversational-state`
75+
76+
Tag or name backups with the environment, date, and reason.
4977

5078
## Portable Export
5179

5280
Create a portable export:
5381

5482
```bash
55-
npm --prefix work-engine run export:data -- .tmp/exports/dataops-export
83+
npm --prefix backend run export:data -- .tmp/exports/dataops-export
5684
```
5785

5886
Or trigger the scheduled export route:
@@ -73,14 +101,30 @@ cards.jsonl
73101
templates.jsonl
74102
recurring_configs.jsonl
75103
files.jsonl
76-
notifications.jsonl
77104
artifacts.jsonl
78105
assistant_jobs.jsonl
79106
audit_events.jsonl
107+
intake_items.jsonl
108+
notifications.jsonl
109+
identity_bindings.jsonl
110+
identity_binding_audits.jsonl
111+
conversations.jsonl
112+
channel_bindings.jsonl
113+
conversation_events.jsonl
114+
summary_checkpoints.jsonl
115+
plugin_drafts.jsonl
116+
proposal_versions.jsonl
117+
proposal_presentations.jsonl
118+
execution_attempts.jsonl
119+
conversation_audit_events.jsonl
120+
result_notifications.jsonl
121+
conversational_private_payloads.jsonl
80122
```
81123

82-
Password hashes and session tokens are redacted. File binaries are excluded;
83-
only metadata is exported.
124+
All 24 JSONL families are required even when a snapshot contains zero records,
125+
so an empty JSONL file is valid. Password hashes and session tokens are redacted.
126+
The portable export excludes file and artifact binaries; it contains metadata
127+
only.
84128

85129
Offsite archives are gzip-compressed tar files stored under:
86130

@@ -89,7 +133,7 @@ Offsite archives are gzip-compressed tar files stored under:
89133
```
90134

91135
The deployed SAM stack sets `DATAOPS_EXPORT_ARCHIVE_BUCKET`,
92-
`DATAOPS_EXPORT_ARCHIVE_PREFIX`, and `DATAOPS_ENV` for the private work-engine.
136+
`DATAOPS_EXPORT_ARCHIVE_PREFIX`, and `DATAOPS_ENV` for the backend.
93137
The archive bucket is retained, private, encrypted, versioned, tagged for backup
94138
selection, and configured with noncurrent-version lifecycle retention.
95139

@@ -98,7 +142,7 @@ selection, and configured with noncurrent-version lifecycle retention.
98142
Validate the export:
99143

100144
```bash
101-
npm --prefix work-engine run validate:export -- .tmp/exports/dataops-export
145+
npm --prefix backend run validate:export -- .tmp/exports/dataops-export
102146
```
103147

104148
This checks manifest schema version, file presence, entity counts, checksums,
@@ -110,7 +154,7 @@ Run a dry-run import to see what a restore would write without mutating any
110154
data:
111155

112156
```bash
113-
npm --prefix work-engine run dry-run:import -- .tmp/exports/dataops-export
157+
npm --prefix backend run dry-run:import -- .tmp/exports/dataops-export
114158
```
115159

116160
Output:
@@ -137,12 +181,21 @@ Exits zero when valid, non-zero when validation fails.
137181
Generate local restore evidence from an archive without writing production data:
138182

139183
```bash
140-
npm --prefix work-engine run restore:drill -- \
184+
# Download or copy the selected remote archive byte-for-byte to
185+
# .tmp/exports/selected-archive.tar.gz before computing its checksum.
186+
s3_archive_checksum=$(
187+
sha256sum .tmp/exports/selected-archive.tar.gz | awk '{print "sha256:"$1}'
188+
)
189+
npm --prefix backend run restore:drill -- \
141190
--archive s3://<archive-bucket>/<archive-key> \
191+
--archive-checksum "$s3_archive_checksum" \
142192
--target-environment staging-drill \
143193
--output-dir .tmp/exports/restore-drill
144194
```
145195

196+
The checksum must come from that byte-for-byte local copy of the selected remote
197+
archive. Do not infer it from bucket metadata.
198+
146199
For local tests, pass a `file://` archive URI returned by the scheduled export
147200
route. The command extracts the archive under `.tmp/exports/restore-drill`, runs
148201
`validate:export`, runs `dry-run:import`, and writes
@@ -160,6 +213,9 @@ The evidence report includes:
160213
- target environment
161214
- evidence timestamp
162215
- smoke-check checklist result
216+
- generic statement that artifact-binary backup proof was not performed or
217+
verified by this drill and remains the separate privately retained
218+
responsibility of the authorized artifact-storage operator
163219

164220
`restore:drill` rejects `production` and `prod` as target environments. It does
165221
not restore, import, overwrite, delete, or repair production DynamoDB records.
@@ -174,8 +230,8 @@ backups:
174230

175231
```bash
176232
aws dynamodb restore-table-to-point-in-time \
177-
--source-table-name dataops-v1-tasks \
178-
--target-table-name dataops-v1-tasks-restored \
233+
--source-table-name <stack>-tasks \
234+
--target-table-name <stack>-tasks-restored \
179235
--restore-date-time 2026-06-27T10:00:00Z
180236
```
181237

@@ -211,8 +267,10 @@ Run this sequence end-to-end before production data becomes critical:
211267

212268
- `generated_at` is the logical snapshot anchor. The export scans tables
213269
sequentially; there is no multi-table transactional snapshot guarantee.
214-
- File export covers metadata only; binary backup requires S3 versioning or
215-
a separate artifact archive.
270+
- Portable export validation, dry-run analysis, restore evidence, and passed
271+
smoke checks do not prove that externally stored file or artifact binaries
272+
remain recoverable. Artifact-binary backup proof is retained privately by the
273+
authorized artifact-storage operator under a separate process.
216274
- The dry-run import validates and counts but does not write to a target
217275
database. A full import tool (for Postgres migration) is a follow-up.
218276
- Production restore/import/write behavior is human-gated. Automated cron

0 commit comments

Comments
 (0)