pi-aia-asf 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,12 +7,54 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
- ## [0.11.0] - 2026-10-12
10
+ ## [0.12.0] - 2026-10-14
11
11
 
12
12
  ### Added
13
13
 
14
- - (describe changes for 0.11.0)
14
+ - **The model gate (P27)** — ASF now inspects the data model, not only the code
15
+ shape. A new mandatory reference, `references/06j-data-model.md`, gives the
16
+ factory what it never had: ownership classes (composition / instance /
17
+ ledger), a required `asf-model:` declaration for every relationship a change
18
+ touches (class, owner, survives-parent), model rules **M1–M8**, the legitimate
19
+ exceptions, and a model Definition of Done. The obligation is the point — a
20
+ declaration that concludes "`CASCADE` is correct here" is a pass; what the gate
21
+ refuses is that nobody was asked.
22
+ - **Two objective fitness functions in the Code Health Gate** (`model` level,
23
+ `lib/code-health/model.mjs`): a cascade declaration with no ownership
24
+ declaration, and the rebuild-by-delete signature (delete + re-insert of the
25
+ same table/collection in one window). Built in — no tool to install, so the
26
+ level can never be skipped for a missing dependency — reporting `file:line`
27
+ and the fix, waivable only with a greppable `asf-model-accept: <reason>`.
28
+ Documented as **a tripwire, not an authority**: `scanned: 0` is reported, never
29
+ hidden, and a clean scan is not a pass.
30
+ - **Store-agnostic by design.** Declarations are recognised across relational,
31
+ ORM-mapped and document forms, and a project adds its own dialect with
32
+ `model.extraCascadePatterns` rather than waiting for a release. Where a store
33
+ exposes no constraint to inspect, the text says plainly that the invariant
34
+ tests are the only hard gate.
35
+ - **Invariant and non-effect tests (06g §3)** — the missing half of the test
36
+ doctrine: survival tests with production-shaped data, non-effect assertions on
37
+ the tables the change does not name, zero-row invariant queries, and two-user
38
+ authorization tests. Every other rule asked *did the thing you wanted happen?*;
39
+ these ask *what must not have happened*.
40
+ - **06b Rule 20 — the model is asserted, not assumed**, plus the Rule 10 DoD
41
+ model line, the `/asf verify` checklist item, a **Data & model questions** lens
42
+ in `04-adversarial.md`, a **Data model & ownership** table in the plan
43
+ template, test-first step 6 in `06-implementation.md`, and a **Model drift**
44
+ anti-pattern (extending a destructive persistence pattern because the file
45
+ above does it is drift, not precedent).
46
+
47
+ ### Fixed
15
48
 
49
+ - **The rule count in `SKILL.md` can no longer silently disagree with 06b.** The
50
+ suite now derives the count from the `## Rule N —` headings instead of
51
+ asserting a hardcoded number, so adding a rule no longer requires editing two
52
+ test files (and a stale index fails).
53
+ - **`docs/research/improvement-proposals.md` header** still claimed "research
54
+ phase only — no ASF code or docs have been changed" long after P23–P26 had
55
+ shipped. It now points at `review-decisions.md` as the authoritative record.
56
+
57
+ ## [0.11.0] - 2026-10-12
16
58
 
17
59
  ### Added
18
60
 
