pi-aia-asf 0.10.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,92 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
- ## [0.10.0] - 2026-10-07
10
+ ## [0.12.0] - 2026-10-14
11
11
 
12
12
  ### Added
13
13
 
14
- - (describe changes for 0.10.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
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
15
58
 
59
+ ### Added
60
+
61
+ - **Browser verification now follows what a change TOUCHES, and happens in the
62
+ build (06b Rule 9, strengthened in place)**. The rule already existed, but
63
+ every formulation keyed on *"if the deliverable has a UI"* — which an agent
64
+ editing one helper in an existing app reads as not engaged, and "I only
65
+ changed a helper" is exactly the change that reaches a rendered surface
66
+ unnoticed. The trigger is now **can this change reach a rendered surface**,
67
+ with an explicit touched list: pages, routes, components, forms, templates;
68
+ markup, styling, copy, formatting, truncation, empty and error states;
69
+ client-side state, routing and the fetching a view depends on; **an API,
70
+ serializer or query that a UI consumes** (a server-side diff is not exempt
71
+ for living in `src/server/`); auth/permission paths that gate what a user
72
+ sees; user-triggered uploads and downloads.
73
+ - **Moved from final verification into the Phase 6 loop.** "Must be verified"
74
+ was Phase 7 language, which makes the gate the first time anyone has looked —
75
+ by then the cause is buried in the whole change. The touched interface is now
76
+ opened in the browser **before committing that change**, while the context is
77
+ warm and the defect is one edit wide (test-first step 5).
78
+ - **Same safety inversion as Rule 19**: uncertain whether the change reaches a
79
+ surface? Then it is touched. "It's only a backend change" is not an argument;
80
+ naming the render path it cannot affect is. Uncertainty costs a browser run,
81
+ never a skipped one.
82
+ - **Closes a hole Rule 19 opened**: nothing previously stopped a subset run
83
+ from excluding the browser/E2E suite for the surface just changed. For a
84
+ touched surface that suite is now **never excludable** — selection may drop
85
+ unrelated suites, never the one that looks at what you just changed.
86
+ - **Evidence, not assertion.** A run is reported with the page/URL opened, the
87
+ action taken and what was asserted — or `n/a: no rendered surface touched,
88
+ because <reason>`. Bare "browser tested" is named as an assertion rather than
89
+ evidence, and an unavailable `pi-aia-browser` must be said out loud instead of
90
+ reading as a passed check. Backed by the Rule 10 checkbox, the `/asf verify`
91
+ checklist item, four new anti-patterns, and `test/test-browser-touched.mjs`
92
+ (42 assertions, 6 negative controls). Rule 9 keeps its number, its five steps
93
+ and its silent-async paragraph — strengthened, not duplicated.
94
+
95
+ ## [0.10.0] - 2026-10-07
16
96
 
17
97
  ### Added
18
98
 
package/index.ts CHANGED
@@ -44,15 +44,16 @@ interface AsfState {
44
44
  /** Definition-of-done checks (references/06b-testing-qa.md, Rule 10). */
45
45
  const QA_CHECKLIST: Array<{ key: string; label: string }> = [
46
46
  { key: "build", label: "Typecheck/build passes" },
47
- { key: "tests", label: "Full test suite green (not a subset)" },
47
+ { key: "tests", label: "Full test suite green (not a subset) — the gate; Rule 19 never relaxes this checkbox" },
48
48
  { key: "regression", label: "Regression test added for every bug fixed this cycle" },
49
49
  { key: "triggers", label: "Triggers AND non-triggers tested for conditional behavior" },
50
50
  { key: "artifact", label: "Shipped artifact inspected (npm pack file list) + clean-room install" },
51
51
  { key: "observable", label: "Observable end state verified as a user would experience it" },
52
- { key: "browser", label: "Web surfaces exercised through a real browser (n/a if none)" },
52
+ { key: "browser", label: "Every interface the change TOUCHES exercised through a real browser during the build (06b Rule 9) — page + action + assertion named; n/a with reason if none" },
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.10.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",
@@ -229,13 +229,14 @@ Execute the task list milestone by milestone. Discipline rules:
229
229
  2. **Codebase isolation**: work strictly inside the project's own codebase. Do NOT edit files in other repos, global config, or unrelated directories — **unless the user explicitly instructs otherwise**. If a change would touch another codebase, stop and ask.
230
230
  3. **No scope creep**: if something new is discovered that changes specs, capture it, ask the user, and update the plan before implementing.
231
231
  4. **Descriptive commits**: `git commit -m "type: specific description of what and why"` (e.g. `fix: verify specs before rotation`). No vague messages, no placeholders.
232
- 5. **Browser testing — MANDATORY for any web interface**: if the deliverable has a UI/website/web app, test it through `pi-aia-browser` (`browser_init`, `browser_navigate`, `browser_click`, `browser_type`, `browser_screenshot`, `browser_dom`, …) to replicate the user's real experience — not just curl/API checks. Verify: loads, key user journeys, responsive behavior, console errors. **Silent async paths included**: ingest through the real flow and wait for the enrichment to land (06b Rule 9).
232
+ 5. **Browser testing — MANDATORY for every interface the change TOUCHES, in the build (06b Rule 9)**: the trigger is **can this change reach a rendered surface**, not whether the project "has a UI" — a page, route, component, form, markup, styling, copy, formatting, client-side state, or an API that a UI consumes all count. Exercise it through `pi-aia-browser` (`browser_init`, `browser_navigate`, `browser_click`, `browser_type`, `browser_screenshot`, `browser_dom`, …) **before committing that change**, not at final verification — not just curl/API checks. Verify: loads, key user journeys, responsive behavior, console errors. **Uncertain whether it reaches a surface? Then it is touched** — "only a backend change" is not an argument, naming the render path it cannot affect is. **Silent async paths included**: ingest through the real flow and wait for the enrichment to land (06b Rule 9). Report the page opened, the action taken and what was asserted, or `n/a` with a reason; if the browser tool is unavailable, say so rather than letting it read as passed.
233
233
  6. **Bounded waits — ALWAYS (06b Rule 15)**: **every** tool call or command that waits on the system gets an explicit, reasonable **hard timeout** (`timeout N …` or the tool's `timeout` parameter) — never run unbounded. State the expected duration first and size the bound to it with headroom (a 30s suite gets ~120s, not 3000s). This matters most **during testing**, where harnesses, servers, daemons and browsers hang on ports, locks and prompts. **Absolute ceiling: no single wait may exceed 30 min (1800s).** The ceiling is a backstop, never a substitute for the real per-call bound. When it is hit: kill the wait and diagnose what it is blocking on — that is a bug signal, not patience. The ceiling may be **waived only for a single run**, stated explicitly beforehand with a reason and a finite larger bound; the waiver **does not persist** and resets immediately after that run. A 50,000-second hang is a failure to investigate.
234
234
  7. **Let pi-vigilant do its job**: it will auto-continue after premature stops and verify specs at settle. When it asks for `update_spec_status` with evidence, do it.
235
235
  8. **CHANGELOG discipline**: every user-visible change gets a CHANGELOG entry describing exactly what changed (no placeholder text).
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
@@ -281,6 +289,14 @@ conflicts). Record the review + triage in the delivery report.
281
289
  - ❌ Skipping research for new projects
282
290
  - ❌ Editing other codebases without explicit permission
283
291
  - ❌ Verifying a UI only via API/curl — browser testing is mandatory
292
+ - ❌ Treating browser testing as about "projects that have a UI" — the trigger is whether **this change** can reach a rendered surface (06b Rule 9)
293
+ - ❌ Deferring browser verification of a touched interface to final verification — open it in the build, before committing that change (06b Rule 9)
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)
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)
284
300
  - ❌ Vague commits or CHANGELOG placeholders
285
301
  - ❌ Declaring done while specs are still `open`
286
302
  - ❌ Publishing anything beyond the configured release policy — no `.asf-release.json` means the default: always get the user's explicit go-ahead
@@ -337,17 +353,18 @@ reads at most 06b + 06c; large work reads the ones its phase calls for.
337
353
  |---|---|---|
338
354
  | `01-intake.md` | question bank, probing, external planning docs (M6) | Phase 1 (large) |
339
355
  | `02-research.md` | research playbook, search templates | Phase 2 (large) |
340
- | `04-adversarial.md` | adversarial checklist (spec/architecture/security lenses) | Phase 4 (large) |
341
- | `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) |
342
358
  | `06-implementation.md` | coding discipline, M4/M5, drift self-correction (P20) | Phase 6 |
343
- | `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) |
344
360
  | `06c-code-quality.md` | **mandatory** modularity standard (9 rules, deep modules, smells) | before structuring code |
