Skip to content

Commit 3fe15da

Browse files
committed
Persist frontmatter quarantine diagnostics
1 parent fcfc212 commit 3fe15da

10 files changed

Lines changed: 681 additions & 31 deletions

engineering/frontmatter-integrity-rfc.md

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,15 @@ P0 and the first P1 containment pass are implemented:
2121
in both disk-to-CRDT and CRDT-to-disk directions
2222
- blocked transitions are traced, surfaced with a throttled user notice, and can
2323
be opted out from Advanced settings for troubleshooting
24+
- blocked paths persist bounded diagnostic-only quarantine metadata in plugin
25+
state for restart-safe debugging
26+
- parser-backed validation now complements the cheap extractor on changed
27+
frontmatter slices, and schema-lite field policies are used for
28+
classification only
2429

2530
Still proposed:
2631

27-
- persisted quarantine state and full recovery UI
32+
- full recovery UI
2833
- structure-aware frontmatter sidecar
2934
- canonical YAML rendering
3035

@@ -280,13 +285,14 @@ guards and diagnostics.
280285

281286
### 5. Add frontmatter validation
282287

283-
The first containment pass uses a cheap structural classifier. It blocks only
284-
obvious hazards such as duplicate top-level keys, repeated bare-key bursts,
288+
The first containment pass uses a cheap structural classifier plus parser-backed
289+
validation on the extracted frontmatter slice. It blocks only obvious hazards
290+
such as duplicate top-level keys, repeated bare-key bursts, parser failures,
285291
malformed frontmatter fences, and isolated frontmatter growth bursts.
286292

287-
That first pass is an emergency brake, not a complete YAML policy. The durable
288-
implementation should use a real YAML parser rather than ad hoc string
289-
manipulation.
293+
That first pass is still an emergency brake, not a complete YAML policy. The
294+
durable implementation should keep parser-backed validation while avoiding
295+
premature merge or rewrite semantics.
290296

291297
The validator should detect:
292298

@@ -353,8 +359,9 @@ The quarantine should be clearable when:
353359
- the user disables the guard for the path
354360

355361
The first implementation is skip behavior plus trace/log diagnostics, a
356-
throttled notice, and a global opt-out. Persisted per-file quarantine state and
357-
explicit accept/keep recovery controls should follow.
362+
throttled notice, a global opt-out, and bounded persisted quarantine metadata
363+
for debugging. Explicit accept/keep recovery controls should follow only if they
364+
remain consistent with YAOS snapshot and Obsidian File Recovery policy.
358365

359366
## P2: Structure-aware frontmatter sync
360367

