sparda-mcp 0.70.1 → 0.71.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.
@@ -1 +1 @@
1
- {"v":"imm1","proven":false,"surfaceOnly":true,"coverage":null,"blindHigh":0,"routes":[{"behaviorHash":"bh1_13969839d8a18d54aa0342618e56db6a","pol":121,"exposed":[]},{"behaviorHash":"bh1_2daf8a2b1b0ed393d443991a2b0700f6","pol":121,"exposed":[]},{"behaviorHash":"bh1_58fbf5ac38b6d71abc7204921de6b957","pol":121,"exposed":[]},{"behaviorHash":"bh1_58fbf5ac38b6d71abc7204921de6b957","pol":121,"exposed":[]},{"behaviorHash":"bh1_cc6788289c612af85b8d215b5bdf9b28","pol":121,"exposed":[]}],"posture":{"auth":{"protected":0,"exposed":0,"na":5},"atomicity":{"protected":0,"exposed":0,"na":5},"reversibility":{"protected":0,"exposed":0,"na":5},"validation":{"protected":0,"exposed":0,"na":5},"aggregate":{"protected":0,"exposed":0,"na":5}},"bytes":5}
1
+ {"v":"imm1","proven":null,"premiseBasis":"unmeasured","surfaceOnly":true,"coverage":null,"blindHigh":0,"routes":[{"behaviorHash":"bh1_13969839d8a18d54aa0342618e56db6a","pol":121,"exposed":[]},{"behaviorHash":"bh1_2daf8a2b1b0ed393d443991a2b0700f6","pol":121,"exposed":[]},{"behaviorHash":"bh1_58fbf5ac38b6d71abc7204921de6b957","pol":121,"exposed":[]},{"behaviorHash":"bh1_58fbf5ac38b6d71abc7204921de6b957","pol":121,"exposed":[]},{"behaviorHash":"bh1_cc6788289c612af85b8d215b5bdf9b28","pol":121,"exposed":[]}],"posture":{"auth":{"protected":0,"exposed":0,"na":5},"atomicity":{"protected":0,"exposed":0,"na":5},"reversibility":{"protected":0,"exposed":0,"na":5},"validation":{"protected":0,"exposed":0,"na":5},"aggregate":{"protected":0,"exposed":0,"na":5}},"bytes":5}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sparda-mcp",
3
- "version": "0.70.1",
3
+ "version": "0.71.1",
4
4
  "mcpName": "io.github.zyx77550/sparda-mcp",
5
5
  "description": "AI writes. SPARDA proves. A deterministic, offline gate that catches when an AI edit removes a guard, exposes a route, or breaks an invariant \u2014 no API key, right in the agent edit loop.",
6
6
  "type": "module",
@@ -31,7 +31,7 @@
31
31
  "mutation": "node tests/mutation/run.mjs",
32
32
  "wedge": "node bench/wedge.mjs",
33
33
  "release:check": "node scripts/release-gate.mjs",
34
- "publish:vscode": "cd extensions/vscode && vsce publish"
34
+ "publish:vscode": "cd extensions/vscode && npx --yes @vscode/vsce@3.9.2 publish"
35
35
  },
36
36
  "files": [
37
37
  "src",
@@ -19,7 +19,7 @@ import {
19
19
  buildProofObjects,
20
20
  } from '../ubg/apocalypse.js';
21
21
  import { surveyBlindspots, coveragePct } from '../ubg/blindspots.js';
22
- import { premiseFor, withPremiseGaps } from '../ubg/premise.js';
22
+ import { premiseFor, withPremiseGaps, basisFrom } from '../ubg/premise.js';
23
23
  import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
24
24
 
25
25
  // version travels with the proof so an audit knows which prover produced it
@@ -86,6 +86,7 @@ export async function runApocalypse(opts) {
86
86
  coverage: blind.coverage.ratio,
87
87
  blindHigh: blind.byRisk.critical + blind.byRisk.high,
88
88
  premiseGaps: premise.available ? premise.gaps.length : 0,
89
+ premiseBasis: basisFrom(premise),
89
90
  });
90
91
 