345
361
  | `06d-delegation.md` | intercom & subagents + independent reviewer gate (P19) | before delegating; Phase 7 review (large) |
346
- | `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) |
347
363
  | `06f-stability.md` | stability patterns + error handling + observability (P06/P07/P08) | **if the deliverable makes external calls or is a service** |
348
- | `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 |
349
365
  | `06h-security.md` | threat model, ASVS DoD, supply chain, agent tools | Phase 4/5 large; small = one line |
350
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 |
351
368
  | `07-release.md` | release workflow + **configurable release policy** (`.asf-release.json`: when/how) + test-on-publish | before publishing |
352
369
 
353
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
 
@@ -20,6 +20,15 @@
20
20
  when certain the subset is warranted; default is full
21
21
  4. Only commit green — the pre-commit run is always the FULL suite (06b
22
22
  Rule 10), never a subset, regardless of what ran in step 3
23
+ 5. Browser-verify every interface the change **touches** through
24
+ `pi-aia-browser` **before committing that change** (06b Rule 9) — not at
25
+ final verification. If you cannot argue the change provably cannot reach a
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.
23
32
 
24
33
  For web interfaces, browser tests via pi-aia-browser are part of the test suite, not an afterthought.
25
34
 
@@ -30,9 +39,13 @@ For web interfaces, browser tests via pi-aia-browser are part of the test suite,
30
39
  - If a legitimate need to touch another codebase arises (shared lib, dependency fix): **stop and ask the user**.