package-lock.json

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

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
1010
"build:server-release": "node build-server-release.mjs",
1111
"test:server-update-local": "npm run build:server-release && node tests/server-update-local.mjs",
12-
"test:regressions": "node --import jiti/register tests/diff-regressions.mjs && node --import jiti/register tests/bound-recovery-regressions.mjs && node --import jiti/register tests/frontmatter-guard-regressions.mjs && node tests/disk-mirror-regressions.mjs && node tests/markdown-ingest-regressions.mjs && node --import jiti/register tests/closed-file-mirror.ts && node --import jiti/register tests/folder-rename.ts && node --import jiti/register tests/chunked-doc-store.ts && node --import jiti/register tests/trace-store.ts && node --import jiti/register tests/server-hardening.ts && node --import jiti/register tests/v2-offline-rename-regressions.mjs",
12+
"test:regressions": "node --import jiti/register tests/diff-regressions.mjs && node --import jiti/register tests/bound-recovery-regressions.mjs && node --import jiti/register tests/frontmatter-guard-regressions.mjs && node --import jiti/register tests/frontmatter-quarantine-regressions.mjs && node tests/disk-mirror-regressions.mjs && node tests/markdown-ingest-regressions.mjs && node --import jiti/register tests/closed-file-mirror.ts && node --import jiti/register tests/folder-rename.ts && node --import jiti/register tests/chunked-doc-store.ts && node --import jiti/register tests/trace-store.ts && node --import jiti/register tests/server-hardening.ts && node --import jiti/register tests/v2-offline-rename-regressions.mjs",
1313
"test:integration:worker": "node tests/worker-integration.mjs",
1414
"test:e2e:obsidian": "wdio run wdio.conf.mts",
1515
"test:ci": "npm run test:regressions && npm run test:integration:worker",
@@ -35,6 +35,7 @@
3535
"dependencies": {
3636
"fast-diff": "^1.3.0",
3737
"fflate": "^0.8.2",
38+
"js-yaml": "^4.1.1",
3839
"obsidian": "1.8.7",
3940
"partyserver": "0.3.2",
4041
"qrcode": "^1.5.4",

src/js-yaml.d.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
declare module "js-yaml" {
2+
export function load(yaml: string): unknown;
3+
4+
const yaml: {
5+
load: typeof load;
6+
};
7+
8+
export default yaml;
9+
}

src/main.ts

Lines changed: 102 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,16 @@ import { applyDiffToYText } from "./sync/diff";
2525
import {
2626
isFrontmatterBlocked,
2727
validateFrontmatterTransition,
28+
extractFrontmatter,
2829
type FrontmatterValidationResult,
2930
} from "./sync/frontmatterGuard";
31+
import {
32+
buildFrontmatterQuarantineDebugLines,
33+
clearFrontmatterQuarantinePath,
34+
readPersistedFrontmatterQuarantine,
35+
upsertFrontmatterQuarantineEntry,
36+
type FrontmatterQuarantineEntry,
37+
} from "./sync/frontmatterQuarantine";
3038
import {
3139
type DiskIndex,
3240
collectFileStats,
@@ -77,6 +85,7 @@ type PersistedPluginState = Partial<VaultSyncSettings> & {
7785
_blobQueue?: BlobQueueSnapshot;
7886
_serverCapabilitiesCache?: PersistedServerCapabilitiesCache;
7987
_updateManifestCache?: PersistedUpdateManifestCache;
88+
_frontmatterQuarantine?: FrontmatterQuarantineEntry[];
8089
};
8190

8291
/** Minimum interval between reconcile runs (prevents rapid reconnect churn). */
@@ -286,6 +295,7 @@ export default class VaultCrdtSyncPlugin extends Plugin {
286295
private commandsRegistered = false;
287296
private idbDegradedHandled = false;
288297
private frontmatterGuardNoticeAt = new Map<string, number>();
298+
private frontmatterQuarantineEntries: FrontmatterQuarantineEntry[] = [];
289299

290300
/**
291301
* True when startup timed out waiting for provider sync.
@@ -449,7 +459,15 @@ export default class VaultCrdtSyncPlugin extends Plugin {
449459
this.settings.debug,
450460
(source, msg, details) => this.trace(source, msg, details),
451461
() => this.settings.frontmatterGuardEnabled,
452-
(path, direction) => this.showFrontmatterGuardNotice(path, direction),
462+
(path, direction, reason, validation, previousContent, nextContent) =>
463+
this.handleFrontmatterValidation(
464+
path,
465+
direction,
466+
reason,
467+
validation,
468+
previousContent,
469+
nextContent,
470+
),
453471
);
454472
this.diskMirror.startMapObservers();
455473

@@ -2167,24 +2185,49 @@ export default class VaultCrdtSyncPlugin extends Plugin {
21672185
if (!this.settings.frontmatterGuardEnabled) return false;
21682186

21692187
const validation = validateFrontmatterTransition(previousContent, nextContent);
2170-
if (!isFrontmatterBlocked(validation)) return false;
2171-
2172-
this.traceFrontmatterQuarantine(
2188+
this.handleFrontmatterValidation(
21732189
path,
21742190
"disk-to-crdt",
21752191
reason,
21762192
validation,
2177-
previousContent?.length ?? null,
2178-
nextContent.length,
2193+
previousContent,
2194+
nextContent,
21792195
);
2196+
if (!isFrontmatterBlocked(validation)) return false;
21802197
this.log(
21812198
`Frontmatter ingest blocked for "${path}" ` +
21822199
`(${validation.reasons.join(", ") || validation.risk})`,
21832200
);
2184-
this.showFrontmatterGuardNotice(path, "disk-to-crdt");
21852201
return true;
21862202
}
21872203

2204+
private handleFrontmatterValidation(
2205+
path: string,
2206+
direction: "disk-to-crdt" | "crdt-to-disk",
2207+
reason: string,
2208+
validation: FrontmatterValidationResult,
2209+
previousContent: string | null,
2210+
nextContent: string,
2211+
): void {
2212+
if (validation.risk === "ok") {
2213+
void this.clearFrontmatterQuarantine(path, `${direction}:${reason}`);
2214+
return;
2215+
}
2216+
2217+
if (!isFrontmatterBlocked(validation)) return;
2218+
2219+
this.traceFrontmatterQuarantine(
2220+
path,
2221+
direction,
2222+
reason,
2223+
validation,
2224+
previousContent?.length ?? null,
2225+
nextContent.length,
2226+
);
2227+
this.showFrontmatterGuardNotice(path, direction);
2228+
void this.persistFrontmatterQuarantine(path, direction, validation, previousContent, nextContent);
2229+
}
2230+
21882231
private showFrontmatterGuardNotice(
21892232
path: string,
21902233
direction: "disk-to-crdt" | "crdt-to-disk",
@@ -2223,6 +2266,44 @@ export default class VaultCrdtSyncPlugin extends Plugin {
22232266
});
22242267
}
22252268

2269+
private async persistFrontmatterQuarantine(
2270+
path: string,
2271+
direction: "disk-to-crdt" | "crdt-to-disk",
2272+
validation: FrontmatterValidationResult,
2273+
previousContent: string | null,
2274+
nextContent: string,
2275+
): Promise<void> {
2276+
const now = Date.now();
2277+
const prevHash = await this.hashFrontmatterContent(previousContent);
2278+
const nextHash = await this.hashFrontmatterContent(nextContent);
2279+
this.frontmatterQuarantineEntries = upsertFrontmatterQuarantineEntry(
2280+
this.frontmatterQuarantineEntries,
2281+
{
2282+
path,
2283+
firstSeenAt: now,
2284+
lastSeenAt: now,
2285+
direction,
2286+
reasons: validation.reasons,
2287+
prevHash,
2288+
nextHash,
2289+
count: 1,
2290+
},
2291+
);
2292+
await this.persistPluginState();
2293+
}
2294+
2295+
private async clearFrontmatterQuarantine(path: string, reason: string): Promise<void> {
2296+
if (this.frontmatterQuarantineEntries.length === 0) return;
2297+
const nextEntries = clearFrontmatterQuarantinePath(this.frontmatterQuarantineEntries, path);
2298+
if (nextEntries.length === this.frontmatterQuarantineEntries.length) return;
2299+
this.frontmatterQuarantineEntries = nextEntries;
2300+
this.trace("trace", "frontmatter-quarantine-cleared", {
2301+
path,
2302+
reason,
2303+
});
2304+
await this.persistPluginState();
2305+
}
2306+
22262307
private async updateDiskIndexForPath(path: string): Promise<void> {
22272308
try {
22282309
const stat = await this.app.vault.adapter.stat(path);
@@ -2667,6 +2748,7 @@ export default class VaultCrdtSyncPlugin extends Plugin {
26672748
this.updateManifest = null;
26682749
this.updateManifestFetchedAt = 0;
26692750
}
2751+
this.frontmatterQuarantineEntries = readPersistedFrontmatterQuarantine(data?._frontmatterQuarantine);
26702752
this.refreshPersistedState();
26712753
if (migratedSettings) {
26722754
await this.persistPluginState();
@@ -3515,6 +3597,11 @@ export default class VaultCrdtSyncPlugin extends Plugin {
35153597
} else {
35163598
delete nextState._updateManifestCache;
35173599
}
3600+
if (this.frontmatterQuarantineEntries.length > 0) {
3601+
nextState._frontmatterQuarantine = this.frontmatterQuarantineEntries;
3602+
} else {
3603+
delete nextState._frontmatterQuarantine;
3604+
}
35183605
this.persistedState = nextState;
35193606
}
35203607

@@ -3572,6 +3659,7 @@ export default class VaultCrdtSyncPlugin extends Plugin {
35723659
`Open files: ${this.openFilePaths.size}`,
35733660
`Server trace events: ${this.recentServerTrace.length}`,
35743661
`Remote cursors: ${this.settings.showRemoteCursors ? "shown" : "hidden"}`,
3662+
...buildFrontmatterQuarantineDebugLines(this.frontmatterQuarantineEntries),
35753663
].join("\n");
35763664
}
35773665

@@ -3595,6 +3683,13 @@ export default class VaultCrdtSyncPlugin extends Plugin {
35953683
return arrayBufferToHex(digest);
35963684
}
35973685

3686+
private async hashFrontmatterContent(content: string | null): Promise<string | undefined> {
3687+
if (content == null) return undefined;
3688+
const block = extractFrontmatter(content);
3689+
if (block.kind !== "present") return undefined;
3690+
return await this.sha256Hex(block.frontmatterText);
3691+
}
3692+
35983693
private async exportDiagnostics(): Promise<void> {
35993694
if (!this.vaultSync) {
36003695
new Notice("Sync not initialized");

src/sync/diskMirror.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,10 +104,13 @@ export class DiskMirror {
104104
debug: boolean,
105105
private trace?: TraceRecord,
106106
private frontmatterGuardEnabled: () => boolean = () => true,
107-
private onFrontmatterBlocked?: (
107+
private onFrontmatterValidated?: (
108108
path: string,
109109
direction: "crdt-to-disk",
110+
reason: "flush-write",
110111
validation: FrontmatterValidationResult,
112+
previousContent: string | null,
113+
nextContent: string,
111114
) => void,
112115
) {
113116
this.debug = debug;
@@ -489,6 +492,14 @@ export class DiskMirror {
489492
if (!this.frontmatterGuardEnabled()) return false;
490493

491494
const validation = validateFrontmatterTransition(previousContent, nextContent);
495+
this.onFrontmatterValidated?.(
496+
path,
497+
"crdt-to-disk",
498+
"flush-write",
499+
validation,
500+
previousContent,
501+
nextContent,
502+
);
492503
if (!isFrontmatterBlocked(validation)) return false;
493504

494505
this.trace?.("trace", "frontmatter-quarantined", {
@@ -506,7 +517,6 @@ export class DiskMirror {
506517
`frontmatter write blocked for "${path}" ` +
507518
`(${validation.reasons.join(", ") || validation.risk})`,
508519
);
509-
this.onFrontmatterBlocked?.(path, "crdt-to-disk", validation);
510520
return true;
511521
}
512522

0 commit comments

Comments
 (0)