Skip to content

Commit fcfc212

Browse files
committed
Address frontmatter guard review
1 parent 6bb3b32 commit fcfc212

6 files changed

Lines changed: 258 additions & 16 deletions

File tree

engineering/frontmatter-integrity-rfc.md

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,18 @@ It is intentionally grounded in the 2026-04-09 incident logs:
1313

1414
## Status
1515

16-
P0 and the first P1 containment pass are implemented.
16+
P0 and the first P1 containment pass are implemented:
17+
18+
- bound-file recovery now uses non-writing binding repair after disk-authority
19+
recovery
20+
- the frontmatter guard blocks obvious duplicate/malformed/growth-burst states
21+
in both disk-to-CRDT and CRDT-to-disk directions
22+
- blocked transitions are traced, surfaced with a throttled user notice, and can
23+
be opted out from Advanced settings for troubleshooting
1724

1825
Still proposed:
1926

20-
- persisted quarantine state and user-facing recovery UI
27+
- persisted quarantine state and full recovery UI
2128
- structure-aware frontmatter sidecar
2229
- canonical YAML rendering
2330

@@ -271,9 +278,15 @@ The extractor should return:
271278
This does not require parsing YAML yet. It is a cheap structural boundary for
272279
guards and diagnostics.
273280

274-
### 5. Add YAML parse validation
281+
### 5. Add frontmatter validation
282+
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,
285+
malformed frontmatter fences, and isolated frontmatter growth bursts.
275286

276-
Use a real YAML parser rather than ad hoc string manipulation.
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.
277290

278291
The validator should detect:
279292

@@ -305,7 +318,7 @@ For `block`, YAOS should:
305318

306319
- leave body sync available when the body can be separated safely
307320
- record a diagnostic
308-
- optionally notify the user with short copy
321+
- notify the user with short copy
309322

310323
### 7. Gate outbound CRDT-to-disk frontmatter writes
311324

@@ -339,7 +352,9 @@ The quarantine should be clearable when:
339352
- the user chooses the local disk state
340353
- the user disables the guard for the path
341354

342-
The first implementation can be trace-only plus skip behavior. UI can follow.
355+
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.
343358

344359
## P2: Structure-aware frontmatter sync
345360