31
40
  - Exception: the user explicitly instructs otherwise — then note the instruction and proceed.
32
41
 
33
- ## Browser testing (MANDATORY for web interfaces)
42
+ ## Browser testing (MANDATORY for every touched interface, in the build)
34
43
 
35
- Any deliverable with a UI/web surface must be verified through the browser to replicate the user's real experience:
44
+ The trigger is **what the change touches**, not whether the project has a UI.
45
+ If the change can reach a rendered surface — page, route, component, form,
46
+ markup, styling, copy, formatting, client-side state, or an API a UI consumes —
47
+ it gets a browser run, **during Phase 6 while building**, before that change is
48
+ committed. Uncertain whether it reaches one? Then it is touched (06b Rule 9).
36
49
 
37
50
  1. `browser_init` — start the browser
38
51
  2. `browser_navigate` to the app URL (local dev server or deployed)
@@ -43,6 +56,11 @@ Any deliverable with a UI/web surface must be verified through the browser to re
43
56
 
44
57
  API-only verification is NOT sufficient for anything a human would see or click.
45
58
 
59
+ Report it as evidence, not assertion: the page/URL opened, the action taken,
60
+ what was asserted — or `n/a: no rendered surface touched, because <reason>`.
61
+ If `pi-aia-browser` is unavailable, say so and name what you did instead; a
62
+ missing tool must never read as a passed check.
63
+
46
64
  ## Commit messages
47
65
 
48
66
  `type: specific description of what and why`
@@ -111,6 +129,11 @@ are drifting):
111
129
  browser verification).
112
130
  - **Quality drift** — accepting a shortcut that changes behavior or leaves
113
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.
114
137
 
115
138
  **Self-correction loop (autonomous, no user needed):**
116
139
 
@@ -117,7 +117,7 @@ two-sided. Half a test suite hides half the bugs.
117
117
  - Check terminal states are reachable, and no state can block its own exit.
118
118
  - Test repeat/idempotent invocations and out-of-order calls.
119
119
 
120
- ## Rule 9 — Browser testing is mandatory for any web surface
120
+ ## Rule 9 — Browser-test every interface you TOUCH, during the build
121
121
 
122
122
  API/curl checks do not replicate what a human sees. Via `pi-aia-browser`:
123
123
 
@@ -129,6 +129,44 @@ API/curl checks do not replicate what a human sees. Via `pi-aia-browser`:
129
129
 
