Skip to content

Commit 11cc0f0

Browse files
committed
fix(provenance): repair only what the reader calls an absence
The repair selected on status alone, which is wider than the branch it was meant to undo. The reader answers 'unrecorded' only for a sidecar that is version 1, bound to the file's current bytes, and holding a well-formed entries array; anything else is a fault it refuses and no policy relaxes. Clearing a fault was not a smaller claim but a larger one. Deleting the sidecar sets secret_provenance_version to NULL, and a NULL version reads as exact-empty — so a file refused because its provenance described bytes it no longer holds would have come back positively vouched for, silently, with no audit entry. Four such rows exist in production today. Both the candidate query and the delete now carry every condition, so a concurrent write cannot lose the distinction between an absence and a fault. Stale, unversioned and malformed sidecars stay refused exactly as the surface refuses them, and recover as they always have, on the next content write.
1 parent 8d9c2ba commit 11cc0f0

2 files changed

Lines changed: 63 additions & 14 deletions

File tree

packages/db/script-migrations-provenance-repair.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,31 @@ describe('unknown provenance repair lock order', () => {
114114
expect(lock).toContain('ORDER BY id')
115115
})
116116

117+
/**
118+
* The reader answers `unrecorded` only for a sidecar that is version 1, bound to the file's
119+
* current bytes, and holding a well-formed entries array. A status-only predicate is wider than
120+
* that, and the extra rows are faults rather than absences — clearing one sets the version to
121+
* NULL, which reads back as exact-empty, promoting a refused file to positively vouched for with
122+
* no audit entry. Both the candidate query and the delete carry every condition.
123+
*/
124+
it('targets only what the reader calls unrecorded, in the select and the delete', async () => {
125+
const { sql, statements } = createRecordingSql('workspace_file_secret_provenance')
126+
await repairUnknownWorkspaceFileProvenance.up(sql)
127+
128+
const candidateSelect = statements.find(
129+
(statement) =>
130+
statement.includes('FROM workspace_file_secret_provenance') && statement.includes('LIMIT')
131+
)
132+
const deleteStatement = statements.find((statement) => statement.startsWith('DELETE'))
133+
134+
for (const statement of [candidateSelect, deleteStatement]) {
135+
expect(statement).toContain("status = 'unknown'")
136+
expect(statement).toContain('secret_provenance_version = 1')
137+
expect(statement).toContain('content_updated_at = f.content_updated_at')
138+
expect(statement).toContain("jsonb_typeof(p.entries) = 'array'")
139+
}
140+
})
141+
117142
/**
118143
* Re-checking `status = 'unknown'` under the parent lock is what stops the repair clearing a
119144
* sidecar a writer just made exact — which would strand a genuinely secret-bearing file reading

packages/db/script-migrations/0007_repair_unknown_workspace_file_provenance.ts

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,28 +10,45 @@ interface RepairPage {
1010
}
1111

1212
/**
13-
* Returns one page of `unknown` files to the untracked state.
13+
* Returns one page of files the reader calls `unrecorded` to the untracked state.
14+
*
15+
* The predicate is the reader's own, condition for condition: version 1, a sidecar bound to the
16+
* file's current bytes, a well-formed entries array, and a status of `unknown`. That is precisely
17+
* the branch `getBoundWorkspaceFileSecretProvenance` answers `unrecorded` to, and a status-only
18+
* query is wider than it. A sidecar left bound to bytes the file no longer holds reads as `unknown`
19+
* — a fault the surface refuses and no policy relaxes — but clearing it here would set
20+
* `secret_provenance_version` to NULL, and a NULL version reads as *exact-empty*: the file would go
21+
* from refused to positively vouched for, silently and with no audit entry, on the strength of a
22+
* sidecar that described different bytes. Relaxing an absence is this migration's whole purpose;
23+
* promoting a fault is not, and the same three conditions guard the delete so the distinction
24+
* cannot be lost to a concurrent write.
1425
*
1526
* Takes the parent row lock before touching the sidecar, in `id` order, because that is the order
1627
* a content write takes them: `workspace-file-manager` updates `workspace_files` and only then
1728
* replaces the sidecar, inside the same transaction. Deleting the sidecar first and updating the
1829
* parent after is the opposite order, so an overlapping upload would deadlock and Postgres would
1930
* resolve it by aborting either the deployment or somebody's file write.
2031
*
21-
* Holding that lock is also what makes the status re-check decisive: a content write moves
32+
* Holding that lock is also what makes the re-check decisive: a content write moves
2233
* `content_updated_at` and rewrites the sidecar under the same lock, so once it is held the write
23-
* is either wholly done or has not begun, and a file it has meanwhile made exact stops matching.
34+
* is either wholly done or has not begun, and a file it has meanwhile made exact or rebound stops
35+
* matching.
2436
*/
2537
async function repairUnknownFileProvenancePage(
2638
sql: Sql,
2739
batchSize: number,
2840
afterFileId: string
2941
): Promise<RepairPage> {
3042
const candidates = await sql<{ fileId: string }[]>`
31-
SELECT file_id AS "fileId"
32-
FROM workspace_file_secret_provenance
33-
WHERE status = 'unknown' AND file_id > ${afterFileId}
34-
ORDER BY file_id
43+
SELECT p.file_id AS "fileId"
44+
FROM workspace_file_secret_provenance p
45+
JOIN workspace_files f ON f.id = p.file_id
46+
WHERE p.status = 'unknown'
47+
AND f.secret_provenance_version = 1
48+
AND p.content_updated_at = f.content_updated_at
49+
AND jsonb_typeof(p.entries) = 'array'
50+
AND p.file_id > ${afterFileId}
51+
ORDER BY p.file_id
3552
LIMIT ${batchSize}
3653
`
3754
if (candidates.length === 0) return { candidates: 0, repaired: 0, lastFileId: null }
@@ -45,10 +62,15 @@ async function repairUnknownFileProvenancePage(
4562
FOR UPDATE
4663
`
4764
const cleared = await tx<{ fileId: string }[]>`
48-
DELETE FROM workspace_file_secret_provenance
49-
WHERE file_id = ANY(${fileIds}::text[])
50-
AND status = 'unknown'
51-
RETURNING file_id AS "fileId"
65+
DELETE FROM workspace_file_secret_provenance p
66+
USING workspace_files f
67+
WHERE p.file_id = f.id
68+
AND p.file_id = ANY(${fileIds}::text[])
69+
AND p.status = 'unknown'
70+
AND f.secret_provenance_version = 1
71+
AND p.content_updated_at = f.content_updated_at
72+
AND jsonb_typeof(p.entries) = 'array'
73+
RETURNING p.file_id AS "fileId"
5274
`
5375
if (cleared.length === 0) return 0
5476
const marked = await tx<{ id: string }[]>`
@@ -81,9 +103,11 @@ async function repairUnknownFileProvenancePage(
81103
* the state, which would otherwise report on every read forever; this clears them so the trail
82104
* carries only what happens next.
83105
*
84-
* A relabel, not a reconstruction. It claims strictly less than the sidecar did — "unrecorded" —
85-
* and puts the file exactly where the untracked file beside it already sits. Idempotent: a repaired
86-
* file has no sidecar row, so it leaves the candidate set and a re-run costs one empty query.
106+
* A relabel, not a reconstruction, and only of files the reader already treats as an absence. Files
107+
* whose sidecar is stale, unversioned, or malformed are faults rather than absences: they stay
108+
* refused, exactly as the surface refuses them, and recover the way they always have — on the next
109+
* content write, which rebinds the sidecar. Idempotent: a repaired file has no sidecar row, so it
110+
* leaves the candidate set and a re-run costs one empty query.
87111
*/
88112
export const repairUnknownWorkspaceFileProvenance: ScriptMigration = {
89113
name: '0007_repair_unknown_workspace_file_provenance',

0 commit comments

Comments
 (0)