package/index.ts CHANGED
@@ -53,6 +53,7 @@ const QA_CHECKLIST: Array<{ key: string; label: string }> = [
53
53
  { key: "specs", label: "Every MUST spec 'met' with concrete evidence" },
54
54
  { key: "trace", label: "Spec-to-code traceability: every met spec has outcome → codePath → test" },
55
55
  { key: "code-health", label: "Code Health Gate passed (or enabled:false / gate.mode:warn with the report shown)" },
56
+ { key: "model", label: "Model (06j): every relationship the change touches has a written ownership declaration; store writes have invariant + non-effect tests (06g §3); /asf health model level passes or its findings are accepted with a reason; n/a with reason if no persistence touched" },
56
57
  { key: "surface", label: "Every delivered feature is consumed by a surface (UI or API) — nothing dead" },
57
58
  { key: "e2e", label: "Feature specs have an end-to-end behavioral test through the real entry point" },
58
59
  { key: "honest", label: "Skipped/inconclusive checks reported explicitly" },
@@ -40,6 +40,19 @@ export const DEFAULT_CONFIG = {
40
40
  dependencyRules: { enabled: false, config: ".asf-code-health.rules.mjs" },
41
41
  },
42
42
 
43
+ // Model level (P27, references/06j-data-model.md): a tripwire that asks
44
+ // whether a deletion decision was written down. Built in — no tool to be
45
+ // missing. The pattern set is illustrative; projects add their own dialect
46
+ // with extraCascadePatterns rather than waiting for a release.
47
+ model: {
48
+ enabled: true,
49
+ requireDeclaration: true, // cascade requires an asf-model: declaration
50
+ deleteRebuild: true, // delete + reinsert of the same target
51
+ declarationWindowLines: 8,
52
+ rebuildWindowLines: 30,
53
+ extraCascadePatterns: [],
54
+ },
55
+
43
56
  ignore: [
44
57
  "**/node_modules/**",
45
58
  "**/dist/**",
@@ -107,6 +120,7 @@ export function normalizeConfig(raw) {
107
120
  const f = isPlainObject(raw.function) ? raw.function : {};
108
121
  const m = isPlainObject(raw.module) ? raw.module : {};
109
122
  const a = isPlainObject(raw.architecture) ? raw.architecture : {};
123
+ const md = isPlainObject(raw.model) ? raw.model : {};
110
124
  const metric = (obj, name) => (isPlainObject(obj[name]) ? obj[name] : {});
111
125
 
112
126
  const complexity = metric(f, "complexity");
@@ -153,6 +167,17 @@ export function normalizeConfig(raw) {
153
167
  config: typeof depRules.config === "string" && depRules.config ? depRules.config : d.architecture.dependencyRules.config,
154
168
  },
155
169
  },
170
+ model: {
171
+ enabled: bool(md.enabled, d.model.enabled),
172
+ requireDeclaration: bool(md.requireDeclaration, d.model.requireDeclaration),
173
+ deleteRebuild: bool(md.deleteRebuild, d.model.deleteRebuild),
174
+ declarationWindowLines: num(md.declarationWindowLines, d.model.declarationWindowLines),
175
+ rebuildWindowLines: num(md.rebuildWindowLines, d.model.rebuildWindowLines),
176
+ extraCascadePatterns:
177
+ Array.isArray(md.extraCascadePatterns) && md.extraCascadePatterns.length > 0
178
+ ? md.extraCascadePatterns.filter((x) => typeof x === "string" && x.length > 0)
179
+ : [],
180
+ },
156
181
  ignore: Array.isArray(raw.ignore) && raw.ignore.every((x) => typeof x === "string") && raw.ignore.length > 0
157
182
  ? raw.ignore
158
183
  : [...d.ignore],
@@ -6,6 +6,8 @@
6
6
  * are disabled. No IO.
7
7
  */
8
8
 
9
+ import { hasPersistenceFiles } from "./model.mjs";
10
+
9
11
  /** Languages the gate's metrics understand. */
10
12
  export const LANGUAGES = {
11
13
  javascript: { name: "JavaScript", exts: [".js", ".jsx", ".mjs", ".cjs"] },
@@ -28,7 +30,7 @@ export function detectLanguages(filePaths) {
28
30
  }
29
31
 
30
32
  /** A metric that the gate can run. */
31
- export function planMetrics(config, languages, tools) {
33
+ export function planMetrics(config, languages, tools, opts = {}) {
32
34
  const plans = [];
33
35
  const hasJs = languages.has("javascript") || languages.has("typescript");
34
36
  const hasTs = languages.has("typescript");
@@ -87,6 +89,24 @@ export function planMetrics(config, languages, tools) {
87
89
  });
88
90
  }
89
91
 
92
+ // Model level (P27): built in, so it needs no tool — but it only has
93
+ // something to say when the project has persistence to inspect.
94
+ const md = config.model;
95
+ const persistence = hasPersistenceFiles(opts.files || []);
96
+ push({
97
+ key: "modelFindings",
98
+ level: "model",
99
+ tool: "asf-model",
100
+ enabled: md.enabled && persistence,
101
+ disableReason: md.enabled ? "no persistence files (no .sql, migrations/, models/, entities/)" : "disabled in config",
102
+ threshold: 0,
103
+ thresholdLabel: "no undeclared cascade / rebuild-by-delete",
104
+ toolAvailable: true,
105
+ toolName: "asf model scan (built in)",
106
+ compare: "violations",
107
+ args: { model: md },
108
+ });
109
+
90
110
  return plans;
91
111
  }
92
112
 
@@ -115,7 +135,7 @@ export function resolvePlans(plans, languages) {
115
135
  const hasJs = languages.has("javascript") || languages.has("typescript");
116
136
  const anySource = languages.size > 0;
117
137
  return plans.map((p) => {
118
- if (!p.enabled) return { ...p, status: "disabled", reason: "disabled in config" };
138
+ if (!p.enabled) return { ...p, status: "disabled", reason: p.disableReason || "disabled in config" };
119
139
  if (p.tool === "eslint" || p.tool === "eslint-sonarjs" || p.tool === "madge") {
120
140
  if (!hasJs) return { ...p, status: "skip-lang", reason: "no JS/TS sources" };
121
141
  }
@@ -41,8 +41,8 @@ export async function runHealth(projectRoot, args = [], deps = {}) {
41
41
  const files = listSourceFiles(projectRoot, config.ignore);
42
42
  const languages = detectLanguages(files);
43
43
  const tools = deps.tools || detectTools(projectRoot, deps);
44
- const plans = resolvePlans(planMetrics(config, languages, tools), languages);
45
- const results = await runAll(plans, projectRoot, { ...deps, timeoutMs: config.toolTimeoutSeconds * 1000 });
44
+ const plans = resolvePlans(planMetrics(config, languages, tools, { files }), languages);
45
+ const results = await runAll(plans, projectRoot, { ...deps, files, timeoutMs: config.toolTimeoutSeconds * 1000 });
46
46
  const rows = plans.map((p) => evaluateRow(p, results[p.key]));
47
47
  const v = verdict(rows, config);
48
48
 
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Code Health Gate — model level (P27).
3
+ *
4
+ * Two objective fitness functions over persistence code, per
5
+ * references/06j-data-model.md. Dependency-free: no tool to install, no parser,
6
+ * no network — a model gate that cannot run never gates.
7
+ *
8
+ * **This is a tripwire, not an authority.** It asks one mechanical question —
9
+ * *is there a deletion decision here that nobody wrote down?* — and it will miss
10
+ * stores and dialects it has not been told about. That is why the obligation
11
+ * lives in the DoD checkbox (06b Rule 20) and in the invariant tests (06g §3),
12
+ * not here. A clean scan is not a pass; it means the tripwire did not catch
13
+ * anything.
14
+ *
15
+ * The pattern set below is deliberately **illustrative, not exhaustive**. The
16
+ * gate is not "we recognise these six spellings of cascade" — the gate is
17
+ * "a cascade requires a declaration". Projects add their own dialect with
18
+ * `model.extraCascadePatterns` rather than waiting for a release (06c Rule 3:
19
+ * config drives behaviour).
20
+ *
21
+ * undeclared-cascade a cascade declaration with no ownership declaration
22
+ * delete-then-reinsert rebuild-by-delete over one table/collection
23
+ *
24
+ * Both are waivable with `asf-model-accept: <reason>` — greppable, so an accepted
25
+ * exception stays visible instead of becoming a silent pass.
26
+ */
27
+
28
+ /**
29
+ * Declarations whose documented meaning is "deleting the parent takes the
30
+ * children". Relational, ORM-mapped and object-store alike — the *concept* is
31
+ * what matters, not which store expresses it. Extend via config.
32
+ */
33
+ const CASCADE_DECL_RES = [
34
+ { marker: "ON DELETE CASCADE", re: /\bON\s+DELETE\s+CASCADE\b/i },
35
+ { marker: "onDelete: Cascade", re: /\bon_?delete\s*[:=]\s*:?\s*["']?cascade["']?/i },
36
+ { marker: "on_delete=models.CASCADE", re: /\bon_delete\s*=\s*\w+\.CASCADE\b/ },
37
+ { marker: "dependent: :destroy", re: /\bdependent\s*(?::|=>)\s*:(?:destroy|delete_all)\b/ },
38
+ { marker: "cascade remove", re: /\b(?:CascadeType\s*\.\s*(?:REMOVE|ALL)|DeleteBehavior\s*\.\s*Cascade|orphanRemoval\s*=\s*true)\b/ },
39
+ { marker: 'cascade = "…delete…"', re: /\bcascade\s*=\s*["'][^"']*delete/i },
40
+ ];
41
+
42
+ /** Writes and deletions, matched on verb shape rather than store dialect. */
43
+ const DELETE_VERBS = /^(deleteMany|deleteOne|deleteAll|destroyAll|bulkDelete|delete|remove|removeAll|destroy)$/i;
44
+ const INSERT_VERBS = /^(insertMany|insertOne|createMany|bulkCreate|bulkWrite|insert|insertAll|save|upsert|create)$/i;
45
+ /** Verbs that also exist on in-memory collections — trusted only in store files. */
46
+ const AMBIGUOUS_VERBS = /^(delete|remove|removeAll|destroy|save|upsert|create|insert)$/i;
47
+
48
+ /** Method calls, matched on the verb; the target is resolved per call. */
49
+ const VERB_RE = /\.([A-Za-z_$][\w$]*)\s*\(/g;
50
+ const SQL_DELETE_RE = /\bDELETE\s+FROM\s+([`"\[]?[\w.]+)/gi;
51
+ const SQL_INSERT_RE = /\bINSERT\s+INTO\s+([`"\[]?[\w.]+)/gi;
52
+ const NAMED_TARGET_RE = /\b(?:collection|from|table|model|entity)\s*\(\s*["']([\w.\-]+)["']/i;
53
+
54
+ /** What a call at `idx` acts on: a named target, else the receiver's last name. */
55
+ function targetBefore(line, idx) {
56
+ const before = line.slice(0, idx);
57
+ const named = NAMED_TARGET_RE.exec(before);
58
+ if (named) return named[1];
59
+ let cleaned = before.replace(/\s+$/, "");
60
+ if (cleaned.endsWith("))")) cleaned = cleaned.replace(/\([^()]*\)$/, "");
61
+ cleaned = cleaned.replace(/[.(\s]+$/, "");
62
+ const m = /([A-Za-z_$][\w$]*)$/.exec(cleaned);
63
+ return m ? m[1] : null;
64
+ }
65
+
66
+ /** A file that demonstrably talks to a store — needed to trust ambiguous verbs. */
67
+ const PERSISTENCE_SIGNAL_RE =
68
+ /\b(createTable|CREATE\s+TABLE|migrat|schema|@Entity|@Table|__tablename__|@@model|defineSchema|collection|repository|session|transaction|BEGIN|COMMIT)\b/i;
69
+
70
+ /** Statements that name a table; used to find the child of a cascade. */
71
+ const SQL_TABLE_RE = /\b(?:ALTER|CREATE|DROP)\s+TABLE\s+(?:IF\s+(?:NOT\s+)?EXISTS\s+)?([`"\[]?[\w.]+)/i;
72
+ const DECL_BLOCK_RE = /^\s*(?:class|model|type|table|collection|resource|entity)\s+["']?([A-Za-z_][\w]*)["']?/im;
73
+ const TABNAME_RE = /__tablename__\s*=\s*["']([\w.]+)["']/i;
74
+
75
+ /** `asf-model:` declares; `asf-model-accept:` waives. Both stay greppable. */
76
+ const TAG_RE = /asf-model(-accept)?\s*:/i;
77
+
78
+ const IDENT = (s) =>
79
+ String(s || "")
80
+ .replace(/[`"\[\]\s]/g, "")
81
+ .split(".")
82
+ .pop()
83
+ .toLowerCase();
84
+
85
+ /** Compile project-supplied dialect additions; invalid patterns are dropped. */
86
+ export function extraCascadePatterns(cfg) {
87
+ const out = [];
88
+ for (const p of (cfg && cfg.extraCascadePatterns) || []) {
89
+ if (typeof p !== "string" || !p) continue;
90
+ try {
91
+ out.push({ marker: `custom: ${p}`, re: new RegExp(p, "i") });
92
+ } catch {
93
+ /* invalid regex — ignore rather than crash the gate */
94
+ }
95
+ }
96
+ return out;
97
+ }
98
+
99
+ /** Distance in lines to the nearest preceding asf-model tag, or null. */
100
+ export function nearestTag(lines, idx, windowLines) {
101
+ for (let d = 0; d <= windowLines; d++) {
102
+ const i = idx - d;
103
+ if (i < 0) break;
104
+ const m = TAG_RE.exec(lines[i]);
105
+ if (m) return { distance: d, accept: Boolean(m[1]) };
106
+ }
107
+ return null;
108
+ }
109
+
110
+ /** The table/collection a cascade at `idx` belongs to (the child, not the parent). */
111
+ export function childTableAt(lines, idx, lookback = 12) {
112
+ for (let i = idx; i >= Math.max(0, idx - lookback); i--) {
113
+ const t = SQL_TABLE_RE.exec(lines[i]);
114
+ if (t) return IDENT(t[1]);
115
+ const tn = TABNAME_RE.exec(lines[i]);
116
+ if (tn) return IDENT(tn[1]);
117
+ const c = DECL_BLOCK_RE.exec(lines[i]);
118
+ if (c) return IDENT(c[1]);
119
+ }
120
+ return null;
121
+ }
122
+
123
+ /** Text of the statement containing `idx` (SQL: back to ; and forward to ;). */
124
+ export function enclosingStatement(lines, idx, span = 15) {
125
+ let start = idx;
126
+ while (start > 0 && !/[;]\s*$/.test(lines[start - 1]) && idx - start < span) start--;
127
+ let end = idx;
128
+ while (end < lines.length - 1 && !/[;]\s*$/.test(lines[end]) && end - idx < span) end++;
129
+ return lines.slice(start, end + 1).join("\n");
130
+ }
131
+
132
+ /** Delete/insert events, matched on verb shape — no per-store dialect table. */
133
+ function collectEvents(lines, trustedFile) {
134
+ const deletes = [];
135
+ const inserts = [];
136
+ const add = (arr, line, target) => {
137
+ const t = IDENT(target);
138
+ if (t && t.length > 1) arr.push({ line, target: t });
139
+ };
140
+
141
+ for (let i = 0; i < lines.length; i++) {
142
+ const line = lines[i];
143
+ for (const m of line.matchAll(SQL_DELETE_RE)) add(deletes, i, m[1]);
144
+ for (const m of line.matchAll(SQL_INSERT_RE)) add(inserts, i, m[1]);
145
+
146
+ VERB_RE.lastIndex = 0;
147
+ let m;
148
+ while ((m = VERB_RE.exec(line))) {
149
+ const verb = m[1];
150
+ // `map.delete(x)` and `list.remove(x)` are not persistence calls: trust the
151
+ // ambiguous verbs only where the file demonstrably talks to a store.
152
+ if (AMBIGUOUS_VERBS.test(verb) && !trustedFile) continue;
153
+ const target = targetBefore(line, m.index);
154
+ if (DELETE_VERBS.test(verb)) add(deletes, i, target);
155
+ else if (INSERT_VERBS.test(verb)) add(inserts, i, target);
156
+ }
157
+ }
158
+ return { deletes, inserts };
159
+ }
160
+
161
+ /**
162
+ * Scan one file's text. Pure — no IO. `cfg` is the `model` config block.
163
+ * Returns findings sorted by line.
164
+ */
165
+ export function scanModelSource(relPath, text, cfg = {}) {
166
+ const lines = String(text).split(/\r?\n/);
167
+ const findings = [];
168
+ // File-level opt-out lives here so every entry point honours it. It stays
169
+ // greppable — an opt-out nobody can find is a silent pass.
170
+ if (/\basf-model-ignore\b/i.test(text)) return findings;
171
+ const window = cfg.rebuildWindowLines || 30;
172
+ const tagWindow = cfg.declarationWindowLines || 8;
173
+ const cascades = [...CASCADE_DECL_RES, ...extraCascadePatterns(cfg)];
174
+
175
+ // ── F1: a cascade that nobody declared ───────────────────────────────────
176
+ if (cfg.requireDeclaration !== false) {
177
+ for (let i = 0; i < lines.length; i++) {
178
+ const hit = cascades.find((c) => c.re.test(lines[i]));
179
+ if (!hit) continue;
180
+ if (nearestTag(lines, i, tagWindow)) continue;
181
+ const child = childTableAt(lines, i);
182
+ findings.push({
183
+ rule: "undeclared-cascade",
184
+ file: relPath,
185
+ line: i + 1,
186
+ table: child,
187
+ marker: hit.marker,
188
+ message: `${hit.marker}${child ? ` on ${child}` : ""} has no ownership declaration — add \`asf-model: ${child || "<child>"} -> <parent> = composition|instance|ledger, owner: <who>\` within ${tagWindow} lines above (06j §2).`,
189
+ });
190
+ }
191
+ }
192
+
193
+ // ── F2: rebuild-by-delete over one table/collection ─────────────────────
194
+ if (cfg.deleteRebuild !== false) {
195
+ const { deletes, inserts } = collectEvents(lines, PERSISTENCE_SIGNAL_RE.test(text) || PERSISTENCE_PATH_RE.test(relPath));
196
+ const reported = new Set();
197
+ for (const d of deletes) {
198
+ for (const ins of inserts) {
199
+ if (ins.target !== d.target) continue;
200
+ if (Math.abs(ins.line - d.line) > window) continue;
201
+ const key = `${d.target}:${d.line}`;
202
+ if (reported.has(key)) continue;
203
+ reported.add(key);
204
+ if (nearestTag(lines, Math.max(d.line, ins.line), tagWindow)) continue;
205
+ findings.push({
206
+ rule: "delete-then-reinsert",
207
+ file: relPath,
208
+ line: d.line + 1,
209
+ table: d.target,
210
+ marker: "delete + insert",
211
+ message: `${d.target} is deleted (line ${d.line + 1}) and re-inserted (line ${ins.line + 1}) within ${window} lines — rebuild-by-delete destroys row identity and any state attached to those rows (06j M3). Upsert on a stable identity, or waive with \`asf-model-accept: <reason>\`.`,
212
+ });
213
+ }
214
+ }
215
+ }
216
+
217
+ return findings.sort((a, b) => a.line - b.line || a.rule.localeCompare(b.rule));
218
+ }
219
+
220
+ /** File names that are persistence-shaped even when the extension is not. */
221
+ const PERSISTENCE_PATH_RE =
222
+ /(\/|^)(migrations?|alembic|db|dao|repositories?|repos?|models?|entities|stores?|collections?|schema|persistence)\/|\.sql$|\.prisma$/i;
223
+
224
+ const SQL_EXT_RE = /\.sql$/i;
225
+ /** Schema DSLs are persistence code even though they are not "source". */
226
+ const DSL_EXT_RE = /\.(?:prisma|proto|avsc|edn)$/i;
227
+ const CODE_EXT_RE = /\.(?:js|jsx|mjs|cjs|ts|tsx|mts|cts|py|rb|php|java|kt|cs|go|rs|ex|exs|scala)$/i;
228
+
229
+ /** Which of the listed files should the model level look at? */
230
+ export function isModelRelevant(relPath) {
231
+ return SQL_EXT_RE.test(relPath) || DSL_EXT_RE.test(relPath) || CODE_EXT_RE.test(relPath);
232
+ }
233
+
234
+ /**
235
+ * Does the project have anything for this level to inspect? Name-based, so a
236
+ * project whose persistence layer sits somewhere unusual sees `scanned: 0` in
237
+ * the report rather than a silent pass — say so in the delivery report.
238
+ */
239
+ export function hasPersistenceFiles(files) {
240
+ return (files || []).some((f) => SQL_EXT_RE.test(f) || DSL_EXT_RE.test(f) || PERSISTENCE_PATH_RE.test(f));
241
+ }
242
+
243
+ /**
244
+ * Scan a project. `files` and `readFile` are injectable for tests.
245
+ * Returns { findings, scanned, errors }.
246
+ */
247
+ export async function scanModel(root, { files, readFile, config } = {}) {
248
+ const fs = readFile || (await import("node:fs/promises")).readFile;
249
+ const list = files || (await import("./run.mjs")).listSourceFiles(root, null);
250
+ const findings = [];
251
+ const errors = [];
252
+ let scanned = 0;
253
+
254
+ for (const rel of list) {
255
+ if (!isModelRelevant(rel)) continue;
256
+ let text;
257
+ try {
258
+ text = await fs(`${root}/${rel}`, "utf-8");
259
+ } catch (err) {
260
+ errors.push(`${rel}: ${err.message}`);
261
+ continue;
262
+ }
263
+ if (/\basf-model-ignore\b/i.test(text)) continue; // file-level opt-out, greppable
264
+ scanned++;
265
+ findings.push(...scanModelSource(rel, text, config));
266
+ }
267
+
268
+ return { findings, scanned, errors };
269
+ }
270
+
271
+ export const MODEL_RULES = ["undeclared-cascade", "delete-then-reinsert"];
@@ -29,7 +29,7 @@ export function evaluateRow(plan, result) {
29
29
  : compare === "violations"
30
30
  ? value > 0
31
31
  : value > plan.threshold;
32
- return {
32
+ const row = {
33
33
  key: plan.key,
34
34
  level: plan.level,
35
35
  value: formatValue(plan.key, value),
@@ -37,12 +37,19 @@ export function evaluateRow(plan, result) {
37
37
  status: failed ? "fail" : "pass",
38
38
  reason: failed ? `value ${value} exceeds ${plan.thresholdLabel}` : "",
39
39
  };
40
+ // Model findings carry the concrete file:line and the fix, so the agent can
41
+ // act on the report instead of guessing what tripped it (06b Rule 4).
42
+ if (result && Array.isArray(result.findings) && result.findings.length > 0) {
43
+ row.details = result.findings.map((f) => `${f.file}:${f.line} ${f.rule} — ${f.message}`);
44
+ }
45
+ return row;
40
46
  }
41
47
 
42
48
  function formatValue(key, value) {
43
49
  if (value === null || value === undefined) return "?";
44
50
  if (key === "duplication") return `${value}%`;
45
51
  if (key === "circularDependencies") return `${value} cycle(s)`;
52
+ if (key === "modelFindings") return `${value} finding(s)`;
46
53
  return String(value);
47
54
  }
48
55
 
@@ -79,6 +86,11 @@ export function formatReport(rows, config) {
79
86
  row.status === "error" ? "!" :
80
87
  row.status === "skipped" ? "–" : "·";
81
88
  lines.push(`${mark} ${row.level.padEnd(12)} ${row.key.padEnd(22)} ${String(row.value).padEnd(10)} ${row.threshold}${row.reason ? " (" + row.reason + ")" : ""}`);
89
+ if (Array.isArray(row.details) && row.details.length > 0) {
90
+ const shown = row.details.slice(0, 12);
91
+ for (const d of shown) lines.push(` ${d}`);
92
+ if (row.details.length > shown.length) lines.push(` … ${row.details.length - shown.length} more`);
93
+ }
82
94
  }
83
95
  const v = verdict(rows, config);
84
96
  lines.push("──────────────────────");
@@ -106,6 +118,7 @@ export function jsonReport(rows, config) {
106
118
  threshold: r.threshold,
107
119
  status: r.status,
108
120
  reason: r.reason || "",
121
+ details: r.details || [],
109
122
  })),
110
123
  verdict: config.enabled ? verdict(rows, config) : { healthy: true, failures: 0, errors: 0, skipped: 0, ran: 0 },
111
124
  };
@@ -8,6 +8,7 @@
8
8
  import { readdirSync, statSync, existsSync } from "node:fs";
9
9
  import { join, relative, sep } from "node:path";
10
10
  import { spawn } from "node:child_process";
11
+ import { scanModel } from "./model.mjs";
11
12
 
12
13
  /** Run a command with a hard timeout; resolve on exit. */
13
14
  export function exec(cmd, args, { cwd, timeoutMs = 120_000, env } = {}) {
@@ -191,6 +192,20 @@ export async function runDepcruise(plan, root, { execFn = exec, timeoutMs = 120_
191
192
  return { value: res.code === 0 ? 0 : 1 };
192
193
  }
193
194
 
195
+ /**
196
+ * Run the model level (P27). Built in: no spawn, no tool to be missing. Any
197
+ * finding fails the level unless waived in-source with `asf-model-accept:`.
198
+ */
199
+ export async function runModelScan(plan, root, opts = {}) {
200
+ const res = await scanModel(root, { files: opts.files, readFile: opts.readFile, config: plan.args.model });
201
+ return {
202
+ value: res.findings.length,
203
+ findings: res.findings,
204
+ scanned: res.scanned,
205
+ error: res.errors.length ? `unreadable file(s): ${res.errors.join("; ")}` : undefined,
206
+ };
207
+ }
208
+
194
209
  /** Run all plans; returns key → result. */
195
210
  export async function runAll(plans, root, opts = {}) {
196
211
  const results = {};
@@ -208,6 +223,7 @@ export async function runAll(plans, root, opts = {}) {
208
223
  if (p.tool === "jscpd") results[p.key] = await runJscpd(p, root, opts);
209
224
  else if (p.tool === "madge") results[p.key] = await runMadge(p, root, opts);
210
225
  else if (p.tool === "dependency-cruiser") results[p.key] = await runDepcruise(p, root, opts);
226
+ else if (p.tool === "asf-model") results[p.key] = await runModelScan(p, root, opts);
211
227
  }
212
228
  return results;
213
229
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-aia-asf",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Ai Applied Agentic Software Factory — codifies the full software development flow: intake, research, spec capture, adversarial analysis, planning with approval gates, test-first implementation, and release. Requires pi-vigilant, pi-smart-web-search, pi-smart-fetch, and pi-aia-browser.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -236,6 +236,7 @@ Execute the task list milestone by milestone. Discipline rules:
236
236
  9. **Challenge approved designs (M4)**: if a spec's literal reading creates product tension (e.g. feedback clusters under "Plan" when "Plan = plans"), stop and resolve it with the user before implementing — never implement blindly and call it delivered.
237
237
  10. **Trace before claiming delivered (M5)**: the delivery log is a claim; the code is the evidence. Before marking anything ✅, trace the actual code path, confirm the output is consumed by a surface, and confirm the operator-facing outcome test passes.
238
238
  11. **No stubs, no fake data, no invented facts (`references/06i-completeness.md`)**: the deliverable is the real implementation. Finish it, or state the discrepancy plainly to the user — what is missing, why, what remains. Never let the user discover the gap by using the product.
239
+ 12. **The model is asserted, not assumed — for any change that WRITES to a store (06b Rule 20 / `references/06j-data-model.md`)**: the trigger is what the change writes, not where the diff lives — "only a backend change" is the case that needs it most. Declare ownership for every relationship touched (composition / instance / ledger, owner, survives-parent), and test the **non-effect**: seed the referencing rows with production-shaped data and assert they survive, and assert the tables the change does not name are unchanged (06g §3). Run `/asf health` and resolve or accept its findings. **Uncertain whether the model is touched? Then it is touched.** A green suite is not evidence here — the table a cascade empties is usually never populated by the suite that stayed green.
239
240
 
240
241
  ---
241
242
 
@@ -249,6 +250,13 @@ Also check the **stability DoD** (`references/06f-stability.md`): if the deliver
249
250
 
250
251
  Also check the **completeness DoD** (`references/06i-completeness.md`): no stub, placeholder, unimplemented path or fabricated data left on a user-reachable path (or declared to the user with the reason); every part of the ask reachable from where the user enters it, with the exercising command/request/action named in the delivery report; nothing asked for quietly moved to "later"; no claim presented as verified when it was not. `pi-vigilant` scans the changed files for these markers at completion — resolve every finding or explain it; a clean scan is not proof of completeness.
251
252
 
253
+ Also check the **model DoD** (`references/06j-data-model.md`) when the change
254
+ writes to a store: every relationship touched has a written ownership
255
+ declaration, the invariant and non-effect tests (06g §3) exist and are green,
256
+ and `/asf health`'s model level passes or its findings are accepted with a
257
+ reason. State the scan result honestly — `scanned: 0` means it looked at
258
+ nothing, and that is said out loud, never reported as a pass.
259
+
252
260
  **Large work:** run `/asf verify` — it mechanically validates the **spec-to-code traceability matrix** (M1): every `met` spec must carry `trace` (outcome → codePath → testFile + assertion), testFile must exist, assertion must appear in it. FAIL rows block delivery. **Verify ingested specs from external planning docs too** — the doc's ✅ markers are claims, not evidence.
253
261
 
254
262
  **Independent reviewer gate (large work, P19):** before delivery, run the
@@ -285,6 +293,10 @@ conflicts). Record the review + triage in the delivery report.
285
293
  - ❌ Deferring browser verification of a touched interface to final verification — open it in the build, before committing that change (06b Rule 9)
286
294
  - ❌ "It's only a backend change" with no render path named — if uncertain whether it reaches a surface, browser-test it (06b Rule 9)
287
295
  - ❌ Reporting "browser tested" without the page, action and assertion — that is an assertion, not evidence; and a missing browser tool must be said out loud, never read as passed (06b Rule 9)
296
+ - ❌ A green suite that never populated the table the change destroys — asserting only the intended effect is not evidence about the model (06j, 06g §3)
297
+ - ❌ `CASCADE` / delete-and-reinsert chosen because the store defaulted to it or the file above does it — a default and a precedent are both non-decisions (06j M1, M4)
298
+ - ❌ Reporting a clean model scan as a passed check when the level scanned nothing, or trusting the scan where the store exposes no constraints to inspect — the scan is a tripwire; the invariant tests are the gate (06j §5)
299
+ - ❌ Treating the model gate as about "projects that have a database schema" — the trigger is whether **this change writes to a store**, in any store (06b Rule 20)
288
300
  - ❌ Vague commits or CHANGELOG placeholders
289
301
  - ❌ Declaring done while specs are still `open`
290
302
  - ❌ Publishing anything beyond the configured release policy — no `.asf-release.json` means the default: always get the user's explicit go-ahead
@@ -341,17 +353,18 @@ reads at most 06b + 06c; large work reads the ones its phase calls for.
341
353
  |---|---|---|
342
354
  | `01-intake.md` | question bank, probing, external planning docs (M6) | Phase 1 (large) |
343
355
  | `02-research.md` | research playbook, search templates | Phase 2 (large) |
344
- | `04-adversarial.md` | adversarial checklist (spec/architecture/security lenses) | Phase 4 (large) |
345
- | `05-plan.md` | PLAN.md template + ADRs + quality scenarios + ATAM-lite + views | Phase 5 (large) |
356
+ | `04-adversarial.md` | adversarial checklist (spec/architecture/data-model/security lenses) | Phase 4 (large) |
357
+ | `05-plan.md` | PLAN.md template + ADRs + quality scenarios + ATAM-lite + views + ownership table | Phase 5 (large) |
346
358
  | `06-implementation.md` | coding discipline, M4/M5, drift self-correction (P20) | Phase 6 |
347
- | `06b-testing-qa.md` | **mandatory** testing & QA standard (19 rules + DoD) | before writing tests (always) |
359
+ | `06b-testing-qa.md` | **mandatory** testing & QA standard (20 rules + DoD) | before writing tests (always) |
348
360
  | `06c-code-quality.md` | **mandatory** modularity standard (9 rules, deep modules, smells) | before structuring code |
349
361
  | `06d-delegation.md` | intercom & subagents + independent reviewer gate (P19) | before delegating; Phase 7 review (large) |
350
- | `06e-code-health.md` | Code Health Gate (+ depth, supply-chain, secrets metrics) | Gate 8 (large) |
362
+ | `06e-code-health.md` | Code Health Gate (+ depth, supply-chain, secrets, model metrics) | Gate 8 (large) |
351
363
  | `06f-stability.md` | stability patterns + error handling + observability (P06/P07/P08) | **if the deliverable makes external calls or is a service** |
352
- | `06g-test-design.md` | risk-based test derivation + determinism | Phase 6 large (test plan) / non-trivial tests |
364
+ | `06g-test-design.md` | risk-based test derivation + determinism + invariant/non-effect tests (P27) | Phase 6 large (test plan) / non-trivial tests |
353
365
  | `06h-security.md` | threat model, ASVS DoD, supply chain, agent tools | Phase 4/5 large; small = one line |
354
366
  | `06i-completeness.md` | **mandatory** no stubs / no fabricated data / no invented facts + completeness DoD | before implementing and before claiming done (always) |
367
+ | `06j-data-model.md` | **mandatory when the work writes to a store** — ownership classes, model rules M1-M8, legitimate exceptions, invariant/non-effect tests + model DoD | before implementing any store write, and before claiming done |
355
368
  | `07-release.md` | release workflow + **configurable release policy** (`.asf-release.json`: when/how) + test-on-publish | before publishing |
356
369
 
357
370
  ## ASF self-modification policy (P22)
@@ -52,6 +52,25 @@ Challenge every spec and design decision like a hostile reviewer. For each item,
52
52
  - **Rollback**: if this ships and breaks, how do we revert?
53
53
  - **Observability**: logs, metrics, errors — can we see it working in production?
54
54
 
55
+ ## Data & model questions (P27 — when the work touches persistence)
56
+
57
+ The code and the behaviour get reviewed; the model usually does not, and a model
58
+ defect is invisible to every behavioural test (full rules in
59
+ [06j-data-model.md](06j-data-model.md)). For every entity the change touches:
60
+
61
+ - **Ownership**: who creates this row, who may delete it, and does it survive its
62
+ parent? If nobody can answer, that is the finding.
63
+ - **Fan-out**: what else points at this row, and what happens to those rows when
64
+ it is deleted — by a constraint, by an ORM mapping, or by a loop someone wrote?
65
+ - **Identity**: what makes two rows the same thing, and does anything enforce it?
66
+ - **Invariants**: what must *always* be true of this data, and is it enforced
67
+ somewhere that cannot be bypassed, or only asserted in a handler?
68
+ - **Non-effects**: which tables/collections must be unchanged by this operation —
69
+ and does any test look at them?
70
+
71
+ "The suite is green" is not an answer to any of these; the suite almost certainly
72
+ never populated the table in question.
73
+
55
74
  ## Resolution rules
56
75
 
57
76
  - Finding → **new spec** (capture_spec) or **spec change** (capture superseding spec; old one becomes obsolete)
@@ -61,3 +80,6 @@ Challenge every spec and design decision like a hostile reviewer. For each item,
61
80
  ## Adversarial gate
62
81
 
63
82
  Summarize: findings found, specs added/changed, risks accepted → user confirmation.
83
+ For persistence work, the summary states the ownership answers found missing
84
+ (06j) — an unanswered ownership question is a finding like any other, not a
85
+ detail to discover during implementation.
@@ -73,13 +73,33 @@ its own sake — the Architecture/Design prose is the primary artifact.
73
73
 
74
74
  ## Architecture / Design
75
75
  - Modules and their responsibilities (small, single-purpose — see `references/06c-code-quality.md`)
76
- - Data model / schema (if any)
76
+ - Data model / schema (if any) — expanded in the next section when the work
77
+ touches persistence
77
78
  - Key flows (request lifecycle, event flow)
78
79
  - Interfaces / contracts between components
79
80
  - **Where shared truth lives** (single source of truth map: which module owns each shared capability)
80
81
  - **Layering**: one-way dependency rules between layers; what each layer may/may not import
81
82
  - **Standalone testability**: how each module is exercised outside the host with the same calls
82
83
 
84
+ ## Data model & ownership (P27 — when the work touches persistence)
85
+
86
+ The artifact the model gate reads. One row per relationship the change touches;
87
+ if you cannot fill a row, that is a finding for Phase 4, not a blank to skip
88
+ (full rules in `references/06j-data-model.md`).
89
+
90
+ | Relationship | Class | Owner | Survives parent? | Delete semantics | Enforced by |
91
+ |---|---|---|---|---|---|
92
+ | `org_action_items → checklist_questions` | instance | the organisation | **yes** | `SET NULL` + detach-and-keep | FK + invariant test |
93
+ | `checklist_steps → checklist_templates` | composition | the template version | no | `CASCADE` | FK + `asf-model:` tag |
94
+
95
+ Also state, for the change as a whole:
96
+
97
+ - **Identity**: what makes two rows the same thing, and what enforces it.
98
+ - **Invariants** that must always hold, and where each is enforced (constraint vs
99
+ handler — a handler assertion is a claim, a constraint is a guarantee).
100
+ - **Invariant / non-effect tests** that will prove it (06g §3), listed by name.
101
+ - `n/a: no persistence touched, because <reason>` when it genuinely does not apply.
102
+
83
103
  ## Spec-to-code traceability matrix
84
104
  For every spec, the operator-facing outcome, the code path that delivers it, and the test that asserts it. This is what `/asf verify` checks mechanically at delivery (M1).
85
105
 
@@ -24,6 +24,11 @@
24
24
  `pi-aia-browser` **before committing that change** (06b Rule 9) — not at
25
25
  final verification. If you cannot argue the change provably cannot reach a
26
26
  rendered surface, it counts as touched and gets a browser run.
27
+ 6. Assert the **model** before committing a change that writes to a store
28
+ (06b Rule 20 / 06j): write the ownership declaration for each relationship
29
+ touched, and the invariant + non-effect test (06g §3) that fails until the
30
+ model is right — then run `/asf health` and resolve or accept its findings.
31
+ Do this while the change is one edit wide, not at final verification.
27
32
 
28
33
  For web interfaces, browser tests via pi-aia-browser are part of the test suite, not an afterthought.
29
34
 
@@ -124,6 +129,11 @@ are drifting):
124
129
  browser verification).
125
130
  - **Quality drift** — accepting a shortcut that changes behavior or leaves
126
131
  dead machinery, then marking it delivered (M5).
132
+ - **Model drift** — extending a destructive persistence pattern because the
133
+ surrounding code already does it: inheriting a cascade, or writing another
134
+ delete-and-reinsert "like the handler above". The file doing it first is not
135
+ a justification; it is an unreviewed model you are now compounding. Stop and
136
+ resolve it (06j), even when the ask would be satisfied by copying it.
127
137
 
128
138
  **Self-correction loop (autonomous, no user needed):**
129
139
 
@@ -194,6 +194,7 @@ never arrives, the wiring is dead — that is a failed test.
194
194
  - [ ] **Delivery report complete (P14): every spec with status + evidence, every test run with result, every skipped/inconclusive check listed with reason, and the not-tested register (what was deliberately not tested and why)**
195
195
  - [ ] **Stability line (06f): if the deliverable makes external calls, it has timeouts and bounded retries (idempotent only)**
196
196
  - [ ] **Security line (06h): no secrets in the repo; no untrusted input reaches a shell/query/path unvalidated; no unbounded resource use on user-controlled input**
197
+ - [ ] **Model line (06j): every relationship the change touches carries a written ownership declaration; store writes have invariant and non-effect tests (06g §3); `/asf health` model level passes or its findings are accepted with a reason — `n/a: no persistence touched, because <reason>` otherwise**
197
198
 
198
199
  ## Rule 11 — Report honestly
199
200
 
@@ -417,3 +418,35 @@ This does not change Rule 10, Rule 11, or the commit-gate workflow: a project
417
418
  that commits after every green run already runs full before every commit, so
418
419
  Rule 19 only ever saves the intermediate runs inside a single change — it
419
420
  never reduces what runs before anything is committed.
421
+
422
+ ## Rule 20 — The model is asserted, not assumed (P27)
423
+
424
+ **The trigger is what the change WRITES, not where the diff lives.** Any change
425
+ that writes to a store — relational, document, key-value, queue, object store, or
426
+ a file another component reads — goes through
427
+ [06j-data-model.md](06j-data-model.md). A diff that only touches `src/server/`
428
+ is not exempt; the model *is* the backend.
429
+
430
+ Three obligations, and each has a mechanical counterpart because prose alone is
431
+ something an agent can satisfy by nodding:
432
+
433
+ 1. **Declare ownership** for every relationship touched: class
434
+ (composition / instance / ledger), owner, and whether the child survives its
435
+ parent (06j §2). No declaration is not a neutral state — it means the
436
+ relationship was never reviewed.
437
+ 2. **Test the non-effect**, not only the intended effect (06g §3): seed the
438
+ referencing rows with production-shaped data and assert they survive; assert
439
+ the tables the change does not name are unchanged. A suite that never
440
+ populated the destroyed table cannot see the destruction.
441
+ 3. **Run the scan** — `/asf health` model level. It is a tripwire, not an
442
+ authority: pattern-shaped and store-shaped, so a clean scan means it caught
443
+ nothing. If it reports `scanned: 0`, say so; never let a scan you did not run
444
+ or that saw nothing read as a passed check.
445
+
446
+ **When uncertain whether the model is touched, it is touched** — the same
447
+ inversion as Rule 9 and Rule 19. "It's only a backend change" is not an argument.
448
+
449
+ **Evidence, not assertion.** The delivery report names the declarations written,
450
+ the invariant tests added, and the scan result. A bare "model reviewed" is not
451
+ evidence. An accepted finding (`asf-model-accept: <reason>`) is a decision you
452
+ show, not a pass you claim.
@@ -27,6 +27,7 @@ Three levels, each with objective thresholds (all configurable):
27
27
  | design | module depth (interface vs implementation) | report-only heuristic | report-only (never gates) |
28
28
  | supply chain | dependency vulnerabilities | npm audit / osv-scanner | warn (high/critical fails) |
29
29
  | secrets | secrets in repo | gitleaks | warn (any finding fails) |
30
+ | model | undeclared cascade / rebuild-by-delete | built-in scan (P27) | any finding fails |
30
31
 
31
32
  **Depth metric (P05, report-only):** a heuristic that flags **shallow
32
33
  pass-through modules** — modules whose interface is nearly as large as their
@@ -40,6 +41,16 @@ for small work; for large work a high/critical vulnerability in a runtime
40
41
  dependency or any secret finding blocks delivery. See
41
42
  `references/06h-security.md` for the full rules.
42
43
 
44
+ **Model level (P27):** two objective fitness functions over persistence code —
45
+ a cascade with no ownership declaration, and the delete-then-reinsert signature.
46
+ Built in (no tool to install, so it can never be skipped for a missing
47
+ dependency), and it reports the concrete `file:line` and the fix. It is a
48
+ **tripwire, not an authority**: the pattern set is illustrative and
49
+ store-agnostic in principle, so a clean scan means it caught nothing, not that
50
+ the model is sound — the obligation sits in the DoD (06b Rule 20) and the
51
+ invariant tests (06g §3). Full rules in
52
+ [06j-data-model.md](06j-data-model.md).
53
+
43
54
  Metrics for languages the project does not use are silently skipped. Metrics
44
55
  whose tool is not installed are skipped (or fail the gate when
45
56
  `missingTool: "fail"`).
@@ -83,6 +94,14 @@ switched on/off independently.
83
94
  "architecture": {
84
95
  "dependencyRules": { "enabled": false, "config": ".asf-code-health.rules.mjs" }
85
96
  },
97
+ "model": {
98
+ "enabled": true,
99
+ "requireDeclaration": true,
100
+ "deleteRebuild": true,
101
+ "declarationWindowLines": 8,
102
+ "rebuildWindowLines": 30,
103
+ "extraCascadePatterns": []
104
+ },
86
105
  "supplyChain": {
87
106
  "npmAudit": { "enabled": true, "failOn": "high" },
88
107
  "osvScanner": { "enabled": false }
@@ -70,10 +70,45 @@ immediately; never "re-run into green" (06b Rule 11).
70
70
  **DoD line:** *"the suite is deterministic: no flaky test is tolerated; a
71
71
  flaky test is fixed or deleted, never retried into green."*
72
72
 
73
- ## 3. Where this applies in ASF
73
+ ## 3. Invariant and non-effect tests (P27)
74
+
75
+ Every other rule in this file asks *did the thing you wanted happen?* For
76
+ persistence work that question is not enough, because a model defect is exactly
77
+ an unintended effect on something outside the specification. The suite that let
78
+ 327 action items vanish was green: the template saved, the response was 200, and
79
+ the table that emptied was never populated nor inspected.
80
+
81
+ For any change that writes to a store, add the tests that ask *what must not
82
+ have happened*:
83
+
84
+ 1. **Survival test.** Seed the rows that reference what you are editing or
85
+ deleting — with **production-shaped data**, because a cascade only fires when
86
+ the rows exist — and assert they survive. If the child is an instance (06j
87
+ §1), this test is the executable form of that decision.
88
+ 2. **Non-effect assertion.** Snapshot the row/document counts of every table or
89
+ collection the change does *not* name, and assert they are unchanged. One
90
+ helper, applies to every write path, and it catches the whole class rather
91
+ than the one case you imagined.
92
+ 3. **Zero-row invariant.** Write the invariant as a query that must return
93
+ nothing (orphans, duplicates on the business identity, a completed row with no
94
+ completion time) and run it in the suite. Objective, store-agnostic, and it
95
+ documents the rule as code.
96
+ 4. **Two-user authorization test.** Two identities, objects owned by each, and
97
+ every operation attempted across the boundary — read, update, delete, export,
98
+ admin. A UUID is not an authorization check.
99
+
100
+ These are the hard gate for any store that exposes no constraints to scan
101
+ (06j §5) — where there is no schema to inspect, the invariant test is the only
102
+ thing that can tell you the model is wrong.
103
+
104
+ **DoD line:** *"for every store write the change introduces, the suite asserts
105
+ what must NOT change, not only what should."*
106
+
107
+ ## 4. Where this applies in ASF
74
108
 
75
109
  - **Phase 5 (PLAN.md):** the test plan states the risk-derived test list
76
- (which risks, which techniques, which tests).
110
+ (which risks, which techniques, which tests), and names the invariant and
111
+ non-effect tests for any store write (§3).
77
112
  - **Phase 6 (implementation):** write tests with the technique that matches
78
113
  the shape; keep them deterministic per the rules above.
79
114
  - **Phase 7 (verification):** a flaky test blocks delivery (it is a defect).
@@ -86,3 +121,5 @@ flaky test is fixed or deleted, never retried into green."*
86
121
  - ❌ Real network calls inside unit tests
87
122
  - ❌ Re-running a flaky test until it passes
88
123
  - ❌ Tests that pass only on the developer's machine (locale/timezone)
124
+ - ❌ A green suite that never populated the table the change destroys (06j)
125
+ - ❌ Asserting only the intended effect, never the non-effect (06j)
@@ -0,0 +1,239 @@
1
+ # 06j — The Data Model Gate
2
+
3
+ **Status: mandatory for large work (gate blocks), advisory for small work.**
4
+
5
+ The model is a design artifact and it must be reviewed as one. **Code review is
6
+ not model review.** Everything else in the ASF inspects code (06c, 06e),
7
+ behaviour (06b, 06g), resilience (06f), security posture (06h) or completeness
8
+ (06i) — and a model defect is none of those. It is a wrong assumption about what
9
+ entities exist, who owns them, what must always be true of them, and what may
10
+ happen to them.
11
+
12
+ This reference exists because a gate set that verifies only *intended effects*
13
+ cannot see a model defect: a model defect **is** an unintended effect on
14
+ something outside the specification. The case that produced it: an admin
15
+ unticked one checkbox on a template and saved; the endpoint rebuilt the
16
+ template by deleting its questions; `ON DELETE CASCADE` took every organisation's
17
+ generated action items with them; the suite was green, because the suite never
18
+ created a single action item. See
19
+ [docs/research/model-blind-spots.md](../../../docs/research/model-blind-spots.md).
20
+
21
+ **When this applies:** any change that **writes to a store** — schema, migrations,
22
+ ORM/entity definitions, repositories, or a handler that persists. The trigger is
23
+ what the change WRITES, not where the diff lives; if your diff touches none of
24
+ those, say `n/a: no persistence touched, because <reason>` and move on.
25
+ "If it's only a backend change" is not an argument — the model *is* the backend.
26
+ Same inversion as Rule 19 and Rule 9: **when uncertain whether the model is
27
+ touched, it is touched.**
28
+
29
+ ## 1. Ownership classes — the vocabulary that was missing
30
+
31
+ Every relationship you touch gets classified. The class decides the delete
32
+ semantics; the reason nobody got this wrong *on purpose* is that nobody was
33
+ ever asked.
34
+
35
+ | Class | The child is… | Delete semantics | Example |
36
+ |---|---|---|---|
37
+ | **composition** | nothing without the parent; exists only as part of it | `CASCADE` legitimate | a step row belonging to one template version |
38
+ | **instance** | a **fact about someone else** that merely cites the parent | **never `CASCADE`** — `SET NULL` / `RESTRICT`, detach-and-keep | an action item an organisation generated *from* a template |
39
+ | **ledger** | a record of something that happened; correctness forbids erasure | **hard delete forbidden** — void / compensate / append | payment, completion, status change, audit entry |
40
+ | **unclassified** | you have not decided | treat as **instance** | — |
41
+
42
+ The fail-safe default is the expensive direction: an undeclared relationship is
43
+ an *instance*, so `CASCADE` is not available to you until you argue for it. This
44
+ is deliberate. Rebuild-by-delete is not written by people who think cascades are
45
+ wrong; it is written because it is the shortest code path that syncs rows —
46
+ what the literature calls designing for *compositional convenience*. A rule that
47
+ only says "don't" loses to convenience; the default has to be the safe side.
48
+
49
+ **The test for instance vs composition is one question:** *if the parent is
50
+ deleted, must this row still exist?* If the answer is "the customer's data would
51
+ be wrong without it", it is an instance and the cascade is a bug you have not
52
+ found yet.
53
+
54
+ ## 2. The ownership declaration (the required artifact)
55
+
56
+ Every relationship touched by the change gets a declaration — in the migration
57
+ or schema file, next to the constraint, and in the plan's data-model section:
58
+
59
+ ```sql
60
+ -- asf-model: org_action_items -> checklist_questions = instance
61
+ -- owner: the organisation; survives template edits; FK is SET NULL.
62
+ ALTER TABLE org_action_items
63
+ ADD CONSTRAINT fk_item_question FOREIGN KEY (question_id)
64
+ REFERENCES checklist_questions (id) ON DELETE SET NULL;
65
+ ```
66
+
67
+ Whatever your store or ORM calls it — a FK action, a relation option, a mapping
68
+ attribute, a delete dependency — the declaration is the same three facts: the
69
+ class, the owner, the survival answer.
70
+
71
+ Rules about the artifact:
72
+
73
+ - **No declaration ⇒ not reviewed ⇒ not done.** Absence is not neutral. This is
74
+ the same mechanism as Rule 19's impact statement: "certain" that is not
75
+ written down is a feeling.
76
+ - `ON DELETE CASCADE` (or `dependent: :destroy`, `onDelete: "cascade"`,
77
+ `on_delete="CASCADE"`) **requires** a declaration naming the owner and saying
78
+ why the child is composition. If you cannot write that sentence, the child is
79
+ an instance.
80
+ - A deliberate exception uses `asf-model-accept: <reason>` — a non-empty reason,
81
+ on the same or the line above. That is the only way to pass the gate with a
82
+ finding open, and it is greppable, so an accepted exception stays visible.
83
+ Silence is never a waiver.
84
+ - **The value is forcing the decision, not the answer.** A declaration that
85
+ concludes "`CASCADE` is correct here" is a pass, not a failure. What the gate
86
+ refuses is that nobody was asked.
87
+
88
+ ## 3. Model rules
89
+
90
+ **M1 — Every relationship has a named owner and a survival answer.** Per
91
+ relationship touched: who creates the child, who may delete it, does it survive
92
+ its parent, and why. Unclassified means instance (§1).
93
+
94
+ **M2 — Never `CASCADE` from a business fact to a configuration row.** Deleting a
95
+ template, a setting, a catalogue entry or a plan must not delete artefacts a
96
+ human or an organisation produced. `SET NULL`, `RESTRICT`, or detach-and-keep.
97
+ If the child carries an owner (an org, tenant, user or account reference) or
98
+ human-authored state (`title`, `status`, `completed_at`, `amount`), assume
99
+ instance and make the argument explicitly.
100
+
101
+ **M3 — No rebuild-by-delete where the child has inbound references or
102
+ row-attached state.** "Delete all the children, insert them again" destroys row
103
+ identity, breaks anything pointing at those rows, and silently loses state that
104
+ lives on the row (`completed_at`, status history, counters, attachments). Upsert
105
+ on a **stable identity** instead. Note what the fix in the originating incident
106
+ was: reattach by `question_number` — i.e. *give the row an identity that
107
+ survives a rebuild*. If you are about to write delete-then-insert in one
108
+ transaction, stop; the gate has a fitness function for exactly this shape.
109
+
110
+ **M4 — Invariants the database can express, live in the database.** Unique,
111
+ not-null, foreign-key and check constraints are enforced by a layer that cannot
112
+ forget, be bypassed by a script, or be missed by a new join. Application-level
113
+ validation is a *claim*; a constraint is a *guarantee*, and it is the only
114
+ guarantee that holds for the caller who reaches the table directly. "There's no
115
+ compiler error for *you forgot `WHERE tenant_id`*" — so put the boundary where
116
+ forgetting is impossible (constraint, RLS policy, scoped accessor), not in
117
+ discipline.
118
+
119
+ **M5 — If the business says two rows cannot be the same thing, something must
120
+ enforce that.** A unique constraint (scoped to live rows where tombstones
121
+ exist), not a dedupe in a handler and certainly not a dedupe in a recovery
122
+ script. State the identity rule before you implement the write path.
123
+
124
+ **M6 — Reference across aggregates by identity, and change one aggregate per
125
+ transaction.** A direct reference into another aggregate is an invitation to
126
+ modify both in one commit. If you do not hold the reference, you cannot
127
+ accidentally cascade through it. Where two aggregates must agree, do it through
128
+ an explicit follow-up operation, not through a shared transaction.
129
+
130
+ **M7 — Destructive lifecycle is a designed subsystem.** For each relationship
131
+ where deletion is possible, decide *which* of these it is: cascade, block until
132
+ empty, detach-and-keep, tombstone, or void-and-append. Then:
133
+
134
+ - **Restore is an operation with checks**, not a timestamp flip — cascades fired,
135
+ unique keys were reused, downstream systems reacted.
136
+ - Retention and purge are decided, not drifted into.
137
+ - Deletion propagates as an event to anything that caches or indexes it.
138
+ - Blocking is a legitimate answer. "You cannot delete the template while 327
139
+ organisations have items generated from it" is a fine product behaviour and a
140
+ much better default than discovering the answer in production.
141
+
142
+ **M8 — A wrong-but-plausible empty result must be loud.** A filter that matches
143
+ nothing — missing tenant context, wrong date, a forgotten tombstone predicate —
144
+ returns *success with no rows*, which reads as an empty day and not as a defect.
145
+ Instrument the boundary itself: log when the enforcement context is missing, and
146
+ alert on that pattern. Turning a silent failure mode into a noisy one is a design
147
+ requirement, not observability polish.
148
+
149
+ ## 4. Legitimate exceptions — declared, never silent
150
+
151
+ Like 06i, the rule is not "never deviate", it is "never deviate quietly". Each
152
+ of these is legitimate **when written down**:
153
+
154
+ - A genuine composition where `CASCADE` is right (declare the owner).
155
+ - A rebuild-by-delete on a table with no inbound references and no row-attached
156
+ state (say which table, and why both are absent).
157
+ - Application-level validation instead of a constraint, where the rule spans
158
+ tables or needs the request context (say why the DB cannot express it).
159
+ - A hard delete required by law, with the anonymise-plus-tombstone shape
160
+ described in the research notes.
161
+ - An `asf-model-accept:` for a legacy constraint you are not changing in this
162
+ ticket — with the reason and, ideally, the ticket that will.
163
+
164
+ What is never legitimate: an undeclared cascade, an unclassified relationship, a
165
+ rebuild-by-delete nobody justified, or a scan finding read back as a clean
166
+ deliverable. **A model finding you chose to accept is a decision; a model
167
+ finding you did not look at is a hole.**
168
+
169
+ ## 5. Prove it is real (Phase 7)
170
+
171
+ Two mechanisms, and both must run — the prose catches what the machine cannot
172
+ judge, the machine catches what the prose can be talked around.
173
+
174
+ **Objective fitness functions** — `/asf health` runs the `model` level
175
+ ([06e-code-health.md](06e-code-health.md)). Built in, deterministic, no external
176
+ tool. It fails on a cascade with no ownership declaration, and on the
177
+ delete-then-reinsert signature over one table or collection. Objectivity is the
178
+ requirement: "is this ownership correct?" is judgment and stays in review, while
179
+ "does this file declare a cascade with no `asf-model:` tag?" is measurable.
180
+
181
+ **It is a tripwire, not an authority — and it is store-agnostic by design.** The
182
+ defect is not SQL-shaped: an instance inheriting its parent's lifecycle happens
183
+ in a relational FK action, in an ORM mapping attribute, in a document store's
184
+ delete dependency, in a key-value prefix wipe, or in a loop someone wrote to
185
+ delete children by hand. The scan recognises a small illustrative set of those
186
+ spellings and a project adds its own dialect with `model.extraCascadePatterns`
187
+ rather than waiting for a release. **Where the store has no constraint to
188
+ inspect, the scan has nothing to attach to and the invariant tests below are the
189
+ only hard gate** — say which of the two you relied on, and if the scan reported
190
+ `scanned: 0`, say that too. A clean scan means the tripwire caught nothing, not
191
+ that the model is sound.
192
+
193
+ **Invariant and non-effect tests** — [06g-test-design.md](06g-test-design.md) §3:
194
+ survival tests, non-effect assertions, zero-row invariant queries, two-user
195
+ authorization tests. These are what a model rule looks like when it stops being
196
+ an opinion, and they are the part that works against any store.
197
+
198
+ ## 6. Definition of Done — model
199
+
200
+ - [ ] Every relationship the change touches is classified (composition /
201
+ instance / ledger) with a named owner and a survival answer.
202
+ - [ ] Every `CASCADE` in the diff is declared as a composition, or carries
203
+ `asf-model-accept:` with a reason.
204
+ - [ ] No rebuild-by-delete over rows with inbound references or row-attached
205
+ state, or it is declared with why both are absent.
206
+ - [ ] Business invariants the DB can express are constraints, not handler code.
207
+ - [ ] Identity rule stated and enforced where the business says rows cannot
208
+ duplicate.
209
+ - [ ] Deletion semantics decided per relationship (§M7), including restore.
210
+ - [ ] `/asf health` `model` level passes, or every finding is accepted with a
211
+ reason and shown; if the level scanned nothing, that is said out loud.
212
+ - [ ] Invariant and non-effect tests exist for the touched relationships
213
+ (06g §3) and are green — and they are the gate, not the scan, where the
214
+ store exposes no constraints.
215
+ - [ ] Or: `n/a: no persistence touched, because <reason>`.
216
+
217
+ ## Anti-patterns
218
+
219
+ - **The instance that inherited its parent's lifecycle** — a fact about a
220
+ customer deleted because a setting changed. (M1, M2)
221
+ - **Rebuild-by-delete as the implementation of "sync"** — shortest path, worst
222
+ blast radius. (M3)
223
+ - **Business rules that exist only in the service layer** — true until a script,
224
+ a backfill, a second endpoint or a new join bypasses them. (M4)
225
+ - **Identity enforced by a `Set` in memory** — dedupe in code where a unique
226
+ constraint belongs. (M5)
227
+ - **One transaction spanning two aggregates** — the cascade's favourite vehicle.
228
+ (M6)
229
+ - **`CASCADE` chosen because the store or ORM defaulted to it** — a default is
230
+ not a decision. (M1, §2)
231
+ - **"It's only a backend change"** — the model *is* the backend; the diff
232
+ location is not the trigger. (§When this applies)
233
+ - **A green suite that never populated the affected table** — a test that cannot
234
+ observe the destruction is not a test of it. (06g §3)
235
+ - **Trusting a clean scan** — the tripwire is pattern-shaped and store-shaped;
236
+ it is not proof. (§5)
237
+ - **Silent zero** — a missing filter reported as an empty day. (M8)
238
+ - **Reading a scan exception as a clean deliverable** — an accepted finding is a
239
+ decision you must show, not a pass you may claim. (§4)