instar 1.3.877 → 1.3.879

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.
Files changed (65) hide show
  1. package/dist/commands/init.d.ts.map +1 -1
  2. package/dist/commands/init.js +8 -0
  3. package/dist/commands/init.js.map +1 -1
  4. package/dist/core/ApprenticeshipMatrixAcceptance.d.ts +124 -0
  5. package/dist/core/ApprenticeshipMatrixAcceptance.d.ts.map +1 -0
  6. package/dist/core/ApprenticeshipMatrixAcceptance.js +240 -0
  7. package/dist/core/ApprenticeshipMatrixAcceptance.js.map +1 -0
  8. package/dist/core/ApprenticeshipProgram.d.ts +25 -3
  9. package/dist/core/ApprenticeshipProgram.d.ts.map +1 -1
  10. package/dist/core/ApprenticeshipProgram.js +72 -25
  11. package/dist/core/ApprenticeshipProgram.js.map +1 -1
  12. package/dist/core/ApprenticeshipStallGate.d.ts +297 -0
  13. package/dist/core/ApprenticeshipStallGate.d.ts.map +1 -0
  14. package/dist/core/ApprenticeshipStallGate.js +935 -0
  15. package/dist/core/ApprenticeshipStallGate.js.map +1 -0
  16. package/dist/core/MessagingToneGate.d.ts +33 -1
  17. package/dist/core/MessagingToneGate.d.ts.map +1 -1
  18. package/dist/core/MessagingToneGate.js +47 -9
  19. package/dist/core/MessagingToneGate.js.map +1 -1
  20. package/dist/core/PostUpdateMigrator.d.ts +8 -0
  21. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  22. package/dist/core/PostUpdateMigrator.js +75 -0
  23. package/dist/core/PostUpdateMigrator.js.map +1 -1
  24. package/dist/core/apprenticeshipStallGate.worker.d.ts +13 -0
  25. package/dist/core/apprenticeshipStallGate.worker.d.ts.map +1 -0
  26. package/dist/core/apprenticeshipStallGate.worker.js +23 -0
  27. package/dist/core/apprenticeshipStallGate.worker.js.map +1 -0
  28. package/dist/core/types.d.ts +13 -0
  29. package/dist/core/types.d.ts.map +1 -1
  30. package/dist/core/types.js.map +1 -1
  31. package/dist/monitoring/SelfUnblockChecklist.d.ts +1 -1
  32. package/dist/monitoring/SelfUnblockChecklist.d.ts.map +1 -1
  33. package/dist/monitoring/SelfUnblockChecklist.js +2 -0
  34. package/dist/monitoring/SelfUnblockChecklist.js.map +1 -1
  35. package/dist/monitoring/SelfUnblockProbeProviders.d.ts +19 -1
  36. package/dist/monitoring/SelfUnblockProbeProviders.d.ts.map +1 -1
  37. package/dist/monitoring/SelfUnblockProbeProviders.js +187 -2
  38. package/dist/monitoring/SelfUnblockProbeProviders.js.map +1 -1
  39. package/dist/monitoring/guardManifest.d.ts.map +1 -1
  40. package/dist/monitoring/guardManifest.js +22 -0
  41. package/dist/monitoring/guardManifest.js.map +1 -1
  42. package/dist/scaffold/templates.d.ts.map +1 -1
  43. package/dist/scaffold/templates.js +4 -2
  44. package/dist/scaffold/templates.js.map +1 -1
  45. package/dist/server/AgentServer.d.ts +1 -0
  46. package/dist/server/AgentServer.d.ts.map +1 -1
  47. package/dist/server/AgentServer.js +22 -0
  48. package/dist/server/AgentServer.js.map +1 -1
  49. package/dist/server/CapabilityIndex.d.ts.map +1 -1
  50. package/dist/server/CapabilityIndex.js +2 -0
  51. package/dist/server/CapabilityIndex.js.map +1 -1
  52. package/dist/server/routes.d.ts +3 -0
  53. package/dist/server/routes.d.ts.map +1 -1
  54. package/dist/server/routes.js +281 -5
  55. package/dist/server/routes.js.map +1 -1
  56. package/package.json +1 -1
  57. package/scripts/stall-matrix-live-check.mjs +309 -0
  58. package/src/data/builtin-manifest.json +64 -64
  59. package/src/scaffold/templates/jobs/instar/stall-matrix-live-check.md +32 -0
  60. package/src/scaffold/templates.ts +4 -2
  61. package/upgrades/1.3.878.md +67 -0
  62. package/upgrades/1.3.879.md +62 -0
  63. package/upgrades/side-effects/correction-derived-hardening.md +188 -0
  64. package/upgrades/side-effects/stall-coverage-matrix-pr-b.md +154 -0
  65. package/upgrades/1.3.877.md +0 -53
