// allow-test-rule: source-text-is-the-product — the plan-checker is a prompt; its .md text IS what the runtime loads (#1954) /** * Dimension 3b — undeclared / temporal coupling between same-wave plans (#1954). * * `msd-plan-checker` proves plan dependencies resolve and are acyclic (Dimension 3), * and `/msd:execute-phase` separately proves same-wave plans do not overlap in * `files_modified`. Neither axis sees coupling that is real but undeclared — plan A * writes a config key / table / migration / global module that plan B reads, or B * only works if A ran first. In parallel execution that surfaces as an intermittent * failure the executor cannot attribute. * * ## What this suite locks * * The deployed contract AND the wiring between the three regions of the agent doc that * must agree: the sub-check body, its severity rule, and the `` * checklist. A sub-check present in the body but absent from `success_criteria` is a * check the agent is never told to run; the reverse is a checklist item with no rubric. * Neither half is observable from the other, which is why both are asserted here. * * ## What it cannot prove * * That the model acts on the text. The subject is an LLM prompt — no test in this repo * can prove behavior for any of the agent's twelve existing dimensions either. Stated * so the coverage claim is honest rather than implied. * * Patterns are CRLF-tolerant (`\r?\n`): the runtime loads the file whole, including on * a checkout that produced CRLF, the same case `scripts/workflow-size.cjs` defends. */ const { describe, test } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const { stripFencedCode } = require('../msd-core/bin/lib/markdown-sectionizer.cjs'); const ROOT = path.join(__dirname, '..'); const AGENT_PATH = path.join(ROOT, 'agents', 'msd-plan-checker.md'); const DOCS_AGENTS_PATH = path.join(ROOT, 'docs', 'AGENTS.md'); const REVISION_LOOP_PATH = path.join(ROOT, 'msd-core', 'references', 'revision-loop.md'); const PLAN_PHASE_PATH = path.join(ROOT, 'msd-core', 'workflows', 'plan-phase.md'); const PLANNER_PATH = path.join(ROOT, 'agents', 'msd-planner.md'); const PLANNER_COUPLING_REF_PATH = path.join(ROOT, 'msd-core', 'references', 'planner-coupling.md'); const VERIFY_WORK_PATH = path.join(ROOT, 'msd-core', 'workflows', 'verify-work.md'); const QUICK_LOOP_PATH = path.join(ROOT, 'msd-core', 'workflows', 'quick', 'steps', 'plan-checker-loop.md'); const IMPORT_PATH = path.join(ROOT, 'msd-core', 'workflows', 'import.md'); const AGENT_CONTRACTS_PATH = path.join(ROOT, 'msd-core', 'references', 'agent-contracts.md'); const { splitLines } = require('../msd-core/bin/lib/text-lines.cjs'); const agentDoc = fs.readFileSync(AGENT_PATH, 'utf-8'); const docsAgents = fs.readFileSync(DOCS_AGENTS_PATH, 'utf-8'); // Severity tokens inside a span's fenced ```yaml examples only. Prose may // legitimately contrast another tier ("this shape would be a blocker — see // Dimension 9"); the yaml examples are the declaration the model copies, so // severity assertions scope here (round-5 Minor 3). function yamlSeverityTiers(span) { const tiers = []; let inYaml = false; for (const line of splitLines(span)) { if (line.trim() === '```yaml') { inYaml = true; continue; } if (inYaml && line.trim() === '```') { inYaml = false; continue; } const m = inYaml ? line.match(/severity:\s*(\w+)/) : null; if (m) tiers.push(m[1].toLowerCase()); } return tiers; } // ── Span helpers ─────────────────────────────────────────────────── // Offsets of the headings that bound each region. `indexOfHeading` returns -1 when // absent so a missing heading fails as a named assertion rather than an off-by-one. function indexOfHeading(content, pattern) { const m = content.match(pattern); return m && typeof m.index === 'number' ? m.index : -1; } const D3_HEADING = /^## Dimension 3: /m; const D3B_HEADING = /^## Dimension 3b: /m; const D4_HEADING = /^## Dimension 4: /m; function sliceBetween(content, startPattern, endPattern) { const start = indexOfHeading(content, startPattern); const end = indexOfHeading(content, endPattern); assert.ok(start >= 0, `start heading not found: ${startPattern}`); assert.ok(end >= 0, `end heading not found: ${endPattern}`); assert.ok(end > start, `end heading precedes start heading: ${startPattern} .. ${endPattern}`); return content.slice(start, end); } /** * Count top-level ordered-list items in a span. This is the trigger gate's arity — * the "flag only when ALL N hold" conjunction. Widening it from 3 to 2 is what turns * a precise heuristic into a noise generator, so the count is asserted, not the prose. */ function countOrderedItems(span) { const matches = span.match(/^\d+\. /gm); return matches ? matches.length : 0; } /** * Remove fenced code blocks before scanning for headings. The agent's * `### Dimension 8 Output` section embeds a literal `## Dimension 8: ...` line inside a * fence as its output template, so a fence-blind scan reports Dimension 8 twice and any * uniqueness or completeness check built on it is wrong before it starts. */ function stripFences(content) { return stripFencedCode(content).text; } describe('msd-plan-checker Dimension 3b — undeclared/temporal coupling (#1954)', () => { describe('the sub-check exists and is scoped to Dimension 3', () => { test('Dimension 3 carries an undeclared-coupling sub-check', () => { const heading = agentDoc.match(/^## Dimension 3b: (.+)$/m); assert.ok(heading, 'agents/msd-plan-checker.md must define a "## Dimension 3b:" heading'); assert.match( heading[1], /coupling/i, `Dimension 3b must name coupling as its subject, got: ${heading[1]}` ); }); test('the sub-check sits inside Dimension 3, not after it', () => { const d3 = indexOfHeading(agentDoc, D3_HEADING); const d3b = indexOfHeading(agentDoc, D3B_HEADING); const d4 = indexOfHeading(agentDoc, D4_HEADING); assert.ok(d3 >= 0 && d3b >= 0 && d4 >= 0, 'Dimensions 3, 3b and 4 must all be present'); assert.ok(d3 < d3b, 'Dimension 3b must follow Dimension 3'); assert.ok(d3b < d4, 'Dimension 3b must precede Dimension 4 — it extends Dimension 3'); }); test('the sub-check scopes comparison to plan pairs', () => { // Parallelism is per PLAN (execute-phase spawns one executor per plan per wave), // so two tasks inside one plan run sequentially and cannot race. Scoping the // comparison to task pairs would flag orderings that are guaranteed by construction. const span = sliceBetween(agentDoc, D3B_HEADING, D4_HEADING); assert.match( span, /Scope:\s*PLAN pairs, not tasks/, 'Dimension 3b must state that the comparison is plan-pair scoped' ); }); }); describe('the trigger gate is a three-way conjunction', () => { test('the trigger gate enumerates exactly three conditions', () => { const span = sliceBetween(agentDoc, D3B_HEADING, D4_HEADING); assert.strictEqual( countOrderedItems(span), 3, 'Dimension 3b must gate on exactly three AND-ed conditions ' + '(same wave, no declared edge, named shared mutable resource or produced-state ' + 'prerequisite). Dropping one widens the heuristic into noise; adding one ' + 'silently narrows what it can catch.' ); }); test('condition counter fires at 2 / 3 / 4', () => { // The assertion above can only ever observe the real doc's arity, so its // inequality branch never executes. Exercise the counter at limit-1 / limit / // limit+1 (RULESET.TESTS.boundary-coverage) through the SAME function the guard // uses, in both LF and CRLF form, so a future edit cannot neuter it. const item = (n) => `${n}. condition ${n}`; for (const eol of ['\n', '\r\n']) { const spanOf = (count) => ['## Dimension 3b: heading', ...Array.from({ length: count }, (_, i) => item(i + 1))] .join(eol); assert.strictEqual(countOrderedItems(spanOf(2)), 2, `2 items must count as 2 (eol=${JSON.stringify(eol)})`); assert.strictEqual(countOrderedItems(spanOf(3)), 3, `3 items must count as 3 (eol=${JSON.stringify(eol)})`); assert.strictEqual(countOrderedItems(spanOf(4)), 4, `4 items must count as 4 (eol=${JSON.stringify(eol)})`); } }); }); describe('severity is advisory, and stays advisory', () => { test('the sub-check severity is the tier the revision loop exempts (#3724 parity)', () => { // #3724's defect was exactly this coming apart: 3b spec'd "advisory" but // tagged `warning`, the tier the revision loop treats as must-fix. The // exempt tier is therefore DERIVED from revision-loop.md's flow, never // hardcoded — if the loop's exemption ever changes, this fails instead // of silently re-opening the guaranteed-replan defect. const loopDoc = fs.readFileSync(REVISION_LOOP_PATH, 'utf-8'); const exempt = loopDoc.match(/If PASSED or only (\w+)-level issues/); assert.ok(exempt, 'revision-loop.md must state its exempt severity tier in the flow'); const tier = exempt[1].toLowerCase(); const span = sliceBetween(agentDoc, D3B_HEADING, D4_HEADING); assert.match( span, new RegExp(`severity:\\s*${tier}`), `Dimension 3b's example issue must carry severity: ${tier} — the tier revision-loop.md exempts` ); // The negative is derived too: every severity token in the span's yaml // examples must BE the exempt tier. If revision-loop.md's exemption ever // moves, this fails naming the real conflict instead of blaming the agent // file with a stale hardcode. const tiersInSpan = yamlSeverityTiers(span); assert.ok(tiersInSpan.length > 0, 'Dimension 3b must carry at least one severity-tagged example'); for (const found of tiersInSpan) { assert.strictEqual( found, tier, `Dimension 3b carries severity: ${found}, but the only tier revision-loop.md exempts is ` + `${tier} — a non-exempt tier re-arms the revision loop` ); } }); test('the sub-check forbids escalating to blocker', () => { // The agent's own penalises "issuing warnings for what are // actually blockers", which biases the model to escalate. Issue #1954 rejected the // hard-block alternative outright, so the prohibition has to be explicit in the span. const span = sliceBetween(agentDoc, D3B_HEADING, D4_HEADING); assert.match( span, /never\s+(a\s+)?blocker/i, 'Dimension 3b must state that the finding is never a blocker' ); assert.ok( !yamlSeverityTiers(span).includes('blocker'), 'Dimension 3b must not contain a blocker-severity example — it is advisory only' ); }); test('existing Dimension 3 blocker severities are unchanged', () => { // Independence: 3b is additive. The circular-dependency finding above it must // still block, or this change quietly downgraded a real gate. const d3Body = sliceBetween(agentDoc, D3_HEADING, D3B_HEADING); assert.match( d3Body, /severity:\s*blocker/, 'Dimension 3\'s own example issue must still be severity: blocker' ); assert.match( d3Body, /Circular dependency/, 'Dimension 3 must still carry its circular-dependency example' ); }); test('the finding reuses the dependency_correctness dimension key', () => { // Hyrum: anything consuming the checker's structured issues keys on `dimension`. // A new key would be a new observable contract; the issue asked for a finding // "under Dimension 3", not a new dimension. const span = sliceBetween(agentDoc, D3B_HEADING, D4_HEADING); assert.match( span, /dimension:\s*dependency_correctness/, 'Dimension 3b\'s example issue must reuse the dependency_correctness dimension key' ); }); }); describe('negative space is enumerated', () => { test('the sub-check enumerates its non-triggering cases', () => { const span = sliceBetween(agentDoc, D3B_HEADING, D4_HEADING); assert.match(span, /Do NOT flag/, 'Dimension 3b must carry an explicit non-triggering list'); // Each token below is a distinct exclusion class from the design's negative space. // Their absence is what produced false positives in the alternatives considered. for (const [token, why] of [ [/files_modified/, 'the file-overlap axis is already checked elsewhere — report it once'], [/depends_on/, 'a pair whose edge is already declared is not a finding'], [/different wave/i, 'the wave itself already orders the pair'], [/READ/, 'two readers of a shared resource are not coupled'], ]) { assert.match(span, token, `Dimension 3b non-triggering list must cover: ${why}`); } }); test('the sub-check defers transform conflicts to Dimension 9', () => { // Dimension 9 (Cross-Plan Data Contracts) owns incompatible transformations of a // shared entity. Without this boundary the same plan pair is reported twice. const span = sliceBetween(agentDoc, D3B_HEADING, D4_HEADING); assert.match( span, /Dimension 9/, 'Dimension 3b must defer incompatible-transform findings to Dimension 9' ); }); }); describe('the sub-check is wired into the agent\'s completion checklist', () => { test('success_criteria includes the coupling check', () => { const span = sliceBetween(agentDoc, //, /<\/success_criteria>/); assert.match( span, /^- \[ \] .*coupling.*$/im, 'the checklist must carry a line for the coupling check — ' + 'a rubric the agent is never told to run is not a check' ); }); }); describe('docs parity', () => { // Parity against the agent's own headings is self-maintaining; a hand-typed count is // not. The section previously claimed "8 Verification Dimensions" while enumerating // names that matched no dimension in the agent at all — a stale count reads as // authoritative, which is worse than no count. function agentDimensionLabels() { return [...stripFences(agentDoc).matchAll(/^## Dimension ([0-9]+[a-z]?): /gm)].map((m) => m[1]); } test('the agent defines a discoverable set of numbered dimensions', () => { const labels = agentDimensionLabels(); assert.ok( labels.length >= 12, `expected the agent to define at least 12 numbered dimensions, found ${labels.length}` ); assert.ok(labels.includes('3b'), 'Dimension 3b must be among the agent\'s numbered dimensions'); assert.strictEqual( new Set(labels).size, labels.length, `duplicate dimension labels in the agent: ${labels.join(', ')}` ); }); test('docs/AGENTS.md enumerates every dimension the agent defines', () => { const section = sliceBetween(docsAgents, /^### msd-plan-checker$/m, /^### msd-integration-checker$/m); const documented = new Set( [...section.matchAll(/^\| ([0-9]+[a-z]?) \| /gm)].map((m) => m[1]) ); const missing = agentDimensionLabels().filter((label) => !documented.has(label)); assert.deepStrictEqual( missing, [], `docs/AGENTS.md omits dimension(s): ${missing.join(', ')}` ); }); test('docs/AGENTS.md documents the coupling check', () => { const section = sliceBetween(docsAgents, /^### msd-plan-checker$/m, /^### msd-integration-checker$/m); assert.match( section, /coupling/i, 'docs/AGENTS.md\'s msd-plan-checker section must document the coupling check' ); }); }); describe('#3724 — the advisory contract holds across the wiring', () => { // The defect #3724 fixed lived in three places at once: the checker's tier, // the orchestrator's loop gate, and the planner's ignorance of the rule. // Each assertion here pins one side; reverting any one of them alone must // red this suite, because #3237 shipping 3b with no orchestration-side // assertion is exactly how the defect arrived. test('plan-phase accepts an INFO-only issues block without entering the revision loop', () => { const planPhase = fs.readFileSync(PLAN_PHASE_PATH, 'utf-8'); // Pins the full single-line paragraph: a reflow of that line in plan-phase.md // reds this find() — update the startsWith prefix and the regexes together. const paragraph = splitLines(planPhase).find((line) => line.startsWith('Parse issue count from checker return:') ); assert.ok(paragraph, 'plan-phase.md step 12 must carry the parse-issue-count paragraph'); assert.match( paragraph, /likewise when every entry in the block is explicitly INFO \(display them as advisories\)/, 'step 12 must accept only an explicitly-INFO issues block, surfacing the advisories (#3724 criterion 1)' ); }); test('plan-phase still counts BLOCKER + WARNING for the revision gate', () => { // Criterion 2's orchestration half: severity-blindness must not invert. // INFO joining the count would re-arm the loop; BLOCKER or WARNING // leaving it would let real defects through. const planPhase = fs.readFileSync(PLAN_PHASE_PATH, 'utf-8'); assert.match( planPhase, /count BLOCKER \+ WARNING entries in the YAML issues block/, 'step 12 must keep gating the revision loop on BLOCKER + WARNING counts' ); }); test('the checker recognizes coupling_justified as a Do-NOT-flag exemption', () => { const span = sliceBetween(agentDoc, D3B_HEADING, D4_HEADING); assert.match( span, /declared `coupling_justified` in either plan's frontmatter/, 'Dimension 3b must exempt a coupling_justified pair so intentional coupling can converge (#3724 criterion 4)' ); assert.match( span, /fix_hint:[^\n]*coupling_justified/, 'the 3b fix_hint must name coupling_justified so the planner learns the escape hatch' ); }); test('verify-work accepts an INFO-only issues block without entering its revision loop', () => { // Round-4 Blocker: verify_gap_plans is the SECOND multi-plan consumer of the // checker's sentinels (agent-contracts.md), spawning it over all phase plans with // no dimension override — so 3b is live there and its handler must be // severity-aware, or the guaranteed replan #3724 fixed survives on that surface. const verifyWork = fs.readFileSync(VERIFY_WORK_PATH, 'utf-8'); // Pins the full single-line handler: a reflow of that line in verify-work.md // reds this find() — update the startsWith prefix and the regexes together. const handler = splitLines(verifyWork).find((line) => line.startsWith('- **ISSUES FOUND:**') ); assert.ok(handler, 'verify-work.md must carry the ISSUES FOUND handler line'); assert.match( handler, /Count BLOCKER \+ WARNING/, 'the verify_gap_plans handler must gate its revision loop on BLOCKER + WARNING counts' ); assert.match( handler, /every entry is explicitly INFO/, 'the verify_gap_plans handler must accept only an explicitly-INFO issues block (whitelist, not count-zero)' ); }); test('plan-phase iteration cap recounts severities instead of gating on advisories', () => { // Round-4 Minor 1: the INFO-only accept must hold at iteration_count >= 3 too, // or an advisory-only third check halts the workflow on a "0 issues remain" // user gate. This is a prose pin, not an executed boundary check: the >= 3 arm's // text is what both the limit and limit+1 iterations land on, so one string // match covers both — nothing here runs a counter (round-5 Minor 4). const planPhase = fs.readFileSync(PLAN_PHASE_PATH, 'utf-8'); const lines = splitLines(planPhase); const armIndex = lines.findIndex((line) => line.startsWith('**If iteration_count >= 3:**')); assert.ok(armIndex >= 0, 'plan-phase.md must carry the iteration_count >= 3 arm'); const armWindow = lines.slice(armIndex, armIndex + 5).join(' '); assert.match( armWindow, /Recount BLOCKER \+ WARNING/, 'the >= 3 arm must recount BLOCKER + WARNING before gating' ); assert.match( armWindow, /explicitly INFO — display any advisories and proceed to step 13/, 'an INFO-only result at the iteration cap must accept, not halt on the user gate' ); }); test('the fail-closed severity rule is verbatim-identical on all three gate surfaces', () => { // Round-5 Blockers 2+3: a count-based accept ("BLOCKER + WARNING count is zero") // is also true for an entry whose severity is missing, misspelled, or // unrecognized — auto-accepting what base sent to the revision loop. All three // gates carry one canonical clause, asserted verbatim, so the predicates cannot // drift apart again (Generative Fix Divergence). const CLAUSE = 'an entry whose severity is missing or unrecognized counts as a BLOCKER (fail closed)'; const planPhase = fs.readFileSync(PLAN_PHASE_PATH, 'utf-8'); const verifyWork = fs.readFileSync(VERIFY_WORK_PATH, 'utf-8'); const lines = splitLines(planPhase); const parseLine = lines.find((line) => line.startsWith('Parse issue count from checker return:')); assert.ok( parseLine && parseLine.includes(CLAUSE), 'the plan-phase iteration_count < 3 arm must carry the fail-closed clause verbatim' ); const armIndex = lines.findIndex((line) => line.startsWith('**If iteration_count >= 3:**')); assert.ok(armIndex >= 0, 'plan-phase.md must carry the iteration_count >= 3 arm'); const armWindow = lines.slice(armIndex, armIndex + 5).join(' '); assert.ok( armWindow.includes(CLAUSE), 'the plan-phase iteration_count >= 3 arm must carry the fail-closed clause verbatim' ); const handler = splitLines(verifyWork).find((line) => line.startsWith('- **ISSUES FOUND:**')); assert.ok( handler && handler.includes(CLAUSE), 'the verify-work verify_gap_plans handler must carry the fail-closed clause verbatim' ); // Round-7 Blocker + Major: the checker's INFO-only ## ISSUES FOUND contract is // dimension-agnostic and reaches every consumer, so the two remaining // severity-blind handlers get the same clause — quick mode (issue-named in // #3724) and import's plan_validate (absent even from agent-contracts.md // until this round). const quickLoop = fs.readFileSync(QUICK_LOOP_PATH, 'utf-8'); const quickHandler = splitLines(quickLoop).find((line) => line.startsWith('- **`## ISSUES FOUND`:**')); assert.ok( quickHandler && quickHandler.includes(CLAUSE), 'the quick-mode plan-checker-loop handler must carry the fail-closed clause verbatim' ); const importDoc = fs.readFileSync(IMPORT_PATH, 'utf-8'); const importHandler = splitLines(importDoc).find((line) => line.startsWith('Handle the checker return by severity')); assert.ok( importHandler && importHandler.includes(CLAUSE), 'the import plan_validate handler must carry the fail-closed clause verbatim' ); }); test('quick mode and import accept an explicitly-INFO-only issues block', () => { const quickLoop = fs.readFileSync(QUICK_LOOP_PATH, 'utf-8'); const quickHandler = splitLines(quickLoop).find((line) => line.startsWith('- **`## ISSUES FOUND`:**')); assert.ok(quickHandler, 'plan-checker-loop.md must carry the ISSUES FOUND handler line'); assert.match( quickHandler, /every entry is explicitly INFO/, 'quick mode must accept only an explicitly-INFO issues block (whitelist, not count-zero)' ); assert.match( quickHandler, /proceed to step 6/, 'an INFO-only result in quick mode must proceed, not enter the revision loop' ); const importDoc = fs.readFileSync(IMPORT_PATH, 'utf-8'); const importHandler = splitLines(importDoc).find((line) => line.startsWith('Handle the checker return by severity')); assert.ok(importHandler, 'import.md plan_validate must carry the severity-aware handler paragraph'); assert.match( importHandler, /never blocks an import/, 'an INFO-only checker return must not block an import' ); const contracts = fs.readFileSync(AGENT_CONTRACTS_PATH, 'utf-8'); const checkerRow = splitLines(contracts).find((line) => line.startsWith('| msd-plan-checker |')); assert.ok( checkerRow && checkerRow.includes('msd-core/workflows/import.md'), 'agent-contracts.md must list import.md as a msd-plan-checker sentinel consumer' ); }); test('an applied coupling_justified exemption stays observable', () => { // Round-7 Minor: the exemption fires from a one-sided, schema-unvalidated // declaration; without a surfaced note, a stale or copy-pasted entry // suppresses the check silently and permanently. const span = sliceBetween(agentDoc, D3B_HEADING, D4_HEADING); assert.match( span, /note the applied\s+exemption as its own `info` advisory/, 'Dimension 3b must surface an applied coupling_justified exemption as an info advisory' ); }); test('the checker returns ## ISSUES FOUND for an INFO-only result, with an advisories section', () => { // Round-5 Blocker 1: with 3b at severity info, an INFO-only result satisfied // the old step-10 `passed` rule and routed to ## VERIFICATION PASSED — a // template with no issues block — so the advisory this fix exists to surface // was dropped, and both orchestrator display clauses were unreachable. assert.match( agentDoc, /An INFO-only result is NOT `passed`/, 'step 10 must exclude an INFO-only result from `passed`' ); assert.match( agentDoc, /Return `## ISSUES FOUND` even when every issue is INFO/, 'step 10 must route an INFO-only result to ## ISSUES FOUND so the block reaches the orchestrator' ); assert.match( agentDoc, /### Advisories \(info\)/, 'the ISSUES FOUND template must carry an advisories section so INFO entries render' ); assert.match( agentDoc, /Advisory only — no revision required/, 'the recommendation must not claim a planner return for an INFO-only result' ); }); test('the planner routes to the coupling reference, and the reference teaches the rule', () => { const planner = fs.readFileSync(PLANNER_PATH, 'utf-8'); assert.match( planner, /@~\/\.claude\/msd-core\/references\/planner-coupling\.md/, 'msd-planner.md must point at the planner-coupling reference (#3724 criterion 3)' ); assert.ok( fs.existsSync(PLANNER_COUPLING_REF_PATH), 'msd-core/references/planner-coupling.md must exist — the planner pointer routes there' ); const ref = fs.readFileSync(PLANNER_COUPLING_REF_PATH, 'utf-8'); assert.match( ref, /mutable\s+resource/i, 'planner-coupling.md must state the shared-mutable-resource rule' ); assert.match( ref, /coupling_justified/, 'planner-coupling.md must document the coupling_justified declaration' ); assert.match( ref, /Dimension 3b/, 'planner-coupling.md must name the verifying side (Dimension 3b)' ); }); }); });