src/main.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ const FAST_RECONNECT_MIN_INTERVAL_MS = 2_000;
8787
const MARKDOWN_DIRTY_SETTLE_MS = 350;
8888
const OPEN_FILE_EXTERNAL_EDIT_IDLE_GRACE_MS = 1200;
8989
const BOUND_RECOVERY_LOCK_MS = 1500;
90+
const FRONTMATTER_GUARD_NOTICE_MS = 30_000;
9091
const CAPABILITY_REFRESH_INTERVAL_MS = 30_000;
9192
const UPDATE_MANIFEST_URLS = [
9293
"https://github.com/kavinsood/yaos/releases/latest/download/update-manifest.json",
@@ -284,6 +285,7 @@ export default class VaultCrdtSyncPlugin extends Plugin {
284285
private legacyServerNoticeShown = false;
285286
private commandsRegistered = false;
286287
private idbDegradedHandled = false;
288+
private frontmatterGuardNoticeAt = new Map<string, number>();
287289

288290
/**
289291
* True when startup timed out waiting for provider sync.
@@ -446,6 +448,8 @@ export default class VaultCrdtSyncPlugin extends Plugin {
446448
this.editorBindings,
447449
this.settings.debug,
448450
(source, msg, details) => this.trace(source, msg, details),
451+
() => this.settings.frontmatterGuardEnabled,
452+
(path, direction) => this.showFrontmatterGuardNotice(path, direction),
449453
);
450454
this.diskMirror.startMapObservers();
451455

@@ -2160,6 +2164,8 @@ export default class VaultCrdtSyncPlugin extends Plugin {
21602164
nextContent: string,
21612165
reason: string,
21622166
): boolean {
2167+
if (!this.settings.frontmatterGuardEnabled) return false;
2168+
21632169
const validation = validateFrontmatterTransition(previousContent, nextContent);
21642170
if (!isFrontmatterBlocked(validation)) return false;
21652171

@@ -2175,9 +2181,27 @@ export default class VaultCrdtSyncPlugin extends Plugin {
21752181
`Frontmatter ingest blocked for "${path}" ` +
21762182
`(${validation.reasons.join(", ") || validation.risk})`,
21772183
);
2184+
this.showFrontmatterGuardNotice(path, "disk-to-crdt");
21782185
return true;
21792186
}
21802187

2188+
private showFrontmatterGuardNotice(
2189+
path: string,
2190+
direction: "disk-to-crdt" | "crdt-to-disk",
2191+
): void {
2192+
const key = `${direction}:${path}`;
2193+
const now = Date.now();
2194+
if ((this.frontmatterGuardNoticeAt.get(key) ?? 0) + FRONTMATTER_GUARD_NOTICE_MS > now) {
2195+
return;
2196+
}
2197+
2198+
this.frontmatterGuardNoticeAt.set(key, now);
2199+
new Notice(
2200+
`YAOS paused a properties update in "${path}" because the frontmatter looked unsafe. Check diagnostics before accepting the change.`,
2201+
12_000,
2202+
);
2203+
}
2204+
21812205
private traceFrontmatterQuarantine(
21822206
path: string,
21832207
direction: "disk-to-crdt" | "crdt-to-disk",

src/settings.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ export interface VaultSyncSettings {
1717
deviceName: string;
1818
/** Enable verbose console.log output for debugging. */
1919
debug: boolean;
20+
/** Pause propagation of suspicious YAML frontmatter transitions. */
21+
frontmatterGuardEnabled: boolean;
2022
/** Comma-separated path prefixes to exclude from sync. */
2123
excludePatterns: string;
2224
/** Maximum file size in KB to sync via CRDT. Files larger are skipped. */
@@ -50,6 +52,7 @@ export const DEFAULT_SETTINGS: VaultSyncSettings = {
5052
vaultId: "",
5153
deviceName: "",
5254
debug: false,
55+
frontmatterGuardEnabled: true,
5356
excludePatterns: "",
5457
maxFileSizeKB: 2048,
5558
externalEditPolicy: "always",
@@ -691,6 +694,18 @@ export class VaultSyncSettingTab extends PluginSettingTab {
691694
}),
692695
);
693696

697+
new Setting(advancedBody)
698+
.setName("Frontmatter safety guard")
699+
.setDesc("Pause suspicious YAML property updates before they spread. Disable only while troubleshooting valid frontmatter that is being blocked.")
700+
.addToggle((toggle) =>
701+
toggle
702+
.setValue(this.plugin.settings.frontmatterGuardEnabled)
703+
.onChange(async (value) => {
704+
this.plugin.settings.frontmatterGuardEnabled = value;
705+
await this.plugin.saveSettings();
706+
}),
707+
);
708+
694709
new Setting(advancedBody)
695710
.setName("Debug logging")
696711
.setDesc("Enable verbose console logs for troubleshooting.")

src/sync/diskMirror.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { formatUnknown, yTextToString } from "../utils/format";
99
import {
1010
isFrontmatterBlocked,
1111
validateFrontmatterTransition,
12+
type FrontmatterValidationResult,
1213
} from "./frontmatterGuard";
1314

1415
/**
@@ -102,6 +103,12 @@ export class DiskMirror {
102103
private editorBindings: EditorBindingManager,
103104
debug: boolean,
104105
private trace?: TraceRecord,
106+
private frontmatterGuardEnabled: () => boolean = () => true,
107+
private onFrontmatterBlocked?: (
108+
path: string,
109+
direction: "crdt-to-disk",
110+
validation: FrontmatterValidationResult,
111+
) => void,
105112
) {
106113
this.debug = debug;
107114
}
@@ -479,6 +486,8 @@ export class DiskMirror {
479486
previousContent: string | null,
480487
nextContent: string,
481488
): boolean {
489+
if (!this.frontmatterGuardEnabled()) return false;
490+
482491
const validation = validateFrontmatterTransition(previousContent, nextContent);
483492
if (!isFrontmatterBlocked(validation)) return false;
484493

@@ -497,6 +506,7 @@ export class DiskMirror {
497506
`frontmatter write blocked for "${path}" ` +
498507
`(${validation.reasons.join(", ") || validation.risk})`,
499508
);
509+
this.onFrontmatterBlocked?.(path, "crdt-to-disk", validation);
500510
return true;
501511
}
502512

tests/bound-recovery-regressions.mjs

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { readFileSync } from "node:fs";
21
import * as Y from "yjs";
32

43
const diffModule = await import("../src/sync/diff.ts");
@@ -70,17 +69,40 @@ console.log("\n--- Test 1: bound-file recovery applies one content authority ---
7069
oldAmplifier.doc.destroy();
7170
}
7271

73-
console.log("\n--- Test 2: local-only recovery branch uses non-writing repair ---");
72+
console.log("\n--- Test 2: repeated disk-authority recovery does not amplify stale editor state ---");
7473
{
75-
const mainSource = readFileSync(new URL("../src/main.ts", import.meta.url), "utf8");
76-
const localOnlyStart = mainSource.indexOf("const localOnlyViews = viewStates.filter");
77-
const crdtOnlyStart = mainSource.indexOf("const crdtOnlyViews = viewStates.filter");
78-
const branch = mainSource.slice(localOnlyStart, crdtOnlyStart);
74+
const crdt = [
75+
"---",
76+
"timeEstimate: 2",
77+
"kind: op",
78+
"---",
79+
"",
80+
].join("\n");
81+
const disk = [
82+
"---",
83+
"timeEstimate: 20",
84+
"kind: op",
85+
"---",
86+
"",
87+
].join("\n");
88+
const staleEditor = [
89+
"---",
90+
"timeEstimate: 200",
91+
"kind: op",
92+
"---",
93+
"",
94+
].join("\n");
95+
96+
const state = makeText(crdt);
97+
for (let i = 0; i < 5; i++) {
98+
const before = state.ytext.toString();
99+
applyDiffToYText(state.ytext, before, disk, "disk-sync-recover-bound");
100+
}
79101

80-
assert(localOnlyStart > -1 && crdtOnlyStart > localOnlyStart, "local-only recovery branch found");
81-
assert(branch.includes("editorBindings?.repair("), "local-only recovery repairs binding without content heal");
82-
assert(!branch.includes("editorBindings?.heal("), "local-only recovery does not call content-writing heal");
83-
assert(branch.includes('"disk-sync-recover-bound"'), "local-only recovery still applies disk-selected CRDT diff");
102+
assert(state.ytext.toString() === disk, "repeated disk-authority recovery stays at disk content");
103+
assert(state.ytext.toString() !== staleEditor, "stale editor content is not reapplied during repair-only recovery");
104+
assert(state.ytext.toString().length === disk.length, "repeated repair-only recovery does not grow content");
105+
state.doc.destroy();
84106
}
85107

86108
console.log(`\n${"-".repeat(50)}`);

0 commit comments

Comments
 (0)