instar 1.3.734 → 1.3.735

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.
@@ -0,0 +1,236 @@
1
+ // defect-class-registry.mjs — the SELF-CONTAINED ESM mirror of the pure
2
+ // registry/derive/threshold logic in src/core/DefectClassRegistry.ts, used by
3
+ // the class-closure gate's CI lint (scripts/class-closure-lint.mjs).
4
+ //
5
+ // WHY a mirror: PR-gate lints run on a fresh checkout with NO build step
6
+ // (decision-audit-gate.yml precedent), so the lint cannot import the TS module
7
+ // from dist/. This file re-implements the SAME pure functions the lint needs —
8
+ // loadRegistry, validateRegistry, readDecisionDeclarations, deriveClassData,
9
+ // computeEscalation (+ the small helpers/constants they lean on).
10
+ //
11
+ // tests/unit/class-closure-registry-parity.test.ts pins this mirror's outputs
12
+ // EQUAL to src/core/DefectClassRegistry.ts for the same inputs, so the two
13
+ // implementations cannot drift (Structure > Willpower). If you edit one, edit
14
+ // the other and the parity test will hold you to it.
15
+
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+
19
+ export const DEFAULT_SPREAD_N = 3; // ≥N across ≥2 components (normal severity)
20
+ export const DEFAULT_SINGLE_K = 5; // ≥K within one component (normal severity)
21
+ export const DEFAULT_GAP_MAX_AGE_DAYS = 45;
22
+
23
+ export const REGISTRY_REL_PATH = path.join('docs', 'defect-classes.json');
24
+
25
+ /** Is `repoRoot` an instar checkout carrying the class registry (repo-gate)? */
26
+ export function isClassClosureRepo(repoRoot) {
27
+ try {
28
+ return (
29
+ fs.existsSync(path.join(repoRoot, '.git')) &&
30
+ fs.existsSync(path.join(repoRoot, 'package.json')) &&
31
+ fs.existsSync(path.join(repoRoot, REGISTRY_REL_PATH))
32
+ );
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ /** Load + parse the registry file. Throws on missing/malformed JSON or invalid shape. */
39
+ export function loadRegistry(repoRoot) {
40
+ const p = path.join(repoRoot, REGISTRY_REL_PATH);
41
+ const raw = fs.readFileSync(p, 'utf-8');
42
+ const parsed = JSON.parse(raw);
43
+ const v = validateRegistry(parsed);
44
+ if (!v.ok) {
45
+ throw new Error(`defect-classes.json is invalid: ${v.errors.join('; ')}`);
46
+ }
47
+ return parsed;
48
+ }
49
+
50
+ /** Structural validation (used by the lint + tests). Never throws. */
51
+ export function validateRegistry(reg) {
52
+ const errors = [];
53
+ if (!reg || typeof reg !== 'object') return { ok: false, errors: ['registry is not an object'] };
54
+ const r = reg;
55
+ if (typeof r.version !== 'number') errors.push('version must be a number');
56
+ if (!Array.isArray(r.classes)) {
57
+ errors.push('classes must be an array');
58
+ return { ok: errors.length === 0, errors };
59
+ }
60
+ const ids = new Set();
61
+ for (const [i, c] of r.classes.entries()) {
62
+ const where = `class[${i}]`;
63
+ if (!c || typeof c !== 'object') { errors.push(`${where} is not an object`); continue; }
64
+ const e = c;
65
+ if (!e.id || typeof e.id !== 'string') errors.push(`${where}.id missing`);
66
+ else {
67
+ if (ids.has(e.id)) errors.push(`${where}.id duplicate: ${e.id}`);
68
+ ids.add(e.id);
69
+ if (!/^[a-z0-9-]+$/.test(e.id)) errors.push(`${where}.id must match ^[a-z0-9-]+$`);
70
+ }
71
+ if (!e.description || typeof e.description !== 'string') errors.push(`${where}.description missing`);
72
+ if (!Array.isArray(e.includes) || e.includes.length < 1) errors.push(`${where}.includes needs ≥1 entry`);
73
+ if (!Array.isArray(e.excludes) || e.excludes.length < 1) errors.push(`${where}.excludes needs ≥1 entry`);
74
+ if (!Array.isArray(e.canonicalExamples)) errors.push(`${where}.canonicalExamples must be an array`);
75
+ if (e.status !== 'confirmed' && e.status !== 'unconfirmed') errors.push(`${where}.status invalid`);
76
+ if (e.severity !== 'critical' && e.severity !== 'normal') errors.push(`${where}.severity invalid`);
77
+ if (typeof e.instanceCount !== 'number') errors.push(`${where}.instanceCount must be a number`);
78
+ if (typeof e.evidenceCountAtLastAck !== 'number') errors.push(`${where}.evidenceCountAtLastAck must be a number`);
79
+ if (!('closureStandard' in e)) errors.push(`${where}.closureStandard missing (nullable ok)`);
80
+ if (!('proposalId' in e)) errors.push(`${where}.proposalId missing (nullable ok)`);
81
+ // A novel class enters unconfirmed and REQUIRES a nearestExistingClass.
82
+ if (e.status === 'unconfirmed' && !e.nearestExistingClass) {
83
+ errors.push(`${where} is unconfirmed but has no nearestExistingClass`);
84
+ }
85
+ }
86
+ return { ok: errors.length === 0, errors };
87
+ }
88
+
89
+ /**
90
+ * Read the class-closure declarations from committed decision-audit entries.
91
+ * The decision-audit entry is the SINGLE machine-readable counting host — the
92
+ * side-effects artifact mirror is display-only and is NOT read here (C1 fix).
93
+ * @returns {Array<object>} declarations, each with `.source` (filename) + `.ts`.
94
+ */
95
+ export function readDecisionDeclarations(repoRoot) {
96
+ const dir = path.join(repoRoot, '.instar', 'instar-dev-decisions');
97
+ let files;
98
+ try {
99
+ files = fs.readdirSync(dir).filter((f) => f.endsWith('.json'));
100
+ } catch {
101
+ return [];
102
+ }
103
+ const out = [];
104
+ for (const f of files) {
105
+ let entry;
106
+ try {
107
+ entry = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf-8'));
108
+ } catch {
109
+ continue;
110
+ }
111
+ if (entry && entry.classClosure && typeof entry.classClosure === 'object') {
112
+ out.push({ ...entry.classClosure, source: f, ts: entry.ts });
113
+ }
114
+ }
115
+ return out;
116
+ }
117
+
118
+ /**
119
+ * Derive per-class counts from declarations, deduped by PR number. Two
120
+ * declarations citing the same PR + class count ONCE. A declaration with no
121
+ * prNumber falls back to its source filename as the dedup key (still counts once).
122
+ * @returns {Map<string, object>} classId → DerivedClassData.
123
+ */
124
+ export function deriveClassData(declarations) {
125
+ const byClass = new Map();
126
+ for (const d of declarations) {
127
+ if (!d.defectClass) continue;
128
+ const list = byClass.get(d.defectClass) ?? [];
129
+ list.push(d);
130
+ byClass.set(d.defectClass, list);
131
+ }
132
+ const result = new Map();
133
+ for (const [classId, list] of byClass) {
134
+ const dedupKey = (d) => (d.prNumber != null ? `pr:${d.prNumber}` : `src:${d.source}`);
135
+ const seen = new Set();
136
+ const prs = new Set();
137
+ const components = new Set();
138
+ const perComponentKeys = new Map();
139
+ let hasOpenGap = false;
140
+ for (const d of list) {
141
+ const key = dedupKey(d);
142
+ seen.add(key);
143
+ if (d.prNumber != null) prs.add(d.prNumber);
144
+ const comp = d.component ?? '<unspecified>';
145
+ components.add(comp);
146
+ const compSet = perComponentKeys.get(comp) ?? new Set();
147
+ compSet.add(key);
148
+ perComponentKeys.set(comp, compSet);
149
+ if (d.closure === 'gap') hasOpenGap = true;
150
+ }
151
+ let maxSingleComponentCount = 0;
152
+ for (const s of perComponentKeys.values()) {
153
+ maxSingleComponentCount = Math.max(maxSingleComponentCount, s.size);
154
+ }
155
+ result.set(classId, {
156
+ dedupedCount: seen.size,
157
+ componentCount: components.size,
158
+ maxSingleComponentCount,
159
+ hasOpenGap,
160
+ prs: [...prs].sort((a, b) => a - b),
161
+ components: [...components].sort(),
162
+ });
163
+ }
164
+ return result;
165
+ }
166
+
167
+ /**
168
+ * Deterministic threshold logic. Fires ONLY when there is NEW evidence past the
169
+ * last-ack baseline (seeded-closed suppression + dedup re-raise) AND a severity
170
+ * arm crosses:
171
+ * - critical ⇒ escalates at ≥1 confirmed instance (any new evidence).
172
+ * - normal ⇒ ≥N across ≥2 components, OR ≥2 + an open gap, OR ≥K in one component.
173
+ * @param {{ severity: string, evidenceCountAtLastAck: number }} entry
174
+ * @param {object} derived DerivedClassData
175
+ * @param {{ spreadN?: number, singleK?: number }} thresholds
176
+ */
177
+ export function computeEscalation(entry, derived, thresholds = {}) {
178
+ const N = thresholds.spreadN ?? DEFAULT_SPREAD_N;
179
+ const K = thresholds.singleK ?? DEFAULT_SINGLE_K;
180
+ const newEvidence = derived.dedupedCount > entry.evidenceCountAtLastAck;
181
+ if (!newEvidence) {
182
+ return {
183
+ shouldEscalate: false,
184
+ arm: null,
185
+ reason: `no new evidence past ack baseline (derived ${derived.dedupedCount} ≤ ack ${entry.evidenceCountAtLastAck})`,
186
+ newEvidence: false,
187
+ };
188
+ }
189
+ if (entry.severity === 'critical') {
190
+ return {
191
+ shouldEscalate: true,
192
+ arm: 'critical-1',
193
+ reason: `critical class recurred (derived ${derived.dedupedCount} > ack ${entry.evidenceCountAtLastAck}); escalates at 1`,
194
+ newEvidence: true,
195
+ };
196
+ }
197
+ // normal severity — three arms.
198
+ if (derived.dedupedCount >= N && derived.componentCount >= 2) {
199
+ return {
200
+ shouldEscalate: true,
201
+ arm: 'spread',
202
+ reason: `≥${N} instances (${derived.dedupedCount}) across ≥2 components (${derived.componentCount})`,
203
+ newEvidence: true,
204
+ };
205
+ }
206
+ if (derived.dedupedCount >= 2 && derived.hasOpenGap) {
207
+ return {
208
+ shouldEscalate: true,
209
+ arm: 'gap-plus',
210
+ reason: `≥2 instances (${derived.dedupedCount}) + an open gap item`,
211
+ newEvidence: true,
212
+ };
213
+ }
214
+ if (derived.maxSingleComponentCount >= K) {
215
+ return {
216
+ shouldEscalate: true,
217
+ arm: 'single-component',
218
+ reason: `≥${K} instances (${derived.maxSingleComponentCount}) within one component`,
219
+ newEvidence: true,
220
+ };
221
+ }
222
+ return {
223
+ shouldEscalate: false,
224
+ arm: null,
225
+ reason: `no normal-severity arm crossed (count ${derived.dedupedCount}, components ${derived.componentCount}, maxSingle ${derived.maxSingleComponentCount})`,
226
+ newEvidence: true,
227
+ };
228
+ }
229
+
230
+ /** Whether a gap item is open past the max-age escalation ceiling. */
231
+ export function gapOpenPastMaxAge(gapOpenedAtIso, now = Date.now(), maxAgeDays = DEFAULT_GAP_MAX_AGE_DAYS) {
232
+ const opened = Date.parse(gapOpenedAtIso);
233
+ if (Number.isNaN(opened)) return false;
234
+ const ageDays = (now - opened) / (24 * 60 * 60 * 1000);
235
+ return ageDays > maxAgeDays;
236
+ }
@@ -176,3 +176,42 @@ pool-wide question is answered by a proxied-on-read merged view." Not abstractio
176
176
  ## Evidence pointers
177
177
 
178
178
  [Optional. Links or file paths to the live verification artifacts produced during `/build` — reproduction steps, before/after logs, test output. These feed the "Evidence" section in the upgrade notes if the change is shipping as a release.]
179
+
180
+ ---
181
+
182
+ ## Class-Closure Declaration (display-only mirror)
183
+
184
+ **REQUIRED whenever this change FIXES a defect in an agent-authored artifact** (an
185
+ LLM prompt, hook, config, skill, or standards text — see
186
+ `docs/specs/class-closure-gate.md`). This section is the human-readable MIRROR of
187
+ the machine-readable `classClosure` block in the commit's decision-audit entry
188
+ (the host the CI lint validates). **Display-only:** the lint counts the
189
+ decision-audit host ONLY and NEVER sums this mirror — the two are asserted to
190
+ AGREE, never added (C1). If this change fixes no agent-authored-artifact defect,
191
+ state: "No agent-authored-artifact defect — not applicable."
192
+
193
+ - **`defectClass`** — a class id from `docs/defect-classes.json`, or `novel`. A
194
+ `novel` class is not a free pass: it REQUIRES a full new registry entry in the
195
+ same change carrying `nearestExistingClass` + ≥1 `includes` + ≥1 `excludes` +
196
+ `severity`, and it enters `status: "unconfirmed"` (an unconfirmed class CANNOT
197
+ satisfy `closure: guard` — its fix carries `closure: gap` until the operator
198
+ confirms it).
199
+ - **`closure`** — either `guard` (the standard/test/lint that makes the class's
200
+ recurrence structurally refused or detected, cited by path/symbol) or `gap` (a
201
+ tracked standards-gap evolution-action id when the class-level guard is out of
202
+ this fix's scope).
203
+ - **`guardEvidence`** (required with `closure: guard`) — the guard's enforcement
204
+ type as graded by the coverage audit's grader (`ratchet` / `gate` / `lint`),
205
+ the citation, and one line on *how this guard would have caught THIS defect*. A
206
+ citation that does not resolve to a LIVE enforcing guard on disk automatically
207
+ downgrades the declaration to `closure: gap` (G3 — a dark/spec-only artifact
208
+ guards nothing).
209
+ - **`gap`** (with `closure: gap`) — the evolution-action id tracking the missing
210
+ guard. A gap is not fire-and-forget: it counts as escalation evidence and
211
+ re-surfaces on the evolution-action cadence.
212
+
213
+ [Fill in the four fields (or "not applicable"). Example: "`defectClass:
214
+ injection-credulity`, `closure: guard`, `guardEvidence: {enforcementType: gate,
215
+ citation: src/core/promptClauses.ts#authorityClause, howCaught: the authority
216
+ clause separates the trusted instruction surface from the quoted untrusted
217
+ transcript excerpt, so the injected instruction is data not command}`."]
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "$schema": "./builtin-manifest.schema.json",
3
3
  "schemaVersion": 1,
4
- "generatedAt": "2026-07-03T19:15:15.540Z",
5
- "instarVersion": "1.3.734",
4
+ "generatedAt": "2026-07-03T20:54:46.753Z",
5
+ "instarVersion": "1.3.735",
6
6
  "entryCount": 202,
7
7
  "entries": {
8
8
  "hook:session-start": {
@@ -0,0 +1,67 @@
1
+ # Upgrade Guide — vNEXT
2
+
3
+ <!-- assembled-by: assemble-next-md -->
4
+ <!-- bump: minor -->
5
+
6
+ ## What Changed
7
+
8
+ Increment 1 of the Class-Closure Gate (`docs/specs/class-closure-gate.md`, converged +
9
+ approved). This is the mechanical arm of the constitutional principle **"Distrust Temporary
10
+ Success: A Recurrence Is a Root Cause"** — until now nothing at fix-time asked *"what CLASS
11
+ of defect is this, and what structural change makes the whole class unrepresentable?"* (the
12
+ question only ever fired because the operator asked it).
13
+
14
+ Increment 1 ships **CI tooling only — no runtime gates**:
15
+
16
+ - A **defect-class registry** (`docs/defect-classes.json`) seeded with the four measured
17
+ classes, each carrying real semantics (includes/excludes/severity/closure-standard).
18
+ - A pure count/threshold **library** (`src/core/DefectClassRegistry.ts`) that derives each
19
+ class's recurrence count from committed instar-dev decision-audit declarations, deduped by
20
+ PR number, and computes deterministic escalation-threshold crossings (critical-at-1;
21
+ normal: ≥3-across-2-components, ≥2+open-gap, or ≥5-in-one-component; seeded-closed
22
+ suppresses historical backfill).
23
+ - A self-contained **guard grader** (`scripts/lib/class-closure-grader.mjs`, mirroring
24
+ `StandardsEnforcementAuditor` with a pinned parity test) that downgrades a `closure:guard`
25
+ declaration to `gap` when the cited guard does not resolve to a live enforcing guard (G3:
26
+ a dark/spec-only artifact guards nothing).
27
+ - A **report-only PR-gate lint** (`scripts/class-closure-lint.mjs`) + CI workflow
28
+ (`.github/workflows/class-closure-gate.yml`, check name `class-closure`) that validates the
29
+ class-declaration field-set on fixes touching agent-authored artifacts and LOGS threshold
30
+ crossings.
31
+ - An author declaration helper (`scripts/class-closure-declare.mjs`) and the side-effects
32
+ template's mirrored declaration section.
33
+
34
+ Config-gated behind `prGate.classClosure = {enabled, dryRun, escalatorDrafting}` (defaults
35
+ off/dry-run) and repo-gated (no-op on installs without `docs/defect-classes.json`). The
36
+ escalator's LLM drafting arm, the proposals writer, the attention-item producer, the runtime
37
+ read route, and the consolidated axis-requirements ratchet are the spec's OWN dark
38
+ **increment 3** — deliberately not in this increment.
39
+
40
+ ## What to Tell Your User
41
+
42
+ Nothing changes for you right now — this ships **dark and report-only**, and it is
43
+ maintainer-only CI machinery (a no-op on your install unless you develop instar itself). It
44
+ is the first brick of a system that will, later and only after an operator sign-off, notice
45
+ when the same *kind* of bug keeps recurring and propose a structural fix for the whole class
46
+ rather than patching instances one at a time. No behavior, message, or command changes today.
47
+
48
+ ## Summary of New Capabilities
49
+
50
+ None active for end users in this increment — everything ships disabled/dry-run and
51
+ repo-gated. (For instar maintainers: a report-only CI lint that logs missing/invalid
52
+ defect-class declarations and deterministic class-recurrence threshold crossings, plus the
53
+ class registry and grading libraries it rides on.)
54
+
55
+ ## Evidence
56
+
57
+ - `npx vitest run tests/unit/class-closure-registry.test.ts tests/unit/class-closure-grader.test.ts tests/unit/class-closure-grader-parity.test.ts tests/unit/class-closure-registry-parity.test.ts tests/integration/class-closure-lint.test.ts`
58
+ → **5 files, 55 tests, 0 failures** (all four escalation arms + seeded-closed suppression +
59
+ newEvidence gate; guard-upheld vs 3 downgrade paths; report-only-exits-0 vs
60
+ enforcing-exits-nonzero for both hard-violation types; both mirror-parity suites).
61
+ - `npx tsc --noEmit` → exit 0, zero errors.
62
+ - Report-only guarantee is structural: `scripts/class-closure-lint.mjs` computes
63
+ `enforcing = config.enabled && !config.dryRun` and returns a nonzero exit ONLY when
64
+ `enforcing && hardViolations.length > 0` — so under the shipped defaults (disabled +
65
+ dry-run) it always exits 0 and cannot fail a PR.
66
+ - Side-effects review: `upgrades/side-effects/class-closure-gate.md` (8 questions + Q7
67
+ multi-machine posture + signal-vs-authority), second-pass reviewed.
@@ -0,0 +1,111 @@
1
+ # Side-Effects Review — Class-Closure Gate (increment 1: registry + report-only lint)
2
+
3
+ **Version / slug:** `class-closure-gate`
4
+ **Date:** `2026-07-03`
5
+ **Author:** `echo`
6
+ **Second-pass reviewer:** `CONCUR — no material concern (independent reviewer subagent, 2026-07-03)`
7
+
8
+ > **Second-pass verdict (independent reviewer):** CONCUR — no material concern. Report-only
9
+ > guarantee holds on every policy path (`class-closure-lint.mjs:271-272`: exit is nonzero only
10
+ > when `enabled && !dryRun && hardViolations>0`; shipped defaults → exit 0 unconditionally,
11
+ > confirmed on all four paths + a malformed-registry probe). Signal-only: no `docs/proposals`
12
+ > writer, no attention-item producer, no `STANDARDS-REGISTRY.md` write in increment 1 (the sole
13
+ > registry reference is a read-scope predicate). Scope honest: `escalatorDrafting` is plumbed
14
+ > but never read (inert reserved key, not a half-built feature); the new TS has zero runtime
15
+ > callers; the declare mutator is in no CI workflow. Drift guarded: both grader/registry parity
16
+ > suites exist and pass. Interactions clean (complementary to `decision-audit-gate`, not
17
+ > shadowing). Two minor non-blocking notes: (1) the CLI entrypoint lacks a top-level try/catch,
18
+ > but all fs reads are individually try/caught so no throw on the real paths — inherent CI-script
19
+ > fragility, not a policy path; (2) the config-default backfill claim is moot because an absent
20
+ > key resolves to the report-only default. Neither requires a change before commit.
21
+
22
+ ## Summary of the change
23
+
24
+ Increment 1 of the Class-Closure Gate (`docs/specs/class-closure-gate.md`, converged + approved). Ships **CI tooling only — no runtime gates**: a class registry (`docs/defect-classes.json`), a pure count/threshold library (`src/core/DefectClassRegistry.ts`), a self-contained guard grader (`scripts/lib/class-closure-grader.mjs`, mirroring `StandardsEnforcementAuditor`'s classification with a pinned parity test), a **report-only** PR-gate lint (`scripts/class-closure-lint.mjs`) that validates the class-declaration field-set on instar-dev decision-audit entries for fixes touching agent-authored artifacts, derives per-class recurrence counts (deduped by PR#), and LOGS any deterministic escalation-threshold crossing. A CI workflow (`.github/workflows/class-closure-gate.yml`) runs the lint report-only. Config-gated (`prGate.classClosure = {enabled, dryRun, escalatorDrafting}`, defaulting off/dry-run) and repo-gated (no-op on installs without `docs/defect-classes.json`). The escalator's LLM drafting arm, the `docs/proposals/*` writer, the attention-item producer, the runtime read route, and the consolidated axis-requirements ratchet are the spec's OWN dark **increment 3** — explicitly out of scope here, not orphan deferrals.
25
+
26
+ ## Decision-point inventory
27
+
28
+ - `scripts/class-closure-lint.mjs` (CI PR-gate lint) — **add** — a build-time SIGNAL (report-only → later enforcing) that a fix to an agent-authored artifact carries a valid class declaration; feeds the operator, never a runtime allow/deny.
29
+ - Deterministic recurrence trigger (inside the lint, via `computeEscalation`) — **add** — LOGS a threshold crossing; the acting-on-it (draft proposal + attention item) is increment 3.
30
+ - `guardEvidence` grading (`evaluateGuardClosure`) — **add** — downgrades a `closure:guard` declaration to `gap` when the cited guard does not resolve to a live enforcing guard (ratchet/gate/lint). Report-only in increment 1.
31
+
32
+ ---
33
+
34
+ ## 1. Over-block
35
+
36
+ **What legitimate inputs does this change reject that it shouldn't?**
37
+
38
+ Increment 1 is **report-only** (`dryRun:true` default) — the lint ALWAYS exits 0 for declaration-content findings; it cannot reject any PR. The only nonzero exit is reserved for a hard structural violation (malformed registry JSON, a `novel` class with no semantics) AND only when `enabled && !dryRun` — which is a later increment's flip. So: **no over-block surface in increment 1.** (When the enforcing flip lands later, a legitimate fix whose guard citation doesn't resolve is not "blocked" — it downgrades to `closure:gap`, a valid tracked terminal state, per spec.)
39
+
40
+ ---
41
+
42
+ ## 2. Under-block
43
+
44
+ **What failure modes does this still miss?**
45
+
46
+ - A fix that bypasses the instar-dev gate entirely (no decision-audit entry) carries no `classClosure` block to validate. That bypass is caught separately by the existing `decision-audit-presence-check` at the PR boundary; this lint does not duplicate that.
47
+ - **Classification accuracy** — an author who mislabels a defect's class (or picks a self-serving hyper-narrow class) is not caught here; the spec routes that to the escalator's periodic spot-check audit (increment 3) and to operator confirmation of `novel` classes. Increment 1 measures population/accuracy honestly during dryRun rather than enforcing.
48
+ - Recurrence inside a single component below K=5 at normal severity does not escalate (by design — distinguishes a systemic pattern from one noisy component).
49
+
50
+ ---
51
+
52
+ ## 3. Level-of-abstraction fit
53
+
54
+ **Is this at the right layer?**
55
+
56
+ Yes — a **CI PR-gate lint** in the same family as `decision-audit-gate`, `eli16-pr-gate`, `release-fragment-gate`. It is a SIGNAL producer at the build boundary, not a runtime authority, and it **reuses** the existing `StandardsEnforcementAuditor` grading logic (via a self-contained `.mjs` mirror pinned equivalent by a parity test, because PR-gate lints run on a fresh checkout with no build step) rather than re-implementing guard classification. The count/threshold math lives in a pure, unit-tested library (`DefectClassRegistry.ts`). No higher gate already does class-level closure; no lower primitive is being duplicated.
57
+
58
+ ---
59
+
60
+ ## 4. Signal vs authority compliance
61
+
62
+ **Does this hold blocking authority with brittle logic, or produce a signal that feeds a smart gate?** (`docs/signal-vs-authority.md`)
63
+
64
+ **COMPLIANT — signal only.** The report-only lint emits a green/annotation signal; it holds no blocking authority in increment 1. The deterministic trigger only LOGS crossings. The downstream escalator (increment 3) drafts a *proposal* and raises an *attention item* — it has **no write path to `STANDARDS-REGISTRY.md`**; adoption is the operator's (Agent Proposes, Operator Approves). No brittle check is given blocking authority; the one place enforcement is later added (the flip) is a deterministic structural validation (registry well-formedness, declaration presence/shape), not a semantic judgment.
65
+
66
+ ---
67
+
68
+ ## 5. Interactions
69
+
70
+ **Does it shadow another check, get shadowed, double-fire, race with adjacent cleanup?**
71
+
72
+ - Rides the PR-gate workflow family alongside `decision-audit-gate.yml`/`eli16-pr-gate.yml`/`release-fragment-gate.yml`. It reads the SAME decision-audit entries as `decision-audit-presence-check` but a DIFFERENT field (`classClosure`) — no shadowing, no double-fire (presence-check asserts an entry EXISTS; class-closure validates the entry's declaration content).
73
+ - The self-contained grader mirror could DRIFT from `StandardsEnforcementAuditor.gradeGuardCitation`/`classifyFileGuard` — mitigated by a pinned parity test that fails if they diverge (Structure > Willpower).
74
+ - Counts derive from decision-audit entries **only** (deduped by PR#); the side-effects mirror is display-only and never summed — prevents the round-3 double-counting finding.
75
+
76
+ ---
77
+
78
+ ## 6. External surfaces
79
+
80
+ **Does it change anything visible to other agents, other users, other systems?**
81
+
82
+ - Adds one new CI check named `class-closure` to PRs against `main` (green + optional annotations). Report-only, so it can never block a merge in increment 1.
83
+ - Adds a config key `prGate.classClosure` (off/dry-run default).
84
+ - Adds an author helper `scripts/class-closure-declare.mjs` (stamps a `classClosure` block onto a decision entry).
85
+ - No agent-to-agent, Telegram, or dashboard surface in increment 1 (the attention-item producer is increment 3). No dependence on runtime/conversation state — the lint is a pure function of the repo checkout + the PR diff.
86
+
87
+ ---
88
+
89
+ ## 7. Multi-machine posture (Cross-Machine Coherence)
90
+
91
+ **Replicated / proxied-on-read / machine-local-by-design?**
92
+
93
+ **Replicated — via git (the repo IS the replication medium).** The class registry, the `classClosure` declarations (in committed decision-audit entries), and (later) proposal files are all committed to the canonical repo and therefore identical on every machine. Dedup is **repo-state**, never machine-local JSONL, so two machines cannot double-file or double-count. Increment 1 runs entirely in GitHub Actions (not per-machine), so it holds no machine-local runtime state. No user-facing notice (no one-voice concern), no durable per-machine state to strand on topic transfer, no generated URL. The spec declares this posture explicitly ("Multi-machine posture (declared)").
94
+
95
+ ---
96
+
97
+ ## 8. Rollback cost
98
+
99
+ **If this turns out wrong in production, what's the back-out?**
100
+
101
+ Trivial and safe. The lint is **report-only + config-gated (`prGate.classClosure` defaults off/dry-run) + repo-gated** — a buggy lint cannot block a merge (it exits 0 in dryRun) and is a no-op on any non-maintainer install. Back-out options, cheapest first: (a) leave defaults (already inert); (b) revert `.github/workflows/class-closure-gate.yml`; (c) revert the whole PR — no data migration, no agent-state repair, no runtime behavior to unwind (no runtime routes added in increment 1). The added `src/core` code is pure libraries with no callers in the runtime path yet.
102
+
103
+ ## Follow-up note — no-silent-fallbacks ratchet
104
+
105
+ `DefectClassRegistry.ts` (`readDecisionDeclarations` absent-dir catch) and
106
+ `StandardsEnforcementAuditor.gradeGuardCitation` (unresolvable-path catch) are pure
107
+ libraries over a repo checkout with **no runtime / DegradationReporter surface** — their
108
+ fail-closed catches return the correct answer (empty list / `resolved:false`, which the
109
+ caller surfaces as a `gap` downgrade), not a degraded result. Both are tagged
110
+ `@silent-fallback-ok` so the `no-silent-fallbacks` ratchet holds at baseline 491 rather than
111
+ demanding a runtime degradation-report the library layer cannot emit.