diff --git a/CHANGELOG.md b/CHANGELOG.md
index b67a935..33be304 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [0.20.0] - 2026-09-17
+
+### Fixed
+- Reachability metadata no longer declares dynamic edges that this repository does not have.
+
## [0.19.0] - 2026-09-16
### Added
diff --git a/README.md b/README.md
index ffef213..3c53454 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
# XChain Platform Encoder
-
+
diff --git a/bin/reachability.js b/bin/reachability.js
index 6e44ae5..68c6ae9 100644
--- a/bin/reachability.js
+++ b/bin/reachability.js
@@ -36,7 +36,7 @@
* so the two tools can never disagree about what a reference is.
*
* A file outside all four is unreferenced across the platform and is the only
- * shape a dead-code sweep deletes outright.
+ * shape a restructure deletes outright.
*
* THE SIBLING REACH INCLUDES THE PLATFORM TOOLING, and it has to. The map tool
* sweeps the `xchain-*` siblings by default and treats the surrounding tree's
@@ -124,41 +124,37 @@ function toolingSweepDirs(siblingsRoot) {
* Each entry names the site that builds the path and what it resolves to, so a
* reader can check the claim instead of trusting the table.
*/
-const DYNAMIC_EDGES = [
- {
- from: 'src/consensus_rules_digest.js',
- // loadGateValues requires './.js' for every SHARED_GATES row, so
- // the gate carriers are held by the digest and not by any literal. The
- // list is read from the module rather than restated, because a restated
- // copy is a second registry that drifts.
- toList: () => {
- const { SHARED_GATES } = require('../src/consensus_rules_digest.js');
- return SHARED_GATES.map(([mod]) => `src/${mod}.js`);
- },
- why: 'the consensus-rules digest requires every SHARED_GATES module by computed path',
- },
- {
- from: 'src/db/index.js',
- // The mixin install loop calls require(file) over its MIXIN_FILES rows, so
- // not one literal in the file names a mixin and all of src/db/ reads
- // unreachable without this edge. The list is read out of the declaration
- // rather than restated here, because a restated copy is a second registry
- // that drifts away from the one the loop actually walks.
- toList: () => {
- const declared = fs.readFileSync(path.join(REPO_ROOT, 'src/db/index.js'), 'utf8');
- const block = /const MIXIN_FILES = \[([\s\S]*?)\];/.exec(declared);
- if (!block) {
- throw new Error('src/db/index.js no longer declares MIXIN_FILES: the mixin edge cannot be read');
- }
- const rows = Array.from(block[1].matchAll(/(['"])([^'"]+)\1/g))
- .map((m) => resolveRequire('src/db/index.js', m[2]))
- .filter(Boolean);
- if (!rows.length) throw new Error('MIXIN_FILES declares no resolvable mixin: the edge is stale');
- return rows;
- },
- why: 'the Database mixin install loop requires every MIXIN_FILES row by computed path',
- },
-];
+// Both entries below were copied out of xchain-indexer/bin/reachability.js by the
+// twin-copier and never adapted: this repo has neither src/consensus_rules_digest.js
+// (SHARED_GATES) nor a src/db/index.js that installs mixins from a MIXIN_FILES list
+// (confirmed by grep: no computed `require(...)` of any kind appears under this
+// repo's src/, only literal specifiers). Declaring edges from files this repo does
+// not have made the summary report `declared dynamic edges: 2` while the walk
+// applied them zero times (edgesFrom only fires when the CURRENT file is the
+// declared `from`, so a `from` outside fileSet is never visited), which is
+// confidence the tool had not earned. This repo has no computed require to
+// declare, so the list is empty; assertDynamicEdgesResolve below is what makes a
+// future stale or copy-pasted entry fail loudly instead of repeating this.
+const DYNAMIC_EDGES = [];
+
+/**
+ * A declared dynamic edge whose `from` file is not in this repo's tracked tree
+ * cannot ever fire (edgesFrom below only applies an edge while walking its exact
+ * `from` file), so the walk would silently treat it as zero edges applied while
+ * the summary still counted it as one of N declared. That is exactly how this file
+ * spent an unknown span reporting `declared dynamic edges: 2` for edges resolving
+ * nowhere. Checked eagerly, by name, so a stale or mis-copied declaration fails the
+ * run instead of passing as an unearned confidence figure.
+ */
+function assertDynamicEdgesResolve(fileSet) {
+ for (const edge of DYNAMIC_EDGES) {
+ if (!fileSet.has(edge.from)) {
+ throw new Error(`DYNAMIC_EDGES declares an edge from '${edge.from}', which is not in this `
+ + `repo's tracked tree, so it can never apply and would otherwise pass as one of `
+ + `${DYNAMIC_EDGES.length} declared edges. Fix or remove it in bin/reachability.js (${edge.why}).`);
+ }
+ }
+}
const SOURCE_EXT = ['.js'];
@@ -324,6 +320,7 @@ function entriesUnder(prefixes, fileSet) {
function analyse(opts) {
const all = trackedFiles();
const fileSet = new Set(all.filter((f) => f.endsWith('.js')));
+ assertDynamicEdgesResolve(fileSet);
const sources = Array.from(fileSet).filter((f) => f.startsWith('src/')).sort();
const runtimeEntryList = runtimeEntries(fileSet);
@@ -442,4 +439,6 @@ function main() {
if (require.main === module) main();
-module.exports = { analyse, closure, runtimeEntries, resolveRequire, toolingSweepDirs, DYNAMIC_EDGES };
+module.exports = {
+ analyse, closure, runtimeEntries, resolveRequire, toolingSweepDirs, DYNAMIC_EDGES, assertDynamicEdgesResolve,
+};
diff --git a/bin/test/reachability_dynamic_edges.test.js b/bin/test/reachability_dynamic_edges.test.js
new file mode 100644
index 0000000..e82ef72
--- /dev/null
+++ b/bin/test/reachability_dynamic_edges.test.js
@@ -0,0 +1,66 @@
+/*********************************************************************
+ *
+ * Copyright © 2025-2026 Dankest, LLC
+ * Based on XChain Platform by Dankest, LLC - https://dankest.llc
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ *
+ * This file is part of XChain Platform. Licensed under the GNU Affero
+ * General Public License v3.0 or later; see LICENSE.md.
+ *
+ **********************************************************************
+ *
+ * bin/reachability.js used to declare two DYNAMIC_EDGES (src/consensus_rules_digest.js
+ * and src/db/index.js) copied verbatim out of xchain-indexer's twin, neither of which
+ * this repo has. edgesFrom() only applies a declared edge while it is walking the exact
+ * file the edge names as `from`, so a `from` this repo never tracks is never visited and
+ * the edge quietly applies zero times, while the summary still reported
+ * `declared dynamic edges: 2`, confidence the tool had not earned. This suite drives the
+ * fix: DYNAMIC_EDGES is empty because this repo has no computed require, and a future
+ * stale declaration fails loudly instead of repeating the silent pass.
+ *
+ * Outside test/ on purpose, matching bin/test/suite_title_split_map.test.js: run it
+ * directly.
+ *
+ * npx mocha --no-config --timeout 30000 bin/test/reachability_dynamic_edges.test.js
+ *
+ ********************************************************************/
+
+'use strict';
+
+const assert = require('assert');
+
+const { analyse, DYNAMIC_EDGES, assertDynamicEdgesResolve } = require('../reachability.js');
+
+describe('xchain-encoder bin/reachability.js: DYNAMIC_EDGES only names paths this repo has', () => {
+ it('declares no dynamic edge, because this repo has no computed require', () => {
+ assert.deepStrictEqual(DYNAMIC_EDGES, []);
+ });
+
+ it('reports dynamicEdgesDeclared: 0 in the summary, not a stale count', () => {
+ const report = analyse({ siblings: false });
+ assert.strictEqual(report.summary.dynamicEdgesDeclared, 0);
+ });
+
+ it('does not throw against this repo\'s own tracked tree', () => {
+ assert.doesNotThrow(() => assertDynamicEdgesResolve(new Set(['src/api.js'])));
+ });
+
+ it('fails loudly, by name, when a declared edge names a file the tree does not have', () => {
+ // Mutates the real exported DYNAMIC_EDGES array (module-cached, so this is
+ // the same array analyse() reads) rather than a re-implemented copy of the
+ // check, then restores it in `finally` so no other test in this process
+ // sees the injected entry.
+ const bogus = { from: 'src/does_not_exist.js', why: 'a stale or copy-pasted declaration' };
+ DYNAMIC_EDGES.push(bogus);
+ try {
+ assert.throws(
+ () => assertDynamicEdgesResolve(new Set(['src/api.js'])),
+ /src\/does_not_exist\.js/,
+ );
+ } finally {
+ DYNAMIC_EDGES.pop();
+ }
+ assert.deepStrictEqual(DYNAMIC_EDGES, [], 'DYNAMIC_EDGES must be restored empty after the injected edge');
+ });
+});
diff --git a/package-lock.json b/package-lock.json
index 229d974..de1c2b4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "xchain-encoder",
- "version": "0.19.0",
+ "version": "0.20.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "xchain-encoder",
- "version": "0.19.0",
+ "version": "0.20.0",
"license": "AGPL-3.0-or-later",
"dependencies": {
"axios": "^1.18.1",
diff --git a/package.json b/package.json
index 8c44764..e0ecac8 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "xchain-encoder",
"description": "xchain-encoder encodes XChain Platform ACTION commands into blockchain transactions.",
- "version": "0.19.0",
+ "version": "0.20.0",
"license": "AGPL-3.0-or-later",
"repository": {
"type": "git",