91
92
  if (opts.sarif) {
@@ -10,7 +10,7 @@ import { compileUBG } from '../ubg/compile.js';
10
10
  import { canonicalizeGraph } from '../ubg/schema.js';
11
11
  import { checkGraph, verdictOf, badgeFor } from '../ubg/apocalypse.js';
12
12
  import { surveyBlindspots, coveragePct } from '../ubg/blindspots.js';
13
- import { premiseFor, withPremiseGaps } from '../ubg/premise.js';
13
+ import { premiseFor, withPremiseGaps, basisFrom } from '../ubg/premise.js';
14
14
 
15
15
  export async function runBadge(opts) {
16
16
  const { graph, report } = compileUBG(opts.cwd, { write: false });
@@ -28,6 +28,7 @@ export async function runBadge(opts) {
28
28
  coverage: blind.coverage.ratio,
29
29
  blindHigh: blind.byRisk.critical + blind.byRisk.high,
30
30
  premiseGaps: premise.available ? premise.gaps.length : 0,
31
+ premiseBasis: basisFrom(premise),
31
32
  });
32
33
  // null = measured-but-unknown (0/0): the JSON keeps the null, the console says the word
33
34
  const cov =
@@ -10,7 +10,7 @@ import path from 'node:path';
10
10
  import { compileUBG } from '../ubg/compile.js';
11
11
  import { canonicalizeGraph } from '../ubg/schema.js';
12
12
  import { checkGraph, verdictOf, verdictState } from '../ubg/apocalypse.js';
13
- import { premiseFor, withPremiseGaps } from '../ubg/premise.js';
13
+ import { premiseFor, withPremiseGaps, basisFrom } from '../ubg/premise.js';
14
14
  import { surveyBlindspots, coveragePct } from '../ubg/blindspots.js';
15
15
  import { buildCapsule } from '../ubg/immunity.js';
16
16
  import { AXES, POLARITY_SYMBOL, exposedAxes } from '../ubg/polarity.js';
@@ -20,7 +20,6 @@ export async function runDossier(opts) {
20
20
  const compiled = compileUBG(opts.cwd, { write: false, openapi: opts.openapi });
21
21
  const canonical = canonicalizeGraph(compiled.graph);
22
22
  const { findings, polarity } = checkGraph(canonical);
23
- const capsule = buildCapsule(canonical);
24
23
  // the public report — same rule as the badge: it must not describe an app SPARDA
25
24
  // never fully had
26
25
  const premise = await premiseFor(canonical, compiled.report, {
@@ -35,7 +34,11 @@ export async function runDossier(opts) {
35
34
  coverage: blindspots.coverage.ratio,
36
35
  blindHigh: blindspots.byRisk.critical + blindspots.byRisk.high,
37
36
  premiseGaps: premise.available ? premise.gaps.length : 0,
37
+ premiseBasis: basisFrom(premise),
38
38
  });
39
+ // built AFTER the premise, deliberately: the old ordering put `buildCapsule` three lines
40
+ // above the only value that licenses it, which is how the capsule stayed ungraded (E-106).
41
+ const capsule = buildCapsule(canonical, { premiseBasis: basisFrom(premise) });
39
42
 
40
43
  const data = {
41
44
  app: path.basename(path.resolve(opts.cwd)) || 'app',
@@ -29,6 +29,7 @@ import fs from 'node:fs';
29
29
  import path from 'node:path';
30
30
  import { parse } from '@babel/parser';
31
31
  import { compileUBG } from '../ubg/compile.js';
32
+ import { premiseFor } from '../ubg/premise.js';
32
33
  import { canonicalizeGraph } from '../ubg/schema.js';
33
34
  import {
34
35
  checkGraph,
@@ -141,11 +142,35 @@ function applyEdits(src, plan, principal, shimBody) {
141
142
  return out;
142
143
  }
143
144
 
145
+ // The DELTA verdict: same app, before and after the synthesized check. Deliberately
146
+ // premise-blind — the premise is identical on both sides, so it cannot discriminate, and
147
+ // enforce V1 is Express-only where no boot-free oracle exists. Gating the rollback decision
148
+ // on it would not make enforce safer, it would make enforce impossible.
149
+ //
150
+ // What the premise DOES license is the WORD. Those are two different questions and E-104 is
151
+ // what happens when one answer is used for both: the edit is justified by the delta, the
152
+ // announcement by the oracle. See `announcedState` below.
144
153
  function compileVerdict(cwd) {
145
- const canonical = canonicalizeGraph(compileUBG(cwd, { write: false }).graph);
154
+ const { graph, report } = compileUBG(cwd, { write: false });
155
+ const canonical = canonicalizeGraph(graph);
146
156
  const { findings } = checkGraph(canonical);
147
157
  const verdict = verdictOf(findings, canonical, {});
148
- return { canonical, findings, verdict, state: verdictState(verdict) };
158
+ return { canonical, report, findings, verdict, state: verdictState(verdict) };
159
+ }
160
+
161
+ // The word this command is allowed to print, once the edit has proven itself.
162
+ //
163
+ // Before E-104 this was the literal string 'PROVEN (ENFORCED)', announced unconditionally on
164
+ // every successful run — on Express, the only framework enforce V1 supports, and one with no
165
+ // boot-free oracle. So the strongest word SPARDA has was printed on an app whose route table
166
+ // nobody had checked, every single time.
167
+ //
168
+ // The synthesized check really was verified on the recompiled graph; that part was never a
169
+ // lie. What was missing is that a verified guard over an UNVERIFIED ROUTE SET does not add up
170
+ // to PROVEN — Direction 3 is a claim about the subject, not the proof.
171
+ async function announcedState(cwd, canonical, report) {
172
+ const premise = await premiseFor(canonical, report, { cwd });
173
+ return premise.available ? 'PROVEN (ENFORCED)' : 'PARTIAL (ENFORCED)';
149
174
  }
150
175
 
151
176
  export function readEnforceManifest(cwd) {
@@ -272,13 +297,14 @@ export async function runEnforce(opts) {
272
297
  JSON.stringify(manifest, null, 2) + '\n',
273
298
  );
274
299
 
300
+ const announced = await announcedState(cwd, after.canonical, after.report);
275
301
  if (opts.json)
276
302
  console.log(
277
303
  JSON.stringify(
278
304
  {
279
305
  enforced: manifest.routes,
280
306
  files: Object.keys(manifest.files),
281
- verdict: 'PROVEN (ENFORCED)',
307
+ verdict: announced,
282
308
  },
283
309
  null,
284
310
  2,
@@ -287,7 +313,11 @@ export async function runEnforce(opts) {
287
313
  else {
288
314
  for (const t of targets) log(` ✓ enforced ${t.label}`);
289
315
  log(
290
- `\n✓ PROVEN (ENFORCED) — ${targets.length} route(s) now carry a boundary check SPARDA verified on the recompiled graph.\n Revert any time: sparda enforce --revert (byte-for-byte).`,
316
+ `\n✓ ${announced} — ${targets.length} route(s) now carry a boundary check SPARDA verified on the recompiled graph.` +
317
+ (announced.startsWith('PARTIAL')
318
+ ? `\n Not PROVEN: no oracle checked this app's route table, so Direction 3 is unverified. Run with --probe to measure it.`
319
+ : '') +
320
+ `\n Revert any time: sparda enforce --revert (byte-for-byte).`,
291
321
  );
292
322
  }
293
323
  }
@@ -125,8 +125,12 @@ export async function runGate(opts) {
125
125
  try {
126
126
  ({ graph, report } = compileUBG(opts.cwd, { write: false }));
127
127
  } catch (err) {
128
+ // `ok` is DELIBERATELY absent, not `true`. Abstaining is right — the agent is mid-edit and
129
+ // a false alarm here is worse than silence — but a consumer reading `ok` must not be able
130
+ // to read a pass out of a check that never ran. The gate still exits 0; it just refuses to
131
+ // say the word (ADR-092).
128
132
  if (opts.json)
129
- console.log(JSON.stringify({ ok: true, abstained: err.message }, null, 2));
133
+ console.log(JSON.stringify({ ok: null, abstained: err.message }, null, 2));
130
134
  else if (!hook)
131
135
  say(
132
136
  `⏳ GATE ABSTAINED — the tree does not compile yet (${err.message}). Fix it, then re-check.`,
@@ -21,16 +21,23 @@ import {
21
21
  emptyGenome,
22
22
  recall,
23
23
  } from '../ubg/genome.js';
24
+ import { premiseFor, basisFrom } from '../ubg/premise.js';
24
25
  import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
25
26
 
26
27
  const GENOME_FILE = 'sparda-genome.jsonl';
27
28
  const KEY_FILE = 'genome.key';
28
29
 
29
30
  export async function runGenome(opts) {
30
- const canonical = canonicalizeGraph(
31
- compileUBG(opts.cwd, { write: false, openapi: opts.openapi }).graph,
32
- );
33
- const capsule = buildCapsule(canonical);
31
+ const compiled = compileUBG(opts.cwd, { write: false, openapi: opts.openapi });
32
+ const canonical = canonicalizeGraph(compiled.graph);
33
+ // The premise matters MORE here than anywhere, and it was the one place that never asked
34
+ // (E-106). An antibody is a per-behavior claim, so a route the compiler never saw does not
35
+ // falsify the antibodies that were minted — it means no antibody exists for it. But this
36
+ // contribution is signed, content-addressed and merged into a file other people pull, and
37
+ // a genome that silently under-represents an app teaches the world that the surface it
38
+ // covers IS the app. The gaps are named below rather than counted.
39
+ const premise = await premiseFor(canonical, compiled.report, { cwd: opts.cwd });
40
+ const capsule = buildCapsule(canonical, { premiseBasis: basisFrom(premise) });
34
41
  const identity = loadOrCreateIdentity(opts.cwd);
35
42
  const minted = mintGenome(capsule, identity);
36
43
 
@@ -62,6 +69,19 @@ export async function runGenome(opts) {
62
69
  console.log(
63
70
  ` merged: +${added} new, ${corroborated} corroborated → ${genome.antibodies.length} antibody(ies) in ${GENOME_FILE}`,
64
71
  );
72
+ // What this contribution is NOT. A count of antibodies reads as a measure of the app; it is
73
+ // a measure of the surface SPARDA had. Gaps are NAMED, because "3 routes missing" is a
74
+ // number a reader discounts and "GET /api/legacy/purge" is one they go and look at.
75
+ if (premise.available && premise.gaps.length)
76
+ for (const g of premise.gaps)
77
+ console.log(
78
+ ` ⚠ no antibody for ${g.method} ${g.path} — the ${premise.oracle} oracle serves it, the compiler never saw it`,
79
+ );
80
+ else if (capsule.premiseBasis === 'unmeasured')
81
+ console.log(
82
+ ` ◐ UNMEASURED PREMISE — no oracle checked this app's route table, so this contribution` +
83
+ ` covers the surface SPARDA saw, which may not be the app (${premise.reason}).`,
84
+ );
65
85
  // surface any behavior the world now disagrees about — load-bearing signal
66
86
  const conflicts = distinctHashes(genome).filter((h) => recall(genome, h).conflict);
67
87
  if (conflicts.length)
@@ -102,7 +102,13 @@ async function gate(opts, id, flight, healDir) {
102
102
  const staticFindings = checkGraph(fixedGraph).findings;
103
103
  let regressions;
104
104
  const baselinePath = path.join(healDir, 'baseline.json');
105
- if (fs.existsSync(baselinePath)) {
105
+ // "nothing protected got removed" is a DELTA, and a delta needs two sides. `--check` can be
106
+ // run without the diagnose phase ever having frozen `baseline.json` — and then `diffGraphs`
107
+ // never runs, so no removed guard can possibly be detected. The old verdict line said
108
+ // "zero protection lost" either way: a property nothing had measured, asserted as an
109
+ // outcome. Tracked, so the headline can be qualified rather than the check silently skipped.
110
+ const deltaMeasured = fs.existsSync(baselinePath);
111
+ if (deltaMeasured) {
106
112
  const baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf8'));
107
113
  regressions = diffGraphs(baseline, fixedGraph).findings;
108
114
  // pre-existing static findings are not the fix's fault — only NEW ones gate
@@ -131,14 +137,31 @@ async function gate(opts, id, flight, healDir) {
131
137
  console.log(
132
138
  ` ${apocalypse.safe ? '✓' : '✗'} apocalypse: ${regressions.length === 0 ? 'no new findings' : regressions.map((f) => f.rule).join(', ')}`,
133
139
  );
140
+ if (!deltaMeasured)
141
+ console.log(
142
+ ` ◐ guard delta UNMEASURED — no baseline.json in .sparda/heal/${id}/;` +
143
+ ` run \`sparda heal ${id}\` first so the pre-fix graph is frozen`,
144
+ );
134
145
 
135
146
  const ok = healing.healed && laws.ok && apocalypse.safe;
136
- const report = { id, healed: healing, laws: laws.checks, regressions, ok };
147
+ const report = {
148
+ id,
149
+ healed: healing,
150
+ laws: laws.checks,
151
+ regressions,
152
+ deltaMeasured,
153
+ ok,
154
+ };
137
155
  atomicWrite(path.join(healDir, 'report.json'), JSON.stringify(report, null, 2) + '\n');
138
156
 
139
157
  if (ok) {
158
+ // The claim is trimmed to what was actually measured. Without a baseline the fixed tree
159
+ // was still graded — every static finding gated, which is STRICTER — but "zero protection
160
+ // lost" specifically requires the before/after diff, so it is not said.
140
161
  console.log(
141
- `✓ HEALED & PROVEN — same recorded inputs, correct output, zero law broken, zero protection lost. Ship it.`,
162
+ deltaMeasured
163
+ ? `✓ HEALED & PROVEN — same recorded inputs, correct output, zero law broken, zero protection lost. Ship it.`
164
+ : `◐ HEALED, PARTIALLY PROVEN — same recorded inputs, correct output, zero law broken, no static finding. Whether a guard was REMOVED is unmeasured: no pre-fix graph was frozen.`,
142
165
  );
143
166
  } else {
144
167
  console.log(
@@ -11,13 +11,18 @@ import { compileUBG } from '../ubg/compile.js';
11
11
  import { canonicalizeGraph } from '../ubg/schema.js';
12
12
  import { buildCapsule } from '../ubg/immunity.js';
13
13
  import { AXES } from '../ubg/polarity.js';
14
+ import { premiseFor, basisFrom } from '../ubg/premise.js';
14
15
  import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
15
16
 
16
17
  export async function runImmunize(opts) {
17
- const canonical = canonicalizeGraph(
18
- compileUBG(opts.cwd, { write: false, openapi: opts.openapi }).graph,
19
- );
20
- const capsule = buildCapsule(canonical);
18
+ const compiled = compileUBG(opts.cwd, { write: false, openapi: opts.openapi });
19
+ const canonical = canonicalizeGraph(compiled.graph);
20
+ // THE PREMISE, asked here too (E-106). `immunize` grades a compiled graph and freezes the
21
+ // result into a portable artifact — the strongest claim in the product, because it is the
22
+ // one that leaves the repo. It printed an `UNMEASURED PREMISE` branch that no call path
23
+ // could reach, because nothing ever told the capsule on what basis it was built.
24
+ const premise = await premiseFor(canonical, compiled.report, { cwd: opts.cwd });
25
+ const capsule = buildCapsule(canonical, { premiseBasis: basisFrom(premise) });
21
26
 
22
27
  if (opts.json) {
23
28
  console.log(JSON.stringify(capsule, null, 2));
@@ -44,11 +49,18 @@ export async function runImmunize(opts) {
44
49
  const exposed = AXES.filter((a) => capsule.posture[a].exposed > 0)
45
50
  .map((a) => `${a}×${capsule.posture[a].exposed}`)
46
51
  .join(', ');
47
- const verdictText = capsule.surfaceOnly
48
- ? '● SURFACE ONLY routes seen, no behavior resolved (nothing to prove)'
49
- : capsule.proven
50
- ? '✓ PROVEN'
51
- : '✗ NOT PROVEN';
52
+ // `proven` is three-state since ADR-092: null means the premise behind this capsule was
53
+ // never measured, so the capsule carries no licence for the strongest word. Checked FIRST —
54
+ // a falsy `null` read as "not proven" would be a different lie in the safe direction, and
55
+ // still a lie.
56
+ const verdictText =
57
+ capsule.proven === null
58
+ ? "◑ UNMEASURED PREMISE — no oracle checked this app's route table, so this capsule cannot claim PROVEN"
59
+ : capsule.surfaceOnly
60
+ ? '● SURFACE ONLY — routes seen, no behavior resolved (nothing to prove)'
61
+ : capsule.proven
62
+ ? '✓ PROVEN'
63
+ : '✗ NOT PROVEN';
52
64
  console.log(` verdict: ${verdictText}` + (exposed ? ` — exposed: ${exposed}` : ''));
53
65
  console.log(
54
66
  ` coverage: ${(capsule.coverage * 100).toFixed(0)}% of observed behavior resolved` +
@@ -60,6 +72,11 @@ export async function runImmunize(opts) {
60
72
  );
61
73
  // surface-only is not a risk (nothing to fault) → don't fail CI; only a real
62
74
  // unproven capsule (an exposed axis) gates.
63
- if (!capsule.proven && !capsule.surfaceOnly) process.exitCode = 1;
75
+ //
76
+ // `=== false`, not `!capsule.proven`: `null` is falsy, and gating on it would make SPARDA
77
+ // fail a build because no oracle was AVAILABLE — demanding what it could not measure, which
78
+ // premise.js forbids in exactly these words. Withholding the word and finding a fault are
79
+ // different outcomes, and only the second one closes a gate.
80
+ if (capsule.proven === false && !capsule.surfaceOnly) process.exitCode = 1;
64
81
  return { capsule, outPath };
65
82
  }
@@ -9,7 +9,7 @@ import { compileUBG } from '../ubg/compile.js';
9
9
  import { canonicalizeGraph } from '../ubg/schema.js';
10
10
  import { checkGraph, verdictOf, verdictState, badgeFor } from '../ubg/apocalypse.js';
11
11
  import { surveyBlindspots, coveragePct } from '../ubg/blindspots.js';
12
- import { premiseFor, withPremiseGaps } from '../ubg/premise.js';
12
+ import { premiseFor, withPremiseGaps, basisFrom } from '../ubg/premise.js';
13
13
  import { buildCapsule } from '../ubg/immunity.js';
14
14
  import { fingerprintGraph } from '../ubg/fingerprint.js';
15
15
  import { suggestAppDirs } from '../detect.js';
@@ -39,8 +39,12 @@ export async function runProve(opts) {
39
39
  coverage: blind.coverage.ratio,
40
40
  blindHigh: blind.byRisk.critical + blind.byRisk.high,
41
41
  premiseGaps: premise.available ? premise.gaps.length : 0,
42
+ premiseBasis: basisFrom(premise),
42
43
  });
43
- const capsule = buildCapsule(canonical);
44
+ // The capsule is graded on the SAME basis as the verdict. It shipped without one (E-106):
45
+ // `prove` computed the premise two lines above, used it for the verdict word, and then
46
+ // built a portable capsule that re-asserted `proven: true` with no licence at all.
47
+ const capsule = buildCapsule(canonical, { premiseBasis: basisFrom(premise) });
44
48
  const prints = fingerprintGraph(canonical);
45
49
  // one app-level seal: a content address over every route's behavior hash — the same
46
50
  // behavior, in any repo, yields the same seal (the genome's join key, made visible).
@@ -77,7 +81,14 @@ export async function runProve(opts) {
77
81
  probed: premise.probed,
78
82
  gaps: premise.gaps.length,
79
83
  }
80
- : { verified: false, reason: premise.reason },
84
+ : {
85
+ verified: false,
86
+ // 'unmeasured' (no oracle ran) vs 'declared' (OpenAPI — the document analysed IS
87
+ // the route table). A machine consumer that cannot tell those apart is in exactly
88
+ // the position E-104 put the verdict in.
89
+ basis: premise.basis ?? 'unmeasured',
90
+ reason: premise.reason,
91
+ },
81
92
  findings: findings.map((f) => ({
82
93
  rule: f.rule,
83
94
  severity: f.severity,
@@ -147,6 +158,15 @@ export async function runProve(opts) {
147
158
  );
148
159
  if (summary.blindHigh > 0)
149
160
  reasons.push(`${summary.blindHigh} high-risk blind spot(s)`);
161
+ // FIRST, because it is the only rung that is about the SUBJECT rather than the proof.
162
+ // A reader told "PARTIAL: 100% of the surface resolved" and nothing else concludes the
163
+ // analysis was thorough and the word merely cautious. The truth was that no oracle ever
164
+ // checked whether the surface analysed was the app's — and that is a different sentence,
165
+ // with a different remedy (E-104).
166
+ if (verdict.premiseUnmeasured)
167
+ reasons.unshift(
168
+ `the route table was never checked by an oracle — no boot-free oracle for this framework, and --probe was not used, so Direction 3 is UNVERIFIED (not a finding: an unmeasured premise, which PROVEN would assert)`,
169
+ );
150
170
  if (verdict.coverageUnknown)
151
171
  reasons.push(
152
172
  `coverage is UNKNOWN (0 behaviors resolved out of 0 seen — no measurement, not a pass)`,
@@ -86,6 +86,7 @@ export function reviewGraphs(
86
86
  coverage: candCov,
87
87
  blindHigh: candBlind.byRisk.critical + candBlind.byRisk.high,
88
88
  premiseGaps: premise?.available ? premise.gaps.length : 0,
89
+ premiseBasis: premise?.available ? 'measured' : (premise?.basis ?? 'unmeasured'),
89
90
  }),
90
91
  obligations,
91
92
  findings,
@@ -65,7 +65,9 @@ export async function runSpeculate(opts) {
65
65
  (result.novel.length ? ` · ${result.novel.length} novel need full proof` : '')
66
66
  : result.novel.length
67
67
  ? `· ${result.novel.length} novel shape(s) need the full prover — run \`sparda apocalypse\``
68
- : `✓ PROVEN (by lookup) — every route settled from the frozen capsule, zero prover work`,
68
+ : capsule?.proven === null
69
+ ? `◑ UNMEASURED PREMISE (by lookup) — every route settled from the frozen capsule, but nothing ever checked that capsule's route table`
70
+ : `✓ PROVEN (by lookup) — every route settled from the frozen capsule, zero prover work`,
69
71
  );
70
72
  if (result.rejected.length) process.exitCode = 1;
71
73
  return { result };
@@ -17,7 +17,14 @@ export async function runStitch(opts, dirs) {
17
17
  process.exitCode = 1;
18
18
  return;
19
19
  }
20
+ // A service that does not compile is UNMEASURED, and the join is only as complete as the
21
+ // set it ran over. The failure used to go to stderr and nowhere else: the headline still
22
+ // said "no cross-service calls resolved (targets may be dynamic or unrelated)" — a
23
+ // reassuring parenthetical for what was really "we compiled one of your two services".
24
+ // Cross-service BOLA is found by JOINING; half a join finds nothing and says nothing,
25
+ // which is the most confident silence in the product.
20
26
  const services = [];
27
+ const unread = [];
21
28
  for (const d of dirs) {
22
29
  const abs = path.resolve(opts.cwd, d);
23
30
  try {
@@ -26,7 +33,7 @@ export async function runStitch(opts, dirs) {
26
33
  const { findings } = checkGraph(g);
27
34
  services.push({ name: path.basename(abs), graph: g, findings });
28
35
  } catch (e) {
29
- console.error(` ✗ ${d}: ${e.message.slice(0, 70)}`);
36
+ unread.push({ dir: d, reason: e.message.slice(0, 140) });
30
37
  }
31
38
  }
32
39
 
@@ -34,16 +41,32 @@ export async function runStitch(opts, dirs) {
34
41
 
35
42
  if (opts.json) {
36
43
  console.log(
37
- JSON.stringify({ services: services.map((s) => s.name), edges, findings }, null, 2),
44
+ JSON.stringify(
45
+ { services: services.map((s) => s.name), unread, edges, findings },
46
+ null,
47
+ 2,
48
+ ),
38
49
  );
50
+ // the JSON consumer is a CI job, and it must not read an empty `findings` over a partial
51
+ // join as a clean bill of health
52
+ if (unread.length) process.exitCode = 1;
39
53
  return;
40
54
  }
41
55
 
42
56
  console.log(`\nSPARDA · stitch — ${services.map((s) => s.name).join(' · ')}`);
43
57
  console.log('─'.repeat(52));
58
+ // stated BEFORE the result, because it is the qualifier on everything that follows
59
+ for (const u of unread) console.log(` ✗ ${u.dir} did not compile — ${u.reason}`);
60
+ if (unread.length)
61
+ console.log(
62
+ ` ◐ PARTIAL JOIN — ${services.length} of ${dirs.length} service(s) read.` +
63
+ ` Any call to or from the missing one(s) is UNMEASURED, not absent.`,
64
+ );
44
65
  if (!edges.length) {
45
66
  console.log(
46
- ' no cross-service calls resolved (targets may be dynamic or unrelated)',
67
+ unread.length
68
+ ? ' no cross-service calls resolved among the services that DID compile'
69
+ : ' no cross-service calls resolved (targets may be dynamic or unrelated)',
47
70
  );
48
71
  } else {
49
72
  console.log(` ${edges.length} cross-service call(s):`);
@@ -55,4 +78,6 @@ export async function runStitch(opts, dirs) {
55
78
  for (const f of findings) console.log(` ${f.message}`);
56
79
  }
57
80
  console.log('');
81
+ if (unread.length) process.exitCode = 1;
82
+ return { services: services.map((s) => s.name), unread, edges, findings };
58
83
  }
@@ -77,9 +77,23 @@ async function replay(opts, id) {
77
77
  `TIMELESS — replaying ${flight.request.method} ${flight.request.url} (${flight.taps.length} taps virtualized)`,
78
78
  );
79
79
  if (result.match) {
80
- console.log(
81
- `✓ byte-identical replay status ${result.actual.status}, every tap consumed, zero divergence`,
82
- );
80
+ // A flight with ZERO taps passes `match` on the response alone: `leftover` and
81
+ // `divergences` are empty because there was nothing to consume and nothing to diverge
82
+ // from. "every tap consumed, zero divergence" over an empty tap set is the falsify bug's
83
+ // shape — a perfect score for a control set of size 0. It matters practically: a flight
84
+ // recorded before `box.wrapClient(db)` was installed captures no taps, so the replay hit
85
+ // the LIVE dependency and the match says the environment answered the same way today,
86
+ // not that the code is unchanged.
87
+ if (!flight.taps.length)
88
+ console.log(
89
+ `◐ response identical (status ${result.actual.status}) — but 0 taps were recorded, so` +
90
+ ` NOTHING was virtualized: determinism is UNMEASURED. Wrap your clients` +
91
+ ` (\`box.wrapClient(db)\`) and re-record to make this a proof.`,
92
+ );
93
+ else
94
+ console.log(
95
+ `✓ byte-identical replay — status ${result.actual.status}, all ${flight.taps.length} tap(s) consumed, zero divergence`,
96
+ );
83
97
  } else {
84
98
  if (!result.statusMatch)
85
99
  console.log(
@@ -30,7 +30,7 @@ import { compileUBG } from '../ubg/compile.js';
30
30
  import { canonicalizeGraph } from '../ubg/schema.js';
31
31
  import { checkGraph, diffGraphs, verdictOf, verdictState } from '../ubg/apocalypse.js';
32
32
  import { surveyBlindspots } from '../ubg/blindspots.js';
33
- import { premiseFor, withPremiseGaps } from '../ubg/premise.js';
33
+ import { premiseFor, withPremiseGaps, basisFrom } from '../ubg/premise.js';
34
34
  import { witnessTargets, admitWitnesses } from '../ubg/witness.js';
35
35
 
36
36
  const EVENT_POLL_MS = Number(process.env.SPARDA_EVENT_POLL_MS ?? 5000);
@@ -1266,6 +1266,7 @@ export async function proveApp(cwd, { route } = {}) {
1266
1266
  coverage: blind.coverage.ratio,
1267
1267
  blindHigh: blind.byRisk.critical + blind.byRisk.high,
1268
1268
  premiseGaps: premise.available ? premise.gaps.length : 0,
1269
+ premiseBasis: basisFrom(premise),
1269
1270
  });
1270
1271
  return {
1271
1272
  verdict: verdictState(verdict),
@@ -290,8 +290,21 @@ export function checkGraph(graph) {
290
290
  // "confirm intent" — never hidden, never marked safe, never touches PROVEN. The list is the
291
291
  // HEAD of the distribution (deliberately precise, not `**/auth/**` blanket: change-password /
292
292
  // 2fa / session management are NOT public, and re-labeling those would hide a real hole).
293
+ //
294
+ // The list is precise, but its TOKENS are not: `register` also names authenticated management
295
+ // (`/account/2fa/register`, `/devices/register`), `webhook`/`hook` a subscription CREATE, and
296
+ // any signature can sit under an authenticated namespace (`/account/…`, `/admin/…`). Because
297
+ // this re-label carries NO evidence of a gate, the asymmetric error model forbids it from
298
+ // firing on those: it must ABSTAIN when the path sits in an authenticated area, or it hides
299
+ // the same unguarded mutation it swears off for change-password, one alternative over (the
300
+ // 2FA-register / webhook-management class). Abstaining only ADDS criticals — the safe direction.
301
+ const authenticatedArea =
302
+ /(^|[:/\- ])(account|accounts|settings|admin|me|profile|2fa|mfa|totp|webhooks?|hooks?|devices?|tokens?|api[_-]?keys?|applications?|integrations?)([/:\- ]|$)/i.test(
303
+ ep.label,
304
+ );
293
305
  const expectedPublic =
294
306
  !credentialGated &&
307
+ !authenticatedArea &&
295
308
  (/(^|[:/\- ])(log-?in|sign-?in|sign-?up|register|log-?out|sign-?out|forgot-?password|reset-?password|forgot|verify-?email|email-?verification|confirm-?email|magic-?link|oauth\d?|openid|sso|saml|health(z|check|-?check)?|livez|readyz|metrics|well-known)([/:\- ]|$)/i.test(
296
309
  ep.label,
297
310
  ) ||
@@ -764,7 +777,7 @@ const COVERAGE_COMPLETE = 0.6;
764
777
  export function verdictOf(
765
778
  findings,
766
779
  graph,
767
- { coverage, blindHigh = 0, premiseGaps = 0 } = {},
780
+ { coverage, blindHigh = 0, premiseGaps = 0, premiseBasis = null } = {},
768
781
  ) {
769
782
  const counts = { critical: 0, high: 0, medium: 0, info: 0 };
770
783
  for (const f of findings) counts[f.severity]++;
@@ -824,12 +837,32 @@ export function verdictOf(
824
837
  // from undefined (not measured at all, e.g. heal's partial-graph delta, old semantics
825
838
  // kept). An unknown coverage can never carry a complete PROVEN: unknown ≠ 100%.
826
839
  const coverageUnknown = coverage === null;
840
+ // E-104. The premise is the claim that the route set analysed IS the route set the app
841
+ // serves — Direction 3 — and the ONLY instrument that checks it is an oracle that is not
842
+ // the analyser. When no oracle ran, that claim is UNVERIFIED, and `PROVEN` asserts it.
843
+ //
844
+ // The bug this closes: `premiseFor` reported `{ available: false, gaps: [] }` honestly, and
845
+ // every consumer then read it through `premiseGaps === 0` — byte-identical to "an oracle ran
846
+ // and agreed". Measured on our own fixtures: 7 of the 8 that read PROVEN had never had an
847
+ // oracle run at all, every one of them Express or FastAPI. An honest LABEL is not an honest
848
+ // SYSTEM; the place a distinction gets flattened is where the lie is told.
849
+ //
850
+ // Deliberately a PARTIAL rung and not a PREMISE_GAP: a gap is measured evidence that the
851
+ // subject was incomplete (blocking), while silence is only the absence of a witness. PARTIAL
852
+ // already means exactly that — "proved what was seen" — so this neither blocks a deploy nor
853
+ // invents a fault. It withholds the strongest word, which is the one thing it must do.
854
+ //
855
+ // `null` keeps the old semantics for callers with no premise to speak of (heal's regression
856
+ // delta is a partial graph, not a whole-app proof). `'declared'` is OpenAPI, where the
857
+ // document analysed IS the route table and a second witness would be the same map twice.
858
+ const premiseUnmeasured = premiseBasis === 'unmeasured';
827
859
  const partial =
828
860
  clean &&
829
861
  entrypoints > 0 &&
830
862
  ((coverage != null && coverage < COVERAGE_COMPLETE) ||
831
863
  coverageUnknown ||
832
864
  blindHigh > 0 ||
865
+ premiseUnmeasured ||
833
866
  assertedMutations > 0);
834
867
  // THE PREMISE GATE. Every other rung above softens a claim about what SPARDA
835
868
  // ANALYSED. This one answers a prior question — was the analysis about the whole
@@ -860,6 +893,11 @@ export function verdictOf(
860
893
  coverageUnknown,
861
894
  premiseGaps,
862
895
  premiseUnverified,
896
+ // Carried so every surface can say WHY the word was withheld. A PARTIAL whose reason is
897
+ // "nobody checked the route table" reads very differently from one whose reason is "77 %
898
+ // coverage", and a user who cannot tell them apart cannot act on either.
899
+ premiseBasis,
900
+ premiseUnmeasured,
863
901
  // The CI gate. A premise gap belongs here and not only in the verdict word: an
864
902
  // app whose route surface we demonstrably did not have cannot be called safe, and
865
903
  // a green CI over it would reproduce the exact failure this whole audit removed —
@@ -82,8 +82,12 @@ export function falsifyGraph(graph, { check = checkGraph } = {}) {
82
82
  controls: [],
83
83
  flipped: 0,
84
84
  holes: [],
85
- score: 1,
86
- note: 'no protected mutation routes nothing to falsify',
85
+ // NOT 1. A score of 1 means "every green depends on its guard, and we checked". Here
86
+ // nothing was checked, and 1 is the number a reader acts on while the `note` sits
87
+ // beside it, unread. `null` cannot be misread — it is the only value that carries its
88
+ // own caveat (ADR-092: the admission goes INSIDE the headline, never next to it).
89
+ score: null,
90
+ note: 'no protected mutation routes — nothing to falsify (score UNMEASURED, not 1)',
87
91
  };
88
92
 
89
93
  // Per-route attribution, flood-aware: ablating EVERY guard at once makes UNGUARDED_MUTATION
@@ -19,7 +19,7 @@ import { AXES, posture, provenByPolarity, packVector, exposedAxes } from './pola
19
19
  export const CAPSULE_VERSION = 'imm1';
20
20
 
21
21
  // canonical graph → a compact, portable, deterministic safety capsule.
22
- export function buildCapsule(graph) {
22
+ export function buildCapsule(graph, { premiseBasis = null } = {}) {
23
23
  const prints = new Map(fingerprintGraph(graph).map((p) => [p.entrypoint, p]));
24
24
  const { polarity } = checkGraph(graph);
25
25
 
@@ -38,9 +38,29 @@ export function buildCapsule(graph) {
38
38
  // only if it says so. The genome inherits this, so the world memory carries not just
39
39
  // "proven" but "proven over how much" — a verdict's confidence, made portable.
40
40
  const survey = surveyBlindspots(graph);
41
+ // THE CAPSULE CARRIES ITS OWN BASIS OF MEASUREMENT (ADR-092). A capsule is PORTABLE — it
42
+ // travels between repos and is replayed by `speculate`/`immunize` far from the analysis that
43
+ // produced it. A frozen `proven: true` whose premise nobody ever measured would be a claim
44
+ // re-asserted in a context where its licence cannot even be checked, which is worse than the
45
+ // original E-104: there, at least, the oracle was one call away.
46
+ //
47
+ // So `proven` is a THREE-state field, not a boolean. `null` = the premise was never measured,
48
+ // and no consumer can read a pass out of it. Absent basis (an older capsule, or a caller
49
+ // with no premise to speak of) keeps the previous semantics rather than retroactively
50
+ // invalidating capsules already in the wild.
51
+ //
52
+ // ONLY THE POSITIVE IS WITHHELD, and getting this wrong is a regression in the unsafe
53
+ // direction. The premise bounds the route SET; a route that is missing from the graph cannot
54
+ // rescue one that IS in it and is exposed. So `proven: false` needs no premise and survives
55
+ // untouched — the direction premise.js states plainly: gaps WITHHOLD a verdict, never grant
56
+ // one. Blanking `false` to `null` would have turned "this app has an unguarded mutation"
57
+ // into "we don't know", which is the same lie pointed the other way.
58
+ const premiseUnmeasured = premiseBasis === 'unmeasured';
59
+ const provenByPol = !surfaceOnly && provenByPolarity(polarity);
41
60
  return {
42
61
  v: CAPSULE_VERSION,
43
- proven: !surfaceOnly && provenByPolarity(polarity),
62
+ proven: premiseUnmeasured && provenByPol ? null : provenByPol,
63
+ premiseBasis,
44
64
  surfaceOnly,
45
65
  coverage: survey.coverage.ratio,
46
66
  blindHigh: survey.byRisk.critical + survey.byRisk.high,
@@ -181,18 +181,53 @@ export async function premiseFor(graph, report, { cwd, probe = false } = {}) {
181
181
  if (!wanted)
182
182
  return {
183
183
  available: false,
184
+ basis: basisOf(report.framework),
184
185
  gaps: [],
185
186
  probed: 0,
186
187
  reason: PROBEABLE.has(report.framework)
187
188
  ? 'runtime oracle not requested (--probe)'
188
189
  : `no oracle for ${report.framework}`,
189
190
  };
190
- return verifyPremise(graph, {
191
+ const verified = await verifyPremise(graph, {
191
192
  framework: report.framework,
192
193
  entryFile: report.entry,
193
194
  cwd,
194
195
  unknownHandlers: report.unknownHandlers,
195
196
  });
197
+ return {
198
+ ...verified,
199
+ basis: verified.available ? 'measured' : basisOf(report.framework),
200
+ };
201
+ }
202
+
203
+ // On what BASIS do we hold the premise — that the route set analysed is the route set the
204
+ // app serves? Three answers, and collapsing the last two is E-104:
205
+ //
206
+ // 'measured' — an oracle that is not the analyser enumerated the surface and agreed.
207
+ // 'declared' — there is nothing to cross-check against because the artefact analysed IS
208
+ // the route table. An OpenAPI document does not have a premise behind it;
209
+ // it is the premise. Demanding a second witness for it would be demanding
210
+ // that a map be verified against itself.
211
+ // 'unmeasured' — no oracle ran. NOT the same as "an oracle ran and found nothing", and the
212
+ // whole point of naming this state is that the verdict must be able to tell
213
+ // the difference (E-104: `PROVEN` was reachable on 7 of our 8 proving
214
+ // fixtures with no oracle ever having run).
215
+ export function basisOf(framework) {
216
+ if (framework === 'openapi') return 'declared';
217
+ return 'unmeasured';
218
+ }
219
+
220
+ // The basis of a premise RESULT, as every consumer needs it. `premiseFor` already computes
221
+ // it; NINE call sites re-derived it by hand anyway — the same ternary, copied, which is nine
222
+ // chances to get it subtly wrong and one more shape to keep in sync.
223
+ //
224
+ // The default is the load-bearing part: a caller that has NO premise object at all — because
225
+ // it never asked — gets 'unmeasured', not 'measured' and not `null`. Forgetting to measure
226
+ // must fail toward the weaker word, never toward silence. That is the whole of E-106 in one
227
+ // default value.
228
+ export function basisFrom(premise) {
229
+ if (premise?.available) return 'measured';
230
+ return premise?.basis ?? 'unmeasured';
196
231
  }
197
232
 
198
233
  // Fold the gaps into the report the blind-spot ledger reads. Every caller did these two