130
130
  A 200 response with a blank or broken page is a **failure**.
131
131
 
132
+ **The trigger is what the change TOUCHES, not whether the project "has a UI".**
133
+ A deliverable-level trigger lets a small edit slide: "I only changed a helper"
134
+ is exactly the change that reaches a rendered surface unnoticed. So the
135
+ question is never *does this project have a UI* — it is **can this change reach
136
+ one**. Anything below counts as touched, and must be exercised through the
137
+ browser:
138
+
139
+ - a page, route, component, widget, form, or template
140
+ - anything that changes what is rendered: markup, styling, copy, formatting,
141
+ date/number/currency rendering, truncation, empty and error states
142
+ - client-side state, routing, data fetching the view depends on
143
+ - an API, serializer or query **that a UI consumes** (server-side changes are
144
+ not exempt just because the diff is in `src/server/`)
145
+ - auth/permission paths that gate what a user sees, and uploads/downloads
146
+ a user triggers from a page
147
+
148
+ **When uncertain, browser-test it — same inversion as Rule 19.** If you cannot
149
+ argue that the change provably cannot reach a rendered surface, treat it as
150
+ touched. "It's only a backend change" is not an argument; naming the render
151
+ path it cannot affect is. The cheap direction to be wrong in must be the safe
152
+ one, so uncertainty costs a browser run, never a skipped one.
153
+
154
+ **This happens in the Phase 6 build loop, per change — not at final
155
+ verification.** Open the touched interface in the browser *before committing
156
+ that change*, while the context is warm and the defect is one edit wide.
157
+ Deferring it to Phase 7 means the gate is the first time anyone has looked, by
158
+ which point the cause is buried in the whole change. If the loop run was a
159
+ Rule 19 subset, the browser/E2E suite for a touched surface is **never
160
+ excludable** — selection may drop unrelated suites, never the one that looks at
161
+ what you just changed.
162
+
163
+ **Evidence, not assertion.** A browser run is reported with what was actually
164
+ done: the page/URL opened, the action taken, and what was asserted — or
165
+ explicitly `n/a: no rendered surface touched, because <reason>`. "Browser
166
+ tested" with no page and no action named is not evidence (Rule 11). If
167
+ `pi-aia-browser` is unavailable, **say so** and name what you did instead;
168
+ never let a missing tool read as a passed check.
169
+
132
170
  **Silent async paths are the priority.** Fire-and-forget enrichment (an async
133
171
  budgeted analysis that fills in a card title after render) fails silently — the
134
172
  page renders, nobody notices the enrichment never landed. For any async/silent
@@ -144,7 +182,7 @@ never arrives, the wiring is dead — that is a failed test.
144
182
  - [ ] Triggers **and** non-triggers tested for conditional behavior
145
183
  - [ ] Shipped artifact inspected (`npm pack` file list) and installed clean-room
146
184
  - [ ] Observable end state verified as a user would experience it
147
- - [ ] Web surfaces exercised through a real browser
185
+ - [ ] Web surfaces exercised through a real browser **in the build, for every interface the change touches** (Rule 9) — report names the page/URL opened, the action taken and what was asserted; `n/a` stated with a reason if nothing rendered is touched
148
186
  - [ ] Every MUST spec `met` with concrete evidence (`update_spec_status`)
149
187
  - [ ] **Spec-to-code traceability: every `met` spec carries `trace` (outcome → codePath → testFile + assertion); `/asf verify` mechanically validates it (large work)**
150
188
  - [ ] **Code Health Gate passed (or `enabled: false` / `gate.mode: warn` with the report shown)**
@@ -156,6 +194,7 @@ never arrives, the wiring is dead — that is a failed test.
156
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)**
157
195
  - [ ] **Stability line (06f): if the deliverable makes external calls, it has timeouts and bounded retries (idempotent only)**
158
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**
159
198
 
160
199
  ## Rule 11 — Report honestly
161
200
 
@@ -379,3 +418,35 @@ This does not change Rule 10, Rule 11, or the commit-gate workflow: a project
379
418
  that commits after every green run already runs full before every commit, so
380
419
  Rule 19 only ever saves the intermediate runs inside a single change — it
381
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)