pi-aia-asf 0.11.0 → 0.12.1
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 +58 -2
- package/index.ts +1 -0
- package/lib/code-health/config.mjs +25 -0
- package/lib/code-health/detect.mjs +22 -2
- package/lib/code-health/index.mjs +2 -2
- package/lib/code-health/model.mjs +271 -0
- package/lib/code-health/report.mjs +14 -1
- package/lib/code-health/run.mjs +16 -0
- package/package.json +1 -1
- package/skills/aia-asf/SKILL.md +18 -5
- package/skills/aia-asf/references/04-adversarial.md +22 -0
- package/skills/aia-asf/references/05-plan.md +21 -1
- package/skills/aia-asf/references/06-implementation.md +10 -0
- package/skills/aia-asf/references/06b-testing-qa.md +33 -0
- package/skills/aia-asf/references/06e-code-health.md +19 -0
- package/skills/aia-asf/references/06g-test-design.md +39 -2
- package/skills/aia-asf/references/06j-data-model.md +257 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,12 +7,68 @@ 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.12.1] - 2026-10-14
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **06j §7 — who enforces the model gate.** The boundary is now written down:
|
|
15
|
+
ASF states the obligation, and the loop guard enforces completion
|
|
16
|
+
**generically**. When a change touches persistence, the model obligations are
|
|
17
|
+
captured as MUST specs (`capture_spec`) and marked `met` with evidence; the
|
|
18
|
+
guard verifies those specs at settle exactly as it verifies any specification
|
|
19
|
+
arriving from any source. What crosses the boundary is the **specification**,
|
|
20
|
+
never the check — the guard does not know what a cascade is and must not have
|
|
21
|
+
to. SKILL.md's Phase 6 model rule now says so directly, and the anti-pattern
|
|
22
|
+
list names the alternative ("asking the loop guard to understand the model").
|
|
23
|
+
|
|
24
|
+
## [0.12.0] - 2026-10-14
|
|
11
25
|
|
|
12
26
|
### Added
|
|
13
27
|
|
|
14
|
-
- (
|
|
28
|
+
- **The model gate (P27)** — ASF now inspects the data model, not only the code
|
|
29
|
+
shape. A new mandatory reference, `references/06j-data-model.md`, gives the
|
|
30
|
+
factory what it never had: ownership classes (composition / instance /
|
|
31
|
+
ledger), a required `asf-model:` declaration for every relationship a change
|
|
32
|
+
touches (class, owner, survives-parent), model rules **M1–M8**, the legitimate
|
|
33
|
+
exceptions, and a model Definition of Done. The obligation is the point — a
|
|
34
|
+
declaration that concludes "`CASCADE` is correct here" is a pass; what the gate
|
|
35
|
+
refuses is that nobody was asked.
|
|
36
|
+
- **Two objective fitness functions in the Code Health Gate** (`model` level,
|
|
37
|
+
`lib/code-health/model.mjs`): a cascade declaration with no ownership
|
|
38
|
+
declaration, and the rebuild-by-delete signature (delete + re-insert of the
|
|
39
|
+
same table/collection in one window). Built in — no tool to install, so the
|
|
40
|
+
level can never be skipped for a missing dependency — reporting `file:line`
|
|
41
|
+
and the fix, waivable only with a greppable `asf-model-accept: <reason>`.
|
|
42
|
+
Documented as **a tripwire, not an authority**: `scanned: 0` is reported, never
|
|
43
|
+
hidden, and a clean scan is not a pass.
|
|
44
|
+
- **Store-agnostic by design.** Declarations are recognised across relational,
|
|
45
|
+
ORM-mapped and document forms, and a project adds its own dialect with
|
|
46
|
+
`model.extraCascadePatterns` rather than waiting for a release. Where a store
|
|
47
|
+
exposes no constraint to inspect, the text says plainly that the invariant
|
|
48
|
+
tests are the only hard gate.
|
|
49
|
+
- **Invariant and non-effect tests (06g §3)** — the missing half of the test
|
|
50
|
+
doctrine: survival tests with production-shaped data, non-effect assertions on
|
|
51
|
+
the tables the change does not name, zero-row invariant queries, and two-user
|
|
52
|
+
authorization tests. Every other rule asked *did the thing you wanted happen?*;
|
|
53
|
+
these ask *what must not have happened*.
|
|
54
|
+
- **06b Rule 20 — the model is asserted, not assumed**, plus the Rule 10 DoD
|
|
55
|
+
model line, the `/asf verify` checklist item, a **Data & model questions** lens
|
|
56
|
+
in `04-adversarial.md`, a **Data model & ownership** table in the plan
|
|
57
|
+
template, test-first step 6 in `06-implementation.md`, and a **Model drift**
|
|
58
|
+
anti-pattern (extending a destructive persistence pattern because the file
|
|
59
|
+
above does it is drift, not precedent).
|
|
15
60
|
|
|
61
|
+
### Fixed
|
|
62
|
+
|
|
63
|
+
- **The rule count in `SKILL.md` can no longer silently disagree with 06b.** The
|
|
64
|
+
suite now derives the count from the `## Rule N —` headings instead of
|
|
65
|
+
asserting a hardcoded number, so adding a rule no longer requires editing two
|
|
66
|
+
test files (and a stale index fails).
|
|
67
|
+
- **`docs/research/improvement-proposals.md` header** still claimed "research
|
|
68
|
+
phase only — no ASF code or docs have been changed" long after P23–P26 had
|
|
69
|
+
shipped. It now points at `review-decisions.md` as the authoritative record.
|
|
70
|
+
|
|
71
|
+
## [0.11.0] - 2026-10-12
|
|
16
72
|
|
|
17
73
|
### Added
|
|
18
74
|
|
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
|
-
|
|
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
|
};
|
package/lib/code-health/run.mjs
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.12.1",
|
|
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",
|
package/skills/aia-asf/SKILL.md
CHANGED
|
@@ -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. **Capture the obligations as MUST specs (`capture_spec`)** so the loop guard verifies them at settle as it verifies any specification from any source — the guard checks completion, it does not know what a cascade is and must not have to.
|
|
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 (
|
|
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.
|
|
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,257 @@
|
|
|
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
|
+
## 7. Who enforces this
|
|
218
|
+
|
|
219
|
+
ASF states the obligation; the loop guard enforces completion **generically**.
|
|
220
|
+
When a change touches persistence, capture the model obligations as MUST specs
|
|
221
|
+
(`capture_spec`) — the ownership decision per relationship, the invariant and
|
|
222
|
+
non-effect tests, the scan result — and mark each `met` with evidence, not
|
|
223
|
+
assertion. The guard then verifies those specs at settle exactly as it verifies
|
|
224
|
+
any specification that arrives from any source, and refuses "done" while a MUST
|
|
225
|
+
spec is open.
|
|
226
|
+
|
|
227
|
+
Keep model knowledge on this side of the boundary. The guard does not know what
|
|
228
|
+
a cascade is, does not look at schemas, and must not have to — the day it
|
|
229
|
+
inspects your data model is the day it stops being usable outside this factory.
|
|
230
|
+
What crosses the boundary is the **specification**, never the check.
|
|
231
|
+
|
|
232
|
+
## Anti-patterns
|
|
233
|
+
|
|
234
|
+
- **The instance that inherited its parent's lifecycle** — a fact about a
|
|
235
|
+
customer deleted because a setting changed. (M1, M2)
|
|
236
|
+
- **Rebuild-by-delete as the implementation of "sync"** — shortest path, worst
|
|
237
|
+
blast radius. (M3)
|
|
238
|
+
- **Business rules that exist only in the service layer** — true until a script,
|
|
239
|
+
a backfill, a second endpoint or a new join bypasses them. (M4)
|
|
240
|
+
- **Identity enforced by a `Set` in memory** — dedupe in code where a unique
|
|
241
|
+
constraint belongs. (M5)
|
|
242
|
+
- **One transaction spanning two aggregates** — the cascade's favourite vehicle.
|
|
243
|
+
(M6)
|
|
244
|
+
- **`CASCADE` chosen because the store or ORM defaulted to it** — a default is
|
|
245
|
+
not a decision. (M1, §2)
|
|
246
|
+
- **"It's only a backend change"** — the model *is* the backend; the diff
|
|
247
|
+
location is not the trigger. (§When this applies)
|
|
248
|
+
- **A green suite that never populated the affected table** — a test that cannot
|
|
249
|
+
observe the destruction is not a test of it. (06g §3)
|
|
250
|
+
- **Trusting a clean scan** — the tripwire is pattern-shaped and store-shaped;
|
|
251
|
+
it is not proof. (§5)
|
|
252
|
+
- **Silent zero** — a missing filter reported as an empty day. (M8)
|
|
253
|
+
- **Reading a scan exception as a clean deliverable** — an accepted finding is a
|
|
254
|
+
decision you must show, not a pass you may claim. (§4)
|
|
255
|
+
- **Asking the loop guard to understand the model** — it verifies that specs are
|
|
256
|
+
done; teaching it cascade vocabulary puts a domain check in a general tool.
|
|
257
|
+
Capture the obligation as a spec instead. (§7)
|