@@ -0,0 +1,935 @@
1
+ // safe-git-allow: read-only git audit metadata (rev-parse HEAD + status --porcelain) for the gate decision record — never a mutating git op.
2
+ /**
3
+ * ApprenticeshipStallGate — the RUNTIME-GATE callsite of the framework
4
+ * stall-coverage matrix standard (PR-B).
5
+ *
6
+ * Spec: docs/specs/framework-stall-coverage-matrix.md (§2.3, §3.2 callsite 2,
7
+ * §3.4, Frontloaded Decisions 6, 10, 11, 16, 17, 21).
8
+ *
9
+ * ONE validator, TWO callsites: the hermetic half lives in
10
+ * stallCoverageValidator.ts (CI ratchet, PR-A). THIS module wires that same
11
+ * validator into the apprenticeship lifecycle transitions:
12
+ *
13
+ * - pending→active → PROVISIONAL check (hermetic depth ONLY — schema,
14
+ * complete enumeration, token legality, ref format; §2.3).
15
+ * - active→complete → FULL check: hermetic validation PLUS the non-hermetic
16
+ * checks CI cannot do — closePath LIVENESS against the commitments ledger
17
+ * and guardKey/posture cross-check against the live /guards inventory,
18
+ * both via loopback HTTP (Frontloaded Decision 17), plus the §2.2
19
+ * acceptance-authority requirement (whole-set operator sign-off).
20
+ *
21
+ * Bounded execution (Decision 6 / instar#1069): the full-matrix hermetic
22
+ * validation runs in a worker thread (apprenticeshipStallGate.worker.ts) with
23
+ * a 60s timeout; a timeout fails CLOSED for the transition with a reason that
24
+ * DISTINGUISHES "validator timed out (retry)" from "matrix invalid". The
25
+ * validation is single-read: the worker returns the validator's contentHash,
26
+ * and the decision record additionally carries the checkout HEAD SHA + dirty
27
+ * flag (no validate-then-decide TOCTOU).
28
+ *
29
+ * Refusal hygiene (Decision 16): every refusal reason names class id + rule
30
+ * name only — rejected raw matrix field content is NEVER echoed into 409
31
+ * bodies, audit rows, or reports.
32
+ *
33
+ * Rollout (§3.4 / Decision 11): gated ENTIRELY by
34
+ * `apprenticeship.stallCoverageGate` in `.instar/config.json`, read LIVE at
35
+ * the gate callsite (no restart; transitions are rare and human-paced). The
36
+ * default is inline in code — absence ⇒ {enabled: true, dryRun: true}; a
37
+ * malformed block resolves to the SAFE default (dry-run) with one loud log
38
+ * line. Under dryRun BOTH presence and validity refusals are suppressed and
39
+ * only a would-refuse verdict is logged to the decision audit.
40
+ */
41
+ import { createHash } from 'node:crypto';
42
+ import { execFile } from 'node:child_process';
43
+ import fs from 'node:fs';
44
+ import path from 'node:path';
45
+ import { Worker } from 'node:worker_threads';
46
+ import yaml from 'js-yaml';
47
+ import { validateStallMatrixFile, } from './stallCoverageValidator.js';
48
+ // ── Retroactivity constants (spec §3.4, Frontloaded Decision 10) ─────────────
49
+ /** Minor version from which the matrix is REQUIRED for pre-ship instances. */
50
+ export const STALL_MATRIX_REQUIRED_SINCE = '1.4.0';
51
+ /** ISO date the gate wiring ships (PR-B commit date). */
52
+ export const STALL_MATRIX_SHIP_DATE = '2026-07-18';
53
+ /**
54
+ * Classes with a named production incident in spec §1's table — these can
55
+ * never pass as declared-gap without explicit recorded acceptance (§2.2).
56
+ */
57
+ export const INCIDENT_NAMED_STALL_CLASSES = [
58
+ 'input-not-draining',
59
+ 'wedged-context',
60
+ 'policy-rejection-loop',
61
+ 'mid-turn-interrupt',
62
+ ];
63
+ /** True when `current`'s major.minor sorts below the required-since minor. */
64
+ export function versionBelowRequiredMinor(current, requiredSince = STALL_MATRIX_REQUIRED_SINCE) {
65
+ const parse = (v) => {
66
+ const m = /^(\d+)\.(\d+)/.exec(v.trim());
67
+ return m ? [Number(m[1]), Number(m[2])] : null;
68
+ };
69
+ const cur = parse(current);
70
+ const req = parse(requiredSince);
71
+ // Unparseable version → NOT below (the strict direction: grandfathering is
72
+ // the relief valve, so an unknown version never widens it).
73
+ if (!cur || !req)
74
+ return false;
75
+ return cur[0] < req[0] || (cur[0] === req[0] && cur[1] < req[1]);
76
+ }
77
+ /**
78
+ * The canonical checklist function of (instanceType, createdAt) — Decision 10.
79
+ * Post-ship framework-onboarding instances (both instance types onboard a
80
+ * framework) read the requirement from HERE, never from the per-instance
81
+ * immutable requiredArtifacts flags. Pre-ship instances are grandfathered
82
+ * with a warning while the running version is below the required-since minor.
83
+ */
84
+ export function stallMatrixRequirement(_instanceType, createdAt, opts) {
85
+ const shipDate = opts?.shipDate ?? STALL_MATRIX_SHIP_DATE;
86
+ const preShip = typeof createdAt === 'string' && createdAt.length > 0 && createdAt < shipDate;
87
+ if (!preShip)
88
+ return 'required';
89
+ const current = opts?.currentVersion ?? readOwnPackageVersion();
90
+ return versionBelowRequiredMinor(current, opts?.requiredSince ?? STALL_MATRIX_REQUIRED_SINCE)
91
+ ? 'grandfathered-warning'
92
+ : 'required';
93
+ }
94
+ let cachedOwnVersion = null;
95
+ function readOwnPackageVersion() {
96
+ if (cachedOwnVersion !== null)
97
+ return cachedOwnVersion;
98
+ try {
99
+ const pkg = JSON.parse(fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
100
+ cachedOwnVersion = typeof pkg.version === 'string' ? pkg.version : '0.0.0';
101
+ }
102
+ catch {
103
+ cachedOwnVersion = '0.0.0';
104
+ }
105
+ return cachedOwnVersion;
106
+ }
107
+ /** The inline code default — absence ⇒ enabled, dry-run (§3.4). */
108
+ export const STALL_GATE_DEFAULT = { enabled: true, dryRun: true };
109
+ /**
110
+ * Resolve the `apprenticeship.stallCoverageGate` block. A malformed block
111
+ * resolves to the SAFE default (dry-run) with one loud log line — mirroring
112
+ * MessagingToneGate.getConfig()'s never-throw shape, plus the log the spec
113
+ * requires.
114
+ */
115
+ export function resolveStallGateConfig(raw, log) {
116
+ if (raw === undefined || raw === null)
117
+ return { ...STALL_GATE_DEFAULT };
118
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
119
+ log?.('[stall-gate] malformed apprenticeship.stallCoverageGate block — using safe default {enabled:true, dryRun:true}');
120
+ return { ...STALL_GATE_DEFAULT };
121
+ }
122
+ const block = raw;
123
+ const badField = (v) => v !== undefined && typeof v !== 'boolean';
124
+ if (badField(block.enabled) || badField(block.dryRun)) {
125
+ log?.('[stall-gate] malformed apprenticeship.stallCoverageGate block — using safe default {enabled:true, dryRun:true}');
126
+ return { ...STALL_GATE_DEFAULT };
127
+ }
128
+ return {
129
+ enabled: block.enabled ?? STALL_GATE_DEFAULT.enabled,
130
+ dryRun: block.dryRun ?? STALL_GATE_DEFAULT.dryRun,
131
+ };
132
+ }
133
+ // ── Decision-audit primitives (tamper-evident append) ────────────────────────
134
+ /**
135
+ * The recordDecision primitive: one JSON line appended to the apprenticeship
136
+ * decision audit. Never throws — the audit is observability; a write failure
137
+ * must not block an already-decided transition.
138
+ */
139
+ export function appendApprenticeshipDecisionRow(logPath, row) {
140
+ try {
141
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
142
+ fs.appendFileSync(logPath, JSON.stringify(row) + '\n');
143
+ }
144
+ catch {
145
+ // @silent-fallback-ok — audit write failure must not block the transition.
146
+ }
147
+ }
148
+ /** sha256 over the canonical (sorted-key) serialization of a row sans `integrity`. */
149
+ export function rowIntegrityHash(row) {
150
+ const clone = {};
151
+ for (const k of Object.keys(row).filter((k) => k !== 'integrity').sort())
152
+ clone[k] = row[k];
153
+ return createHash('sha256').update(JSON.stringify(clone)).digest('hex');
154
+ }
155
+ /** Append a row carrying a self-integrity hash (tamper-EVIDENT, not tamper-proof). */
156
+ export function appendTamperEvidentDecisionRow(logPath, row) {
157
+ appendApprenticeshipDecisionRow(logPath, { ...row, integrity: rowIntegrityHash(row) });
158
+ }
159
+ const DECISION_LOG_READ_CAP = 10 * 1024 * 1024;
160
+ /** Read the decision log's parsed rows (bounded; tail-read past the cap). */
161
+ export function readDecisionRows(logPath) {
162
+ let text;
163
+ try {
164
+ const size = fs.statSync(logPath).size;
165
+ if (size > DECISION_LOG_READ_CAP) {
166
+ const fd = fs.openSync(logPath, 'r');
167
+ try {
168
+ const buf = Buffer.alloc(DECISION_LOG_READ_CAP);
169
+ fs.readSync(fd, buf, 0, DECISION_LOG_READ_CAP, size - DECISION_LOG_READ_CAP);
170
+ text = buf.toString('utf8');
171
+ // Drop the (likely partial) first line of a tail read.
172
+ text = text.slice(text.indexOf('\n') + 1);
173
+ }
174
+ finally {
175
+ fs.closeSync(fd);
176
+ }
177
+ }
178
+ else {
179
+ text = fs.readFileSync(logPath, 'utf8');
180
+ }
181
+ }
182
+ catch {
183
+ // @silent-fallback-ok — an absent/unreadable audit log yields no rows; the
184
+ // CALLERS decide the consequence (e.g. provenance-record-missing refusal).
185
+ return [];
186
+ }
187
+ const rows = [];
188
+ for (const line of text.split('\n')) {
189
+ if (!line.trim())
190
+ continue;
191
+ try {
192
+ const parsed = JSON.parse(line);
193
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
194
+ rows.push(parsed);
195
+ }
196
+ catch {
197
+ // @silent-fallback-ok — a corrupt line is skipped; integrity hashes on
198
+ // the rows that matter make targeted tampering evident.
199
+ }
200
+ }
201
+ return rows;
202
+ }
203
+ /**
204
+ * Classify the install at init time from the same signals the dev-agent gate
205
+ * uses (config.developmentAgent) plus presence of the analyzable tree —
206
+ * mirroring DoorwayRegistryReader's no-manifest pattern for source-absence
207
+ * (a pure npm-package install ships no docs/).
208
+ */
209
+ export function deriveInstallProvenance(projectDir, config) {
210
+ const frameworksDir = fs.existsSync(path.join(projectDir, 'docs', 'frameworks'));
211
+ const specFile = fs.existsSync(path.join(projectDir, 'docs', 'specs', 'framework-stall-coverage-matrix.md'));
212
+ const developmentAgent = !!config?.developmentAgent;
213
+ return {
214
+ installClass: developmentAgent || (frameworksDir && specFile) ? 'source-carrying' : 'fleet',
215
+ signals: { developmentAgent, frameworksDir, specFile },
216
+ };
217
+ }
218
+ const PROVENANCE_GATE = 'install-provenance';
219
+ export function hasInstallProvenanceRecord(logPath) {
220
+ // Cheap presence scan (idempotency check for init + the migrator backfill).
221
+ try {
222
+ return fs.readFileSync(logPath, 'utf8').includes(`"gate":"${PROVENANCE_GATE}"`);
223
+ }
224
+ catch {
225
+ // @silent-fallback-ok — no log file = no record; the caller then WRITES one
226
+ // (append path), so this is the presence-scan miss, not a degraded read.
227
+ return false;
228
+ }
229
+ }
230
+ /**
231
+ * Record the install-provenance row ONCE (presence-scan idempotent) — the
232
+ * init-time derivation (§3.2) and the PostUpdateMigrator backfill both funnel
233
+ * through here. Tamper-evident append via the recordDecision primitive.
234
+ */
235
+ export function recordInstallProvenanceIfAbsent(projectDir, stateDir) {
236
+ try {
237
+ const logPath = path.join(stateDir, 'logs', 'apprenticeship-decisions.jsonl');
238
+ if (hasInstallProvenanceRecord(logPath))
239
+ return 'present';
240
+ let config;
241
+ try {
242
+ config = JSON.parse(fs.readFileSync(path.join(stateDir, 'config.json'), 'utf8'));
243
+ }
244
+ catch {
245
+ // @silent-fallback-ok — an unreadable config only loses the developmentAgent
246
+ // signal; the tree-presence signals still classify the install.
247
+ config = undefined;
248
+ }
249
+ const prov = deriveInstallProvenance(projectDir, config);
250
+ appendTamperEvidentDecisionRow(logPath, {
251
+ ts: new Date().toISOString(),
252
+ gate: PROVENANCE_GATE,
253
+ installClass: prov.installClass,
254
+ signals: prov.signals,
255
+ });
256
+ return 'recorded';
257
+ }
258
+ catch {
259
+ return 'error';
260
+ }
261
+ }
262
+ /** Read the LATEST install-provenance row; verify its integrity hash. */
263
+ export function readInstallProvenance(logPath) {
264
+ const rows = readDecisionRows(logPath).filter((r) => r.gate === PROVENANCE_GATE);
265
+ if (rows.length === 0)
266
+ return { ok: false, error: 'missing' };
267
+ const latest = rows[rows.length - 1];
268
+ const integrity = latest.integrity;
269
+ if (typeof integrity !== 'string' || integrity !== rowIntegrityHash(latest)) {
270
+ return { ok: false, error: 'invalid' };
271
+ }
272
+ const cls = latest.installClass;
273
+ if (cls !== 'source-carrying' && cls !== 'fleet')
274
+ return { ok: false, error: 'invalid' };
275
+ return { ok: true, installClass: cls };
276
+ }
277
+ // ── Hermetic validation unit (shared by the worker thread + in-process fallback) ──
278
+ const FRAMEWORK_RE = /^[a-z0-9-]+$/;
279
+ const MATRIX_SUFFIX = '-stall-coverage.md';
280
+ const RAW_FIELD_CAP = 4096;
281
+ /**
282
+ * The pure validation unit: derives the matrix path EXCLUSIVELY from the
283
+ * registry's charset-clamped framework field, realpath-jailed to
284
+ * docs/frameworks/ (§3.2), runs the REAL hermetic validator (PR-A), and
285
+ * re-parses the front-matter for the raw row fields the non-hermetic checks
286
+ * need. Runs inside the worker thread; also callable in-process (the fallback
287
+ * when a worker cannot start, e.g. under vitest where the compiled worker
288
+ * file does not exist — behavior parity, never a silent skip).
289
+ */
290
+ export function runStallGateValidation(input) {
291
+ if (!FRAMEWORK_RE.test(input.framework)) {
292
+ // A non-clamped framework can never name a matrix file (Decision 6 jail).
293
+ return { fileMissing: true };
294
+ }
295
+ const rel = path.join('docs/frameworks', `${input.framework}${MATRIX_SUFFIX}`);
296
+ const abs = path.resolve(input.repoRoot, rel);
297
+ let real;
298
+ try {
299
+ real = fs.realpathSync(abs);
300
+ }
301
+ catch {
302
+ return { fileMissing: true };
303
+ }
304
+ const jail = path.resolve(input.repoRoot, 'docs/frameworks');
305
+ let jailReal;
306
+ try {
307
+ jailReal = fs.realpathSync(jail);
308
+ }
309
+ catch {
310
+ return { fileMissing: true };
311
+ }
312
+ if (!real.startsWith(jailReal + path.sep))
313
+ return { fileMissing: true };
314
+ const result = validateStallMatrixFile({
315
+ repoRoot: input.repoRoot,
316
+ filePath: rel,
317
+ now: new Date(input.nowIso),
318
+ });
319
+ // Re-parse authored rows for the non-hermetic checks (bounded: the validator
320
+ // already capped the file at 256KB; fields are byte-capped here).
321
+ let rawRows;
322
+ try {
323
+ const text = fs.readFileSync(real, 'utf8');
324
+ const fm = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text);
325
+ if (fm) {
326
+ const doc = yaml.load(fm[1], { schema: yaml.JSON_SCHEMA });
327
+ const rows = doc && Array.isArray(doc['stall-coverage']) ? doc['stall-coverage'] : [];
328
+ rawRows = rows.slice(0, 64).map((r) => sanitizeRawRow(r));
329
+ }
330
+ }
331
+ catch {
332
+ // @silent-fallback-ok — rawRows feed the NON-hermetic checks; their absence
333
+ // surfaces there as named refusals, never as a silent pass.
334
+ rawRows = undefined;
335
+ }
336
+ return { fileMissing: false, result, rawRows };
337
+ }
338
+ const RAW_ROW_FIELDS = [
339
+ 'class', 'status', 'reason', 'detector', 'recovery', 'guardKey', 'posture',
340
+ 'evidence', 'issueRef', 'closePath', 'seededAt', 'acceptanceRef',
341
+ 'revalidateOn', 'liveness-surface',
342
+ ];
343
+ function sanitizeRawRow(row) {
344
+ const out = {};
345
+ if (row && typeof row === 'object' && !Array.isArray(row)) {
346
+ const r = row;
347
+ for (const f of RAW_ROW_FIELDS) {
348
+ const v = r[f];
349
+ if (typeof v === 'string')
350
+ out[f] = v.slice(0, RAW_FIELD_CAP);
351
+ }
352
+ if (Array.isArray(r.matchedClasses)) {
353
+ out.matchedClasses = r.matchedClasses
354
+ .filter((m) => typeof m === 'string')
355
+ .slice(0, 16)
356
+ .map((m) => m.slice(0, 128));
357
+ }
358
+ }
359
+ return out;
360
+ }
361
+ const LOOPBACK_FETCH_TIMEOUT_MS = 10_000;
362
+ export class ApprenticeshipStallGate {
363
+ d;
364
+ decisionLogPath;
365
+ constructor(deps) {
366
+ this.d = deps;
367
+ this.decisionLogPath =
368
+ deps.decisionLogPath ?? path.join(deps.stateDir, 'logs', 'apprenticeship-decisions.jsonl');
369
+ }
370
+ /** Live config read at the callsite — no restart (§3.4). */
371
+ getConfig() {
372
+ let raw;
373
+ try {
374
+ const parsed = JSON.parse(fs.readFileSync(path.join(this.d.stateDir, 'config.json'), 'utf8'));
375
+ raw = parsed.apprenticeship?.stallCoverageGate;
376
+ }
377
+ catch {
378
+ // @silent-fallback-ok — absent/unreadable config file ⇒ the inline code
379
+ // default {enabled:true, dryRun:true} (spec §3.4: absence = default).
380
+ raw = undefined;
381
+ }
382
+ return resolveStallGateConfig(raw, this.d.log ?? ((m) => console.warn(m)));
383
+ }
384
+ /**
385
+ * Evaluate the matrix gate for a lifecycle transition. NEVER throws — every
386
+ * failure mode maps to a named verdict (fail closed under enforce, logged
387
+ * would-refuse under dryRun).
388
+ */
389
+ async evaluateForTransition(instance, phase) {
390
+ const config = this.getConfig();
391
+ const requirement = stallMatrixRequirement(instance.instanceType, instance.createdAt, {
392
+ currentVersion: this.d.getCurrentVersion?.(),
393
+ });
394
+ const report = {
395
+ phase,
396
+ framework: instance.framework,
397
+ requirement,
398
+ verdict: 'valid',
399
+ issues: [],
400
+ flaggedRows: [],
401
+ guardPairs: [],
402
+ enumerated: { covered: [], coveredDark: [], declaredGaps: [], notApplicable: [] },
403
+ acceptance: { wholeSetValid: false, checkedRefs: 0, invalidRefs: 0 },
404
+ warnings: [],
405
+ };
406
+ if (!config.enabled) {
407
+ report.verdict = 'skipped-disabled';
408
+ return { allow: true, reason: 'stall-coverage gate disabled by config', dryRunSuppressed: false, report };
409
+ }
410
+ if (requirement === 'grandfathered-warning') {
411
+ // Pre-ship instance below the required-since minor: WARNING row only
412
+ // (Decision 10) — on the provisional arm this is a full skip (the
413
+ // provisional requirement exists only for post-ship instances, §2.3).
414
+ report.verdict = 'grandfathered-warning';
415
+ report.warnings.push(`stall-coverage matrix not yet required for this pre-${STALL_MATRIX_SHIP_DATE} instance — REQUIRED from v${STALL_MATRIX_REQUIRED_SINCE}`);
416
+ this.recordGateDecision(instance, config, report, true);
417
+ return { allow: true, reason: 'grandfathered (pre-ship instance): matrix warning only', dryRunSuppressed: false, report };
418
+ }
419
+ const refusal = await this.evaluateRequired(instance, phase, report);
420
+ if (refusal === null) {
421
+ this.recordGateDecision(instance, config, report, true);
422
+ return { allow: true, reason: `stall-coverage matrix ${report.verdict}`, dryRunSuppressed: false, report };
423
+ }
424
+ if (config.dryRun) {
425
+ // BOTH presence and validity refusals are suppressed under dryRun —
426
+ // only the would-refuse verdict is logged (§2.3/§3.4).
427
+ const wouldRefuseFor = report.verdict;
428
+ report.verdict = 'would-refuse';
429
+ report.warnings.push(`dry-run: would refuse — ${refusal} (${wouldRefuseFor})`);
430
+ this.recordGateDecision(instance, config, report, true, wouldRefuseFor);
431
+ return { allow: true, reason: `dry-run: would refuse — ${refusal}`, dryRunSuppressed: true, report };
432
+ }
433
+ this.recordGateDecision(instance, config, report, false);
434
+ return { allow: false, reason: refusal, dryRunSuppressed: false, report };
435
+ }
436
+ /** Returns null when the gate passes, else the named refusal reason. */
437
+ async evaluateRequired(instance, phase, report) {
438
+ // ── Install provenance decides the degraded rung (§3.2, Decision 21) ──
439
+ const prov = readInstallProvenance(this.decisionLogPath);
440
+ if (!prov.ok) {
441
+ report.verdict = prov.error === 'missing' ? 'provenance-record-missing' : 'provenance-record-invalid';
442
+ return `${report.verdict} — re-run update/migration`;
443
+ }
444
+ report.installClass = prov.installClass;
445
+ if (prov.installClass === 'fleet') {
446
+ // No docs/ ships on a fleet npm install: the verdict is recorded and the
447
+ // authenticated overseer-acceptance path carries the sign-off — NEVER a
448
+ // presence-check refusal for a reason unrelated to matrix quality.
449
+ report.verdict = 'matrix-unverifiable-no-source';
450
+ const degradedHash = degradedAcceptanceHash(instance.id, instance.framework);
451
+ report.contentHash = degradedHash;
452
+ if (phase === 'provisional')
453
+ return null; // nothing verifiable to gate provisionally
454
+ if (this.d.acceptance?.hasWholeSetAcceptance(instance.id, degradedHash)) {
455
+ report.acceptance.wholeSetValid = true;
456
+ return null;
457
+ }
458
+ return 'acceptance-missing — matrix-unverifiable-no-source verdict requires recorded overseer acceptance';
459
+ }
460
+ // ── source-carrying: the tree must be analyzable (never degrade-to-acceptance) ──
461
+ const frameworksDir = path.join(this.d.projectDir, 'docs', 'frameworks');
462
+ if (!fs.existsSync(frameworksDir)) {
463
+ report.verdict = 'source-tree-unanalyzable';
464
+ return 'source-tree-unanalyzable — install provenance is source-carrying but docs/frameworks is missing';
465
+ }
466
+ // ── Bounded hermetic validation (worker thread, Decision 6) ──
467
+ const run = this.d.runValidation ?? ((input) => this.runValidationBounded(input));
468
+ const nowIso = (this.d.now?.() ?? new Date()).toISOString();
469
+ const outcome = await run({ repoRoot: this.d.projectDir, framework: instance.framework, nowIso });
470
+ if (!outcome.ok) {
471
+ if (outcome.timedOut) {
472
+ report.verdict = 'validator-timeout';
473
+ return 'validator-timeout (retry) — validation did not finish; this is NOT a matrix-invalid verdict';
474
+ }
475
+ report.verdict = 'validator-error';
476
+ return 'validator-error (retry) — validation could not run; this is NOT a matrix-invalid verdict';
477
+ }
478
+ const { fileMissing, result, rawRows } = outcome.output;
479
+ if (fileMissing || !result) {
480
+ report.verdict = 'invalid';
481
+ report.issues.push({ rule: 'matrix-file-missing', message: `matrix file missing for framework '${instance.framework}'` });
482
+ return `matrix invalid: rule 'matrix-file-missing' (framework '${instance.framework}')`;
483
+ }
484
+ report.contentHash = result.contentHash;
485
+ // Checkout HEAD SHA + dirty flag ride the decision record (§3.2 audit).
486
+ const git = await this.gitState();
487
+ report.headSha = git.headSha;
488
+ report.dirty = git.dirty;
489
+ // ── Hermetic issues, minus recorded per-instance overrides (§3.4 relief) ──
490
+ const rowByClass = new Map();
491
+ for (const r of rawRows ?? []) {
492
+ if (typeof r.class === 'string' && !rowByClass.has(r.class))
493
+ rowByClass.set(r.class, r);
494
+ }
495
+ const unexcused = [];
496
+ for (const issue of result.issues) {
497
+ if (issue.classId && this.d.acceptance) {
498
+ const row = rowByClass.get(issue.classId);
499
+ const rowId = `${instance.framework}:${issue.classId}`;
500
+ const rowHash = row ? canonicalRowHash(row) : '';
501
+ if (row && this.d.acceptance.overrideExcuses(instance.id, issue.rule, rowId, rowHash)) {
502
+ report.warnings.push(`rule '${issue.rule}' on class '${issue.classId}' excused by recorded override`);
503
+ continue;
504
+ }
505
+ }
506
+ unexcused.push(issue);
507
+ }
508
+ if (unexcused.length > 0) {
509
+ report.verdict = 'invalid';
510
+ report.issues.push(...unexcused);
511
+ const named = unexcused
512
+ .slice(0, 8)
513
+ .map((i) => (i.classId ? `class '${i.classId}': rule '${i.rule}'` : `rule '${i.rule}'`))
514
+ .join('; ');
515
+ const more = unexcused.length > 8 ? ` (+${unexcused.length - 8} more)` : '';
516
+ return `matrix invalid: ${named}${more}`;
517
+ }
518
+ // Enumerations for recorded acceptance (all statuses — §3.2).
519
+ for (const r of rawRows ?? []) {
520
+ const classId = typeof r.class === 'string' ? r.class : '';
521
+ if (!classId)
522
+ continue;
523
+ switch (r.status) {
524
+ case 'covered':
525
+ report.enumerated.covered.push({ classId, evidence: r.evidence });
526
+ break;
527
+ case 'covered-dark':
528
+ report.enumerated.coveredDark.push(classId);
529
+ break;
530
+ case 'declared-gap':
531
+ report.enumerated.declaredGaps.push(classId);
532
+ break;
533
+ case 'not-applicable':
534
+ report.enumerated.notApplicable.push(classId);
535
+ break;
536
+ }
537
+ }
538
+ if (phase === 'provisional') {
539
+ // §2.3: provisional depth is the hermetic checks ONLY — never the
540
+ // non-hermetic gate checks (liveness/guards) and no acceptance yet.
541
+ report.verdict = 'valid';
542
+ return null;
543
+ }
544
+ // ── Non-hermetic checks (full gate only, Decision 17: loopback HTTP) ──
545
+ const ledger = await this.nonHermeticChecks(instance, rawRows ?? [], report);
546
+ if (ledger !== null)
547
+ return ledger;
548
+ // ── Acceptance authority (§2.2): whole-set operator sign-off required ──
549
+ if (this.d.acceptance?.hasWholeSetAcceptance(instance.id, result.contentHash)) {
550
+ report.acceptance.wholeSetValid = true;
551
+ }
552
+ else {
553
+ report.verdict = 'acceptance-missing';
554
+ return 'acceptance-missing — active→complete requires a recorded whole-set operator acceptance bound to the current matrix content hash';
555
+ }
556
+ // Row-scoped acceptanceRef authenticity (accept-then-edit voids — §2.2).
557
+ const currentRowsById = new Map();
558
+ for (const r of rawRows ?? []) {
559
+ if (typeof r.class === 'string')
560
+ currentRowsById.set(`${instance.framework}:${r.class}`, r);
561
+ }
562
+ const resolveRowSetHash = (rowIds) => {
563
+ const entries = [];
564
+ for (const id of rowIds) {
565
+ const row = currentRowsById.get(id);
566
+ if (!row)
567
+ return null; // an accepted row no longer exists ⇒ invalid
568
+ entries.push({ rowId: id, row });
569
+ }
570
+ return canonicalRowSetHash(entries);
571
+ };
572
+ for (const r of rawRows ?? []) {
573
+ const ref = r.acceptanceRef;
574
+ const classId = typeof r.class === 'string' ? r.class : '';
575
+ if (typeof ref !== 'string' || !ref || !classId)
576
+ continue;
577
+ report.acceptance.checkedRefs++;
578
+ const ok = this.d.acceptance?.rowAcceptanceValid(ref, `${instance.framework}:${classId}`, resolveRowSetHash) ?? false;
579
+ if (!ok) {
580
+ report.acceptance.invalidRefs++;
581
+ report.issues.push({ classId, rule: 'acceptance-ref-invalid', message: `class '${classId}': rule 'acceptance-ref-invalid'` });
582
+ }
583
+ }
584
+ if (report.acceptance.invalidRefs > 0) {
585
+ report.verdict = 'invalid';
586
+ return `matrix invalid: ${report.acceptance.invalidRefs} row acceptanceRef(s) failed authenticity (rule 'acceptance-ref-invalid')`;
587
+ }
588
+ report.verdict = 'valid';
589
+ return null;
590
+ }
591
+ /** closePath liveness + guardKey/posture cross-check. Null = pass. */
592
+ async nonHermeticChecks(instance, rawRows, report) {
593
+ if (!this.d.loopback || !Number.isFinite(this.d.loopback.port) || this.d.loopback.port <= 0) {
594
+ report.verdict = 'ledger-unreachable';
595
+ return 'ledger-unreachable (retry) — no loopback server configured for liveness/posture checks; this is NOT a matrix-invalid verdict';
596
+ }
597
+ // ── /guards posture cross-check via each row's guardKey join (§3.2) ──
598
+ const guardsRes = await this.loopbackJson('GET', '/guards');
599
+ if (!guardsRes.ok) {
600
+ report.verdict = 'ledger-unreachable';
601
+ return 'ledger-unreachable (retry) — guards inventory unreachable; this is NOT a matrix-invalid verdict';
602
+ }
603
+ const guardRows = Array.isArray(guardsRes.body.guards)
604
+ ? (guardsRes.body.guards)
605
+ : [];
606
+ const effectiveByKey = new Map();
607
+ for (const g of guardRows) {
608
+ if (typeof g.key === 'string' && typeof g.effective === 'string')
609
+ effectiveByKey.set(g.key, g.effective);
610
+ }
611
+ const LIVE_STATES = new Set(['on-confirmed', 'on-unverified', 'on-stale']);
612
+ const postureIssues = [];
613
+ for (const r of rawRows) {
614
+ const classId = typeof r.class === 'string' ? r.class : '';
615
+ const guardKey = typeof r.guardKey === 'string' ? r.guardKey : '';
616
+ if (!classId || !guardKey || (r.status !== 'covered' && r.status !== 'covered-dark'))
617
+ continue;
618
+ if (guardKey.startsWith('exempt:')) {
619
+ // Manifest-exempt component: the posture check is vacuous-with-reason
620
+ // (already format-verified hermetically).
621
+ report.guardPairs.push({ classId, guardKey, detector: r.detector, effective: null, check: 'vacuous-with-reason' });
622
+ continue;
623
+ }
624
+ const effective = effectiveByKey.get(guardKey) ?? null;
625
+ if (effective === null || effective === 'missing') {
626
+ report.guardPairs.push({ classId, guardKey, detector: r.detector, effective, check: 'missing' });
627
+ postureIssues.push(`class '${classId}': rule 'guard-missing-from-inventory'`);
628
+ report.issues.push({ classId, rule: 'guard-missing-from-inventory', message: `class '${classId}': rule 'guard-missing-from-inventory'` });
629
+ continue;
630
+ }
631
+ if (r.status === 'covered' && !LIVE_STATES.has(effective)) {
632
+ // "A Dark Feature Guards Nothing": posture:live contradicted by /guards.
633
+ report.guardPairs.push({ classId, guardKey, detector: r.detector, effective, check: 'contradicts' });
634
+ postureIssues.push(`class '${classId}': rule 'posture-contradicts-inventory'`);
635
+ report.issues.push({ classId, rule: 'posture-contradicts-inventory', message: `class '${classId}': rule 'posture-contradicts-inventory'` });
636
+ continue;
637
+ }
638
+ if (r.status === 'covered-dark' && LIVE_STATES.has(effective)) {
639
+ // Live guard on a covered-dark row: stale under-claim — warn, not refuse
640
+ // (§5 names only the missing-guard side as a failure).
641
+ report.warnings.push(`class '${classId}': covered-dark row's guard classifies LIVE — row may be stale (flip to covered?)`);
642
+ }
643
+ report.guardPairs.push({ classId, guardKey, detector: r.detector, effective, check: 'ok' });
644
+ }
645
+ // ── closePath liveness (declared-gap + covered-dark rows; §2.2) ──
646
+ let evolutionActions;
647
+ for (const r of rawRows) {
648
+ const classId = typeof r.class === 'string' ? r.class : '';
649
+ const closePath = typeof r.closePath === 'string' ? r.closePath : '';
650
+ if (!classId || !closePath || (r.status !== 'declared-gap' && r.status !== 'covered-dark'))
651
+ continue;
652
+ if (closePath === 'pending-mint')
653
+ continue; // the live-check job owns the mint (§2.1)
654
+ if (/^CMT-/i.test(closePath)) {
655
+ const res = await this.loopbackJson('GET', `/commitments/${encodeURIComponent(closePath)}`);
656
+ if (!res.ok && res.status !== 404) {
657
+ // Transport failure OR any non-404 HTTP error (500/401/…): the
658
+ // ledger could not answer — retryable, NEVER conflated with a
659
+ // dead ref / matrix invalidity.
660
+ report.verdict = 'ledger-unreachable';
661
+ return 'ledger-unreachable (retry) — commitments ledger unreachable; this is NOT a matrix-invalid verdict';
662
+ }
663
+ if (res.status === 404 || !commitmentIsOpen(res.body)) {
664
+ // 404 or terminal status = DEAD ref (a delivered commitment is a
665
+ // closed anchor — no anchor).
666
+ report.flaggedRows.push({ classId, rule: 'closepath-dead-ref', ref: closePath });
667
+ }
668
+ }
669
+ else if (/^ACT-/i.test(closePath)) {
670
+ if (evolutionActions === undefined) {
671
+ const res = await this.loopbackJson('GET', '/evolution/actions');
672
+ if (!res.ok) {
673
+ // The dead determination for ACT refs comes from a SUCCESSFUL list
674
+ // lacking the id — any failure (transport or HTTP) is retryable.
675
+ report.verdict = 'ledger-unreachable';
676
+ return 'ledger-unreachable (retry) — evolution-actions ledger unreachable; this is NOT a matrix-invalid verdict';
677
+ }
678
+ evolutionActions = extractActions(res.body);
679
+ }
680
+ const open = (evolutionActions ?? []).some((a) => a.id === closePath && (a.status === 'pending' || a.status === 'in_progress'));
681
+ if (!open)
682
+ report.flaggedRows.push({ classId, rule: 'closepath-dead-ref', ref: closePath });
683
+ }
684
+ else {
685
+ // A well-formed but unresolvable ref family cannot be proven live.
686
+ report.flaggedRows.push({ classId, rule: 'closepath-unresolvable-ref', ref: closePath });
687
+ }
688
+ }
689
+ if (report.flaggedRows.length > 0) {
690
+ // ONE aggregated attention item for ALL flagged rows (never one per row).
691
+ void this.postAggregatedAttention(instance, report.flaggedRows, report.contentHash ?? '');
692
+ report.verdict = 'invalid';
693
+ const named = report.flaggedRows
694
+ .slice(0, 8)
695
+ .map((f) => `class '${f.classId}': rule '${f.rule}'`)
696
+ .join('; ');
697
+ report.issues.push(...report.flaggedRows.map((f) => ({ classId: f.classId, rule: f.rule, message: `class '${f.classId}': rule '${f.rule}'` })));
698
+ return `matrix invalid: ${named}${report.flaggedRows.length > 8 ? ` (+${report.flaggedRows.length - 8} more)` : ''}`;
699
+ }
700
+ if (postureIssues.length > 0) {
701
+ report.verdict = 'invalid';
702
+ return `matrix invalid: ${postureIssues.slice(0, 8).join('; ')}${postureIssues.length > 8 ? ` (+${postureIssues.length - 8} more)` : ''}`;
703
+ }
704
+ return null;
705
+ }
706
+ // ── Bounded worker execution (Decision 6; instar#1069) ──
707
+ runValidationBounded(input) {
708
+ const timeoutMs = this.d.validatorTimeoutMs ?? 60_000;
709
+ return new Promise((resolve) => {
710
+ let worker;
711
+ try {
712
+ worker = new Worker(new URL('./apprenticeshipStallGate.worker.js', import.meta.url), {
713
+ workerData: input,
714
+ });
715
+ }
716
+ catch (err) {
717
+ // Worker start failure (e.g. uncompiled tree under vitest): run the
718
+ // SAME exported validation unit in-process — behavior parity, never a
719
+ // silent skip. The validation is bounded (single matrix, capped reads).
720
+ resolve(this.runInProcess(input, err));
721
+ return;
722
+ }
723
+ let settled = false;
724
+ const done = (r) => {
725
+ if (settled)
726
+ return;
727
+ settled = true;
728
+ clearTimeout(timer);
729
+ worker.terminate().catch(() => { });
730
+ resolve(r);
731
+ };
732
+ // Timeout fails CLOSED with the retryable, timeout-vs-invalid-distinct reason.
733
+ const timer = setTimeout(() => done({ ok: false, timedOut: true }), timeoutMs);
734
+ worker.once('message', (msg) => {
735
+ if (msg && msg.ok === true && msg.output)
736
+ done({ ok: true, output: msg.output });
737
+ else
738
+ done({ ok: false, error: typeof msg?.error === 'string' ? msg.error : 'worker-malformed-result' });
739
+ });
740
+ worker.once('error', (err) => {
741
+ // A worker that errors at MODULE LOAD (missing compiled file) falls
742
+ // back in-process; a mid-run error is a real validation error.
743
+ if (/Cannot find module|ERR_MODULE_NOT_FOUND/i.test(err.message)) {
744
+ if (!settled) {
745
+ settled = true;
746
+ clearTimeout(timer);
747
+ worker.terminate().catch(() => { });
748
+ resolve(this.runInProcess(input, err));
749
+ }
750
+ return;
751
+ }
752
+ done({ ok: false, error: err.message });
753
+ });
754
+ worker.once('exit', () => {
755
+ if (!settled)
756
+ done({ ok: false, error: 'worker-exited-before-result' });
757
+ });
758
+ });
759
+ }
760
+ runInProcess(input, cause) {
761
+ this.d.log?.(`[stall-gate] worker unavailable (${cause instanceof Error ? cause.message : String(cause)}) — running validation in-process`);
762
+ try {
763
+ return { ok: true, output: runStallGateValidation(input) };
764
+ }
765
+ catch (err) {
766
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
767
+ }
768
+ }
769
+ // ── Git checkout audit fields (§3.2 single-read record) ──
770
+ gitState() {
771
+ const exec = (args) => new Promise((resolve) => {
772
+ try {
773
+ execFile('git', args, { cwd: this.d.projectDir, timeout: 5_000, maxBuffer: 1024 * 1024 }, (err, stdout) => {
774
+ resolve(err ? null : stdout.toString());
775
+ });
776
+ }
777
+ catch {
778
+ resolve(null);
779
+ }
780
+ });
781
+ return (async () => {
782
+ const head = await exec(['rev-parse', 'HEAD']);
783
+ if (head === null)
784
+ return { headSha: null, dirty: null };
785
+ const status = await exec(['status', '--porcelain']);
786
+ return {
787
+ headSha: head.trim().slice(0, 40) || null,
788
+ dirty: status === null ? null : status.trim().length > 0,
789
+ };
790
+ })();
791
+ }
792
+ // ── Loopback HTTP (Decision 17) ──
793
+ async loopbackJson(method, route, body) {
794
+ const f = this.d.fetchImpl ?? fetch;
795
+ const lb = this.d.loopback;
796
+ try {
797
+ const res = await f(`http://127.0.0.1:${lb.port}${route}`, {
798
+ method,
799
+ headers: {
800
+ ...(lb.authToken ? { Authorization: `Bearer ${lb.authToken}` } : {}),
801
+ ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
802
+ },
803
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
804
+ signal: AbortSignal.timeout(LOOPBACK_FETCH_TIMEOUT_MS),
805
+ });
806
+ let parsed = null;
807
+ try {
808
+ parsed = await res.json();
809
+ }
810
+ catch {
811
+ // @silent-fallback-ok — a non-JSON body is handled by the caller's
812
+ // predicate (treated as not-open / not-ok), never silently succeeded.
813
+ parsed = null;
814
+ }
815
+ return { ok: res.ok, status: res.status, body: parsed };
816
+ }
817
+ catch {
818
+ // status 0 = transport failure ⇒ ledger-unreachable (retry), never
819
+ // conflated with matrix invalidity (Decision 17).
820
+ return { ok: false, status: 0, body: null };
821
+ }
822
+ }
823
+ async postAggregatedAttention(instance, flagged, contentHash) {
824
+ try {
825
+ const lines = flagged
826
+ .slice(0, 20)
827
+ .map((f) => `- ${instance.framework} class '${f.classId}' — ${f.rule}${f.ref ? ` (${f.ref})` : ''}`);
828
+ await this.loopbackJson('POST', '/attention', {
829
+ // Dedup: createAttentionItem is id-keyed (existing id ⇒ returned, not
830
+ // re-posted) — ONE item per (instance, matrix content); a retry loop
831
+ // over the same matrix state can never repeat it, a content change
832
+ // mints a fresh id.
833
+ id: `stall-matrix-${createHash('sha256').update(`${instance.id}:${contentHash}`).digest('hex').slice(0, 24)}`,
834
+ title: `Stall-coverage matrix: ${flagged.length} dead/unresolvable closePath ref(s) for ${instance.framework}`,
835
+ body: `The apprenticeship stall-coverage gate flagged rows whose closePath no longer resolves to an OPEN commitment/action (a closed anchor is no anchor — spec §2.2):\n` +
836
+ lines.join('\n') +
837
+ (flagged.length > 20 ? `\n…and ${flagged.length - 20} more` : ''),
838
+ priority: 'medium',
839
+ source: 'stall-coverage-gate',
840
+ });
841
+ }
842
+ catch {
843
+ // @silent-fallback-ok — attention delivery is best-effort observability.
844
+ }
845
+ }
846
+ // ── Decision audit (the extended row shape — additive, §3.2) ──
847
+ recordGateDecision(instance, config, report, allow, wouldRefuseFor) {
848
+ appendApprenticeshipDecisionRow(this.decisionLogPath, {
849
+ ts: (this.d.now?.() ?? new Date()).toISOString(),
850
+ gate: 'stall-matrix',
851
+ instanceId: instance.id,
852
+ phase: report.phase,
853
+ framework: instance.framework,
854
+ allow,
855
+ verdict: report.verdict,
856
+ ...(wouldRefuseFor ? { wouldRefuseFor } : {}),
857
+ requirement: report.requirement,
858
+ dryRun: config.dryRun,
859
+ ...(report.installClass ? { installClass: report.installClass } : {}),
860
+ ...(report.contentHash ? { contentHash: report.contentHash } : {}),
861
+ ...(report.headSha !== undefined ? { headSha: report.headSha } : {}),
862
+ ...(report.dirty !== undefined ? { dirty: report.dirty } : {}),
863
+ // Rule names + class ids ONLY — never rejected raw content (Decision 16).
864
+ ...(report.issues.length ? { rules: report.issues.slice(0, 32).map((i) => (i.classId ? `${i.classId}:${i.rule}` : i.rule)) } : {}),
865
+ });
866
+ }
867
+ }
868
+ // ── Shared hashing helpers (acceptance binding granularity — Decision 20) ────
869
+ /**
870
+ * Canonical serialization of one authored matrix row: known fields only,
871
+ * sorted keys, stable JSON. Row-scoped acceptances/overrides hash EXACTLY
872
+ * this — a codemod adding UNRELATED rows does not void them; ANY change to
873
+ * the row itself does.
874
+ */
875
+ export function canonicalRowSerialization(row) {
876
+ const r = row;
877
+ const out = {};
878
+ for (const k of Object.keys(r).sort()) {
879
+ // `acceptanceRef` is the pointer TO an acceptance, not accepted content —
880
+ // excluding it breaks the chicken-and-egg (mint → bind → write the ref
881
+ // into the row) without weakening the binding: every substantive field
882
+ // still voids on change.
883
+ if (k === 'acceptanceRef')
884
+ continue;
885
+ const v = r[k];
886
+ if (typeof v === 'string' || (Array.isArray(v) && v.every((x) => typeof x === 'string')))
887
+ out[k] = v;
888
+ }
889
+ return JSON.stringify(out);
890
+ }
891
+ export function canonicalRowHash(row) {
892
+ return createHash('sha256').update(canonicalRowSerialization(row)).digest('hex');
893
+ }
894
+ /**
895
+ * Joint hash over a SET of accepted rows (rows-scope challenges, Decision 20):
896
+ * sorted by rowId, each entry [rowId, canonical serialization]. Adding
897
+ * UNRELATED rows to the matrix leaves this hash untouched; ANY change to an
898
+ * accepted row changes it.
899
+ */
900
+ export function canonicalRowSetHash(entries) {
901
+ const sorted = [...entries].sort((a, b) => (a.rowId < b.rowId ? -1 : a.rowId > b.rowId ? 1 : 0));
902
+ return createHash('sha256')
903
+ .update(JSON.stringify(sorted.map((e) => [e.rowId, canonicalRowSerialization(e.row)])))
904
+ .digest('hex');
905
+ }
906
+ /** The bound content for a fleet-install (no-source) whole-set acceptance. */
907
+ export function degradedAcceptanceHash(instanceId, framework) {
908
+ return createHash('sha256')
909
+ .update(`matrix-unverifiable-no-source:${instanceId}:${framework}`)
910
+ .digest('hex');
911
+ }
912
+ // ── Loopback response predicates ─────────────────────────────────────────────
913
+ /** OPEN iff status ∈ {pending, verified, violated} and unexpired
914
+ * (CommitmentTracker.getActive semantics). */
915
+ function commitmentIsOpen(body) {
916
+ if (!body || typeof body !== 'object')
917
+ return false;
918
+ const c = body;
919
+ if (c.status !== 'pending' && c.status !== 'verified' && c.status !== 'violated')
920
+ return false;
921
+ if (typeof c.expiresAt === 'string' && c.expiresAt < new Date().toISOString())
922
+ return false;
923
+ return true;
924
+ }
925
+ function extractActions(body) {
926
+ if (Array.isArray(body))
927
+ return body;
928
+ if (body && typeof body === 'object') {
929
+ const b = body;
930
+ if (Array.isArray(b.actions))
931
+ return b.actions;
932
+ }
933
+ return [];
934
+ }
935
+ //# sourceMappingURL=ApprenticeshipStallGate.js.map