← back to Mfr Review Viewer Corruption
pre-stage the graph repair: cascade-impact-map parser + Steve's runbook
5b61b4a3b8d0cfb69923035947391245b8c9ed16 · 2026-09-04 08:51:26 -0700 · Steve Abrams
The DTD verdict made a cascade-delete impact map a precondition for touching the
Relationship Graph -- nobody had produced one. parse-ddr-cascade-map.mjs turns a
read-only FileMaker DDR export into exactly that: dangling table occurrences, every
cascade-delete relationship and what it would take with it, and the intersection of
the two (the [110] culprit). Smoke-tested against a fixture with a planted culprit.
Also adds the runbook: steps 1-3 are read-only, step 4 is the gated repair with the
disarm-vs-repoint distinction the contrarian surfaced, step 5 is the finish command.
Neither depends on which path is chosen, so both are useful either way. No canonical
FileMaker record touched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5qbBnj5C8dVC83qvX6fV5
Files touched
A scripts/diagnostics/fixtures/sample-ddr.xmlA scripts/diagnostics/parse-ddr-cascade-map.mjsA verification/filemaker-graph-repair-runbook.md
Diff
commit 5b61b4a3b8d0cfb69923035947391245b8c9ed16
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri Sep 4 08:51:26 2026 -0700
pre-stage the graph repair: cascade-impact-map parser + Steve's runbook
The DTD verdict made a cascade-delete impact map a precondition for touching the
Relationship Graph -- nobody had produced one. parse-ddr-cascade-map.mjs turns a
read-only FileMaker DDR export into exactly that: dangling table occurrences, every
cascade-delete relationship and what it would take with it, and the intersection of
the two (the [110] culprit). Smoke-tested against a fixture with a planted culprit.
Also adds the runbook: steps 1-3 are read-only, step 4 is the gated repair with the
disarm-vs-repoint distinction the contrarian surfaced, step 5 is the finish command.
Neither depends on which path is chosen, so both are useful either way. No canonical
FileMaker record touched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5qbBnj5C8dVC83qvX6fV5
---
scripts/diagnostics/fixtures/sample-ddr.xml | 33 ++++++
scripts/diagnostics/parse-ddr-cascade-map.mjs | 140 +++++++++++++++++++++++++
verification/filemaker-graph-repair-runbook.md | 95 +++++++++++++++++
3 files changed, 268 insertions(+)
diff --git a/scripts/diagnostics/fixtures/sample-ddr.xml b/scripts/diagnostics/fixtures/sample-ddr.xml
new file mode 100644
index 0000000..6833794
--- /dev/null
+++ b/scripts/diagnostics/fixtures/sample-ddr.xml
@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<FMPReport type="Report">
+ <File name="WALLPAPER">
+ <RelationshipGraph>
+ <TableOccurrenceCatalog>
+ <TableOccurrence id="1" name="WALLPAPER" baseTable="WALLPAPER" dataSource="FileMaker"/>
+ <TableOccurrence id="2" name="Clients" baseTable="Clients" dataSource="Clients"/>
+ <TableOccurrence id="3" name="WALLPAPER2 by mfr#" baseTable="WALLPAPER" dataSource="FileMaker"/>
+ <TableOccurrence id="4" name="SHOPIFY DATABASE" baseTable="<Table Missing>" dataSource="ShopifyProductDatabase"/>
+ <TableOccurrence id="5" name="Metafileds by Handle" baseTable="" dataSource="ShopifyProductDatabase"/>
+ <TableOccurrence id="6" name="SamplesforCustomer" baseTable="Clients" dataSource="Clients"/>
+ </TableOccurrenceCatalog>
+ <RelationshipCatalog>
+ <Relationship id="10">
+ <LeftTable name="WALLPAPER" allowCreate="False" allowDelete="False"/>
+ <RightTable name="Clients" allowCreate="False" allowDelete="False"/>
+ </Relationship>
+ <Relationship id="11">
+ <LeftTable name="WALLPAPER" allowCreate="False" allowDelete="False"/>
+ <RightTable name="SHOPIFY DATABASE" allowCreate="False" allowDelete="True"/>
+ </Relationship>
+ <Relationship id="12">
+ <LeftTable name="WALLPAPER" allowCreate="False" allowDelete="False"/>
+ <RightTable name="SamplesforCustomer" allowCreate="False" allowDelete="True"/>
+ </Relationship>
+ <Relationship id="13">
+ <LeftTable name="WALLPAPER" allowCreate="False" allowDelete="False"/>
+ <RightTable name="Metafileds by Handle" allowCreate="False" allowDelete="False"/>
+ </Relationship>
+ </RelationshipCatalog>
+ </RelationshipGraph>
+ </File>
+</FMPReport>
diff --git a/scripts/diagnostics/parse-ddr-cascade-map.mjs b/scripts/diagnostics/parse-ddr-cascade-map.mjs
new file mode 100644
index 0000000..bc6894d
--- /dev/null
+++ b/scripts/diagnostics/parse-ddr-cascade-map.mjs
@@ -0,0 +1,140 @@
+#!/usr/bin/env node
+// parse-ddr-cascade-map.mjs — turn a FileMaker Database Design Report (DDR, XML) into
+// the CASCADE-DELETE IMPACT MAP that TK-10922's DTD verdict made a precondition.
+//
+// Cody's objection to "just repair the graph" was that nobody had produced this map:
+// you cannot safely repair a cascade relationship without first knowing what it deletes.
+// This script answers three questions from a read-only DDR export:
+//
+// 1. Which table occurrences are DANGLING (no resolvable base table / missing source)?
+// 2. Which relationships have CASCADE DELETE enabled, and what would they delete?
+// 3. Which of those two sets INTERSECT -> the culprit behind FileMaker error [110].
+//
+// Read-only. Parses a local file. Touches no database. Zero dependencies.
+//
+// Usage:
+// node scripts/diagnostics/parse-ddr-cascade-map.mjs ~/Desktop/ddr/WALLPAPER.xml
+// node scripts/diagnostics/parse-ddr-cascade-map.mjs <ddr.xml> --base WALLPAPER
+
+import { readFileSync, existsSync } from 'node:fs';
+
+const file = process.argv[2];
+const baseArg = (() => { const i = process.argv.indexOf('--base'); return i >= 0 ? process.argv[i + 1] : null; })();
+
+if (!file || !existsSync(file)) {
+ console.error('usage: node parse-ddr-cascade-map.mjs <DDR.xml> [--base <BaseTableName>]');
+ console.error('\nProduce the DDR first (read-only, changes nothing):');
+ console.error(' FileMaker Pro > open WALLPAPER > Tools > Database Design Report...');
+ console.error(' format XML, select only the WALLPAPER file, save to ~/Desktop/ddr/');
+ process.exit(2);
+}
+const xml = readFileSync(file, 'utf8');
+
+// ---- tolerant attribute reader (DDR attribute spelling shifts across FM versions) ----
+const attr = (tag, ...names) => {
+ for (const n of names) {
+ const m = tag.match(new RegExp(`\\b${n}\\s*=\\s*"([^"]*)"`, 'i'));
+ if (m) return m[1];
+ }
+ return '';
+};
+const truthy = (v) => /^(true|yes|1)$/i.test(String(v || '').trim());
+// a base table that is blank, or literally "<Table Missing>", is dangling
+const MISSING = (v) => {
+ const s = String(v || '').trim().replace(/</g, '<').replace(/>/g, '>');
+ return !s || /^<.*missing.*>$/i.test(s) || /missing/i.test(s);
+};
+
+// ---- 1. table occurrences ----
+const tos = [];
+for (const m of xml.matchAll(/<TableOccurrence\b([^>]*)\/?>/gi)) {
+ const t = m[1];
+ const name = attr(t, 'name');
+ if (!name) continue;
+ tos.push({
+ name,
+ baseTable: attr(t, 'baseTable', 'baseTableName', 'table'),
+ source: attr(t, 'dataSource', 'source', 'fileName'),
+ });
+}
+const toByName = new Map(tos.map((t) => [t.name, t]));
+const dangling = tos.filter((t) => MISSING(t.baseTable));
+
+// ---- 2. relationships + their cascade-delete flags ----
+// DDR encodes cascade delete per SIDE: deleting a record in the OTHER side's table
+// deletes matching records in THIS side's table.
+const rels = [];
+for (const m of xml.matchAll(/<Relationship\b[^>]*>([\s\S]*?)<\/Relationship>/gi)) {
+ const body = m[1];
+ const sides = [];
+ for (const s of body.matchAll(/<(LeftTable|RightTable)\b([^>]*)\/?>/gi)) {
+ sides.push({
+ side: s[1],
+ name: attr(s[2], 'name', 'table'),
+ cascadeDelete: truthy(attr(s[2], 'allowDelete', 'delete', 'cascadeDelete', 'deleteRelated')),
+ allowCreate: truthy(attr(s[2], 'allowCreate', 'create')),
+ });
+ }
+ if (sides.length === 2) rels.push({ sides });
+}
+
+// ---- report ----
+const P = (s = '') => console.log(s);
+P('');
+P('=== FileMaker DDR cascade-delete impact map ===');
+P(`source: ${file}`);
+P(`table occurrences: ${tos.length} relationships: ${rels.length}`);
+if (!tos.length || !rels.length) {
+ P('');
+ P('!! Parsed 0 table occurrences or 0 relationships. The DDR may be HTML rather than XML,');
+ P('!! or a newer schema. Re-export as XML; if it still parses empty, say so and I will');
+ P('!! adapt the parser to the actual tag names in your export.');
+}
+
+P('');
+P('--- 1. DANGLING table occurrences (unresolvable base table) ---');
+if (!dangling.length) P(' none found');
+for (const d of dangling) P(` ⚠ "${d.name}" baseTable="${d.baseTable}" source="${d.source}"`);
+
+P('');
+P('--- 2. CASCADE-DELETE relationships (what a delete would take with it) ---');
+const cascades = [];
+for (const r of rels) {
+ const [a, b] = r.sides;
+ // side X cascadeDelete => deleting in the OTHER side deletes matching records in X
+ if (a.cascadeDelete) cascades.push({ trigger: b.name, deletes: a.name });
+ if (b.cascadeDelete) cascades.push({ trigger: a.name, deletes: b.name });
+}
+if (!cascades.length) P(' none — no relationship in this file cascades deletes');
+for (const c of cascades) {
+ const dead = MISSING(toByName.get(c.deletes)?.baseTable ?? 'unknown-TO');
+ P(` deleting a record in "${c.trigger}" -> ALSO DELETES matching in "${c.deletes}"${dead ? ' <== TARGET IS DANGLING' : ''}`);
+}
+
+P('');
+P('--- 3. CULPRIT CANDIDATES for FileMaker error [110] ---');
+const culprits = cascades.filter((c) => MISSING(toByName.get(c.deletes)?.baseTable ?? 'unknown'));
+if (!culprits.length) {
+ P(' No cascade-delete relationship points at a dangling TO.');
+ P(' If deletes still fail with [110], the break is a NON-cascade relationship evaluated');
+ P(' at delete time, or a dangling entry in File > Manage > External Data Sources.');
+ if (dangling.length) {
+ P(' Start with the dangling TOs listed in section 1 — they are still the prime suspects.');
+ }
+} else {
+ for (const c of culprits) {
+ P(` ★ "${c.trigger}" --cascade--> "${c.deletes}" (base table unresolvable)`);
+ }
+ P('');
+ P(' DISARM, DO NOT REPOINT (per the DTD verdict):');
+ P(' • Unticking the cascade box, or deleting the dangling TO, DISARMS an inert cascade.');
+ P(' • Repointing it at a real table ARMS it across every future delete in this file.');
+ P(' These are opposite outcomes. Section 2 above is what would become live if you repoint.');
+}
+
+if (baseArg) {
+ P('');
+ P(`--- TOs on base table "${baseArg}" (the delete walks outward from these) ---`);
+ for (const t of tos.filter((t) => t.baseTable === baseArg)) P(` ${t.name}`);
+}
+P('');
diff --git a/verification/filemaker-graph-repair-runbook.md b/verification/filemaker-graph-repair-runbook.md
new file mode 100644
index 0000000..7806a3e
--- /dev/null
+++ b/verification/filemaker-graph-repair-runbook.md
@@ -0,0 +1,95 @@
+# TK-10922 — FileMaker graph runbook (Steve's 10 minutes)
+
+Everything up to step 4 is **read-only and changes nothing.** Stop after step 3 and hand me
+the file if you'd rather I do the analysis — that's the whole point of the parser.
+
+---
+
+## Step 1 — open the file (read-only)
+
+FileMaker Pro is already installed and this host is in your recent-files list:
+
+```
+fmnet:/designerwallcoverings.account.filemaker-cloud.com/WALLPAPER
+```
+
+Open it with an account that has **[Full Access]** — the Database Design Report is greyed
+out for any lesser privilege set. That's why I can't do this over the API: the Data API
+account has no schema rights, and DDR isn't exposed to it at all.
+
+## Step 2 — export the Database Design Report (read-only)
+
+**Tools ▸ Database Design Report…**
+
+- Available files: tick **WALLPAPER only** (untick everything else — the other files make it
+ slow and add noise)
+- Include fields for: leave all ticked
+- Report format: **XML** ← not HTML; the parser reads XML
+- Save to: `~/Desktop/ddr/`
+
+This writes a file and modifies nothing in the database.
+
+## Step 3 — hand it to me
+
+```
+node scripts/diagnostics/parse-ddr-cascade-map.mjs ~/Desktop/ddr/WALLPAPER.xml --base WALLPAPER
+```
+
+That prints three sections:
+
+1. **Dangling table occurrences** — any TO whose base table no longer resolves
+2. **The cascade-delete impact map** — every relationship that deletes related records, i.e.
+ exactly what would start happening on every delete once the graph works again
+3. **Culprit candidates** — the intersection: a cascade-delete relationship pointing at a
+ dangling TO. That is the thing throwing `[110]`.
+
+The parser is smoke-tested against a fixture (`scripts/diagnostics/fixtures/sample-ddr.xml`).
+If your real DDR parses as 0 occurrences, the tag names differ in your FileMaker version —
+tell me and I'll adapt it, don't hand-read 251 layouts' worth of XML.
+
+## Step 4 — the repair (only after reading section 2) ⚠️ GATED
+
+**File ▸ Manage ▸ Database… ▸ Relationships**
+
+A dangling table occurrence renders distinctively — the TO box shows a missing-table
+indicator instead of a normal field list. Find the one section 3 named.
+
+**Disarm it. Do not repoint it.**
+
+| Action | Effect |
+|---|---|
+| Untick *"Delete related records in this table when a record is deleted in the other table"* | **DISARMS** a cascade that is currently inert-by-error. Safe. |
+| Delete the dangling TO / its relationship outright | **DISARMS**, and removes the dead weight. Safe if nothing else uses it. |
+| Repoint it at a real table ("fixing" it properly) | **ARMS** the cascade across every future delete in a 470,000-record file. This is the dangerous one. |
+
+Those last two rows are opposite outcomes reached by the same "fix the error" instinct. The
+panel's contrarian is why this table exists — section 2 tells you what you'd be arming.
+
+Also check **File ▸ Manage ▸ External Data Sources…** for an entry pointing at a file that
+no longer exists. That's the other well-known source of `[110]` on delete.
+
+## Step 5 — tell me, and I finish it
+
+The three deletes take about ten seconds once the graph is clean:
+
+```
+node scripts/execute-sweep.mjs \
+ --plan data/plans/HSW-corrected-3record-2026-08-28T14-32-26-000Z.json \
+ --ticket TK-10922-execute-corrected-mfr-repair-3-record-de --apply
+```
+
+Snapshot-first, sku-match guard, keep-worthiness guard, never-delete-to-zero guard, and a
+reversible-ledger entry per delete — all already wired and dry-run-clean as of today.
+
+---
+
+## If you'd rather not touch the graph at all
+
+The panel's dissent (2 of 7 votes, and the contrarian's pick) is a legitimate alternative:
+stamp the three placeholders' existing free-text `Mfr Pattern` field with
+`SUPERSEDED - see master <keep-id>`. No schema change, no cascade walk, works through the API
+I already have, reversible from the snapshots I'm holding, and it immediately stops the record
+masquerading as a real manufacturer code. The duplicates stay in the file.
+
+It's a canonical write, so it needs your explicit go — but it's the one path that doesn't
+require you to open FileMaker Pro at all.
← d04f917 Document GovArbitrage resubmission readiness
·
back to Mfr Review Viewer Corruption
·
add mark-superseded.mjs — the TK-10922 fallback remediation 1bf0022 →