harness-everything 0.49.0 → 0.50.0

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.
@@ -10,7 +10,7 @@
10
10
  "name": "harness-everything",
11
11
  "source": "./",
12
12
  "description": "Harness OS: an orchestrated agent operating system — tier routing, circuit breaker guardrails, verification gates, WAL session memory, and self-evolving workspace rules.",
13
- "version": "0.49.0",
13
+ "version": "0.50.0",
14
14
  "author": {
15
15
  "name": "Miya Daniel"
16
16
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "harness-everything",
3
3
  "description": "Harness OS: an orchestrated agent operating system — tier routing, circuit breaker guardrails, verification gates, WAL session memory, and self-evolving workspace rules.",
4
- "version": "0.49.0",
4
+ "version": "0.50.0",
5
5
  "author": {
6
6
  "name": "Miya Daniel"
7
7
  },
@@ -21,88 +21,6 @@ jobs:
21
21
  - name: Verify pilot instrumentation, sanitizer and offline replay
22
22
  run: python3 benchmarks/issue290-pilot/check.py
23
23
 
24
- doc-governance:
25
- runs-on: ubuntu-latest
26
- steps:
27
- - uses: actions/checkout@v7
28
- with:
29
- fetch-depth: 0
30
- - uses: actions/setup-node@v7
31
- with:
32
- node-version: 24
33
- - name: Run base-owned document governance guard
34
- env:
35
- HARNESS_GUARD_EVENT: ${{ github.event_name }}
36
- HARNESS_GUARD_BASE_SHA: ${{ github.event.pull_request.base.sha }}
37
- HARNESS_GUARD_CANDIDATE_SHA: ${{ github.event.pull_request.head.sha }}
38
- run: |
39
- node -e 'const crypto = require("node:crypto");
40
- const fs = require("node:fs");
41
- const os = require("node:os");
42
- const path = require("node:path");
43
- const { spawnSync } = require("node:child_process");
44
- (function main() {
45
- const guardPath = "scripts/doc-governance-guard.js";
46
- const event = process.env.HARNESS_GUARD_EVENT || "unknown";
47
- const baseInput = process.env.HARNESS_GUARD_BASE_SHA || "";
48
- const candidateInput = process.env.HARNESS_GUARD_CANDIDATE_SHA || "";
49
- function warn(state, message) {
50
- console.log("::warning title=" + state + "::" + message);
51
- if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, "## Document governance guard\n\n**" + state + "**: " + message + "\n\n", "utf8");
52
- }
53
- function git(args) {
54
- const result = spawnSync("git", args, { encoding: "buffer", maxBuffer: 16 * 1024 * 1024, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", GIT_NO_REPLACE_OBJECTS: "1", GIT_PAGER: "cat", GIT_TERMINAL_PROMPT: "0" } });
55
- if (result.error) throw result.error;
56
- if (result.status !== 0) throw new Error("git " + args[0] + " failed: " + Buffer.from(result.stderr || result.stdout || "").toString("utf8").trim());
57
- return Buffer.from(result.stdout || "");
58
- }
59
- function resolveCommit(input, label) {
60
- if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(input)) throw new Error(label + " is not a full commit SHA");
61
- const resolved = git(["rev-parse", "--verify", "--quiet", "--end-of-options", input + "^{commit}"]).toString("ascii").trim().toLowerCase();
62
- if (resolved !== input.toLowerCase()) throw new Error(label + " did not resolve to the supplied commit SHA");
63
- return resolved;
64
- }
65
- if (event !== "pull_request") {
66
- warn("UNCHECKED", "No externally selected PR base/head is available for event " + event + "; the base-owned guard was not executed.");
67
- return;
68
- }
69
- try {
70
- const base = resolveCommit(baseInput, "PR base SHA");
71
- const candidate = resolveCommit(candidateInput, "PR head SHA");
72
- const checkout = process.env.GITHUB_SHA || "unavailable";
73
- console.log("PR head SHA used by the guard: " + candidate);
74
- console.log("GITHUB_SHA used by checkout and the ordinary suite: " + checkout + " (the pull_request checkout may be a synthetic merge commit).");
75
- const entries = git(["ls-tree", "-z", "--full-tree", base, "--", guardPath]);
76
- if (entries.length === 0) {
77
- warn("BOOTSTRAP_UNCHECKED", "Trusted base " + base + " has no " + guardPath + "; candidate guard was not executed. Independent review of the frozen candidate guard is required.");
78
- return;
79
- }
80
- if (entries[entries.length - 1] !== 0) throw new Error("base guard tree entry is not NUL terminated");
81
- const entry = entries.subarray(0, entries.length - 1);
82
- const tab = entry.indexOf(9);
83
- if (tab < 0 || entry.subarray(tab + 1).toString("utf8") !== guardPath) throw new Error("base guard tree entry is malformed");
84
- const [mode, type, oid] = entry.subarray(0, tab).toString("ascii").split(" ");
85
- if (!(["100644", "100755"].includes(mode)) || type !== "blob" || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(oid || "")) throw new Error("base guard is not a regular Git blob");
86
- const guardBytes = git(["cat-file", "blob", oid]);
87
- const digest = crypto.createHash("sha256").update(guardBytes).digest("hex");
88
- console.log("Base guard blob: " + oid + "; SHA-256: " + digest);
89
- const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "harness-doc-governance-"));
90
- const tempGuard = path.join(tempDir, "doc-governance-guard.js");
91
- try {
92
- fs.writeFileSync(tempGuard, guardBytes, { mode: 0o600 });
93
- const result = spawnSync(process.execPath, [tempGuard, "--repo", process.cwd(), "--base", base, "--candidate", candidate], { encoding: "buffer", maxBuffer: 64 * 1024 * 1024, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", GIT_NO_REPLACE_OBJECTS: "1", GIT_PAGER: "cat", GIT_TERMINAL_PROMPT: "0" } });
94
- if (result.error) throw result.error;
95
- if (result.stdout) process.stdout.write(result.stdout);
96
- if (result.stderr) process.stderr.write(result.stderr);
97
- process.exitCode = result.status === null ? 1 : result.status;
98
- } finally {
99
- fs.rmSync(tempDir, { recursive: true, force: true });
100
- }
101
- } catch (error) {
102
- console.error("Document governance guard setup failed: " + (error.message || String(error)));
103
- process.exitCode = 1;
104
- }
105
- })();'
106
24
  self-regression:
107
25
  strategy:
108
26
  fail-fast: false
@@ -0,0 +1,91 @@
1
+ name: Document Governance
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ workflow_call:
8
+
9
+ jobs:
10
+ doc-governance:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v7
14
+ with:
15
+ fetch-depth: 0
16
+ - uses: actions/setup-node@v7
17
+ with:
18
+ node-version: 24
19
+ - name: Run base-owned document governance guard
20
+ env:
21
+ HARNESS_GUARD_EVENT: ${{ github.event_name }}
22
+ HARNESS_GUARD_BASE_SHA: ${{ github.event.pull_request.base.sha }}
23
+ HARNESS_GUARD_CANDIDATE_SHA: ${{ github.event.pull_request.head.sha }}
24
+ run: |
25
+ node -e 'const crypto = require("node:crypto");
26
+ const fs = require("node:fs");
27
+ const os = require("node:os");
28
+ const path = require("node:path");
29
+ const { spawnSync } = require("node:child_process");
30
+ (function main() {
31
+ const guardPath = "scripts/doc-governance-guard.js";
32
+ const event = process.env.HARNESS_GUARD_EVENT || "unknown";
33
+ const baseInput = process.env.HARNESS_GUARD_BASE_SHA || "";
34
+ const candidateInput = process.env.HARNESS_GUARD_CANDIDATE_SHA || "";
35
+ function warn(state, message) {
36
+ console.log("::warning title=" + state + "::" + message);
37
+ if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, "## Document governance guard\n\n**" + state + "**: " + message + "\n\n", "utf8");
38
+ }
39
+ function git(args) {
40
+ const result = spawnSync("git", args, { encoding: "buffer", maxBuffer: 16 * 1024 * 1024, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", GIT_NO_REPLACE_OBJECTS: "1", GIT_PAGER: "cat", GIT_TERMINAL_PROMPT: "0" } });
41
+ if (result.error) throw result.error;
42
+ if (result.status !== 0) throw new Error("git " + args[0] + " failed: " + Buffer.from(result.stderr || result.stdout || "").toString("utf8").trim());
43
+ return Buffer.from(result.stdout || "");
44
+ }
45
+ function resolveCommit(input, label) {
46
+ if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(input)) throw new Error(label + " is not a full commit SHA");
47
+ const resolved = git(["rev-parse", "--verify", "--quiet", "--end-of-options", input + "^{commit}"]).toString("ascii").trim().toLowerCase();
48
+ if (resolved !== input.toLowerCase()) throw new Error(label + " did not resolve to the supplied commit SHA");
49
+ return resolved;
50
+ }
51
+ if (event !== "pull_request") {
52
+ warn("UNCHECKED", "No externally selected PR base/head is available for event " + event + "; the base-owned guard was not executed.");
53
+ return;
54
+ }
55
+ try {
56
+ const base = resolveCommit(baseInput, "PR base SHA");
57
+ const candidate = resolveCommit(candidateInput, "PR head SHA");
58
+ const checkout = process.env.GITHUB_SHA || "unavailable";
59
+ console.log("PR head SHA used by the guard: " + candidate);
60
+ console.log("GITHUB_SHA used by checkout and the ordinary suite: " + checkout + " (the pull_request checkout may be a synthetic merge commit).");
61
+ const entries = git(["ls-tree", "-z", "--full-tree", base, "--", guardPath]);
62
+ if (entries.length === 0) {
63
+ warn("BOOTSTRAP_UNCHECKED", "Trusted base " + base + " has no " + guardPath + "; candidate guard was not executed. Independent review of the frozen candidate guard is required.");
64
+ return;
65
+ }
66
+ if (entries[entries.length - 1] !== 0) throw new Error("base guard tree entry is not NUL terminated");
67
+ const entry = entries.subarray(0, entries.length - 1);
68
+ const tab = entry.indexOf(9);
69
+ if (tab < 0 || entry.subarray(tab + 1).toString("utf8") !== guardPath) throw new Error("base guard tree entry is malformed");
70
+ const [mode, type, oid] = entry.subarray(0, tab).toString("ascii").split(" ");
71
+ if (!(["100644", "100755"].includes(mode)) || type !== "blob" || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(oid || "")) throw new Error("base guard is not a regular Git blob");
72
+ const guardBytes = git(["cat-file", "blob", oid]);
73
+ const digest = crypto.createHash("sha256").update(guardBytes).digest("hex");
74
+ console.log("Base guard blob: " + oid + "; SHA-256: " + digest);
75
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "harness-doc-governance-"));
76
+ const tempGuard = path.join(tempDir, "doc-governance-guard.js");
77
+ try {
78
+ fs.writeFileSync(tempGuard, guardBytes, { mode: 0o600 });
79
+ const result = spawnSync(process.execPath, [tempGuard, "--repo", process.cwd(), "--base", base, "--candidate", candidate], { encoding: "buffer", maxBuffer: 64 * 1024 * 1024, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", GIT_NO_REPLACE_OBJECTS: "1", GIT_PAGER: "cat", GIT_TERMINAL_PROMPT: "0" } });
80
+ if (result.error) throw result.error;
81
+ if (result.stdout) process.stdout.write(result.stdout);
82
+ if (result.stderr) process.stderr.write(result.stderr);
83
+ process.exitCode = result.status === null ? 1 : result.status;
84
+ } finally {
85
+ fs.rmSync(tempDir, { recursive: true, force: true });
86
+ }
87
+ } catch (error) {
88
+ console.error("Document governance guard setup failed: " + (error.message || String(error)));
89
+ process.exitCode = 1;
90
+ }
91
+ })();'
@@ -19,12 +19,15 @@ permissions:
19
19
  id-token: write
20
20
 
21
21
  jobs:
22
- # Release publication is gated by exactly the same test matrix as a PR.
22
+ # Release publication waits for the full CI matrix and document governance.
23
23
  gate:
24
24
  uses: ./.github/workflows/ci.yml
25
25
 
26
+ doc-governance:
27
+ uses: ./.github/workflows/doc-governance.yml
28
+
26
29
  release:
27
- needs: gate
30
+ needs: [gate, doc-governance]
28
31
  runs-on: ubuntu-latest
29
32
  steps:
30
33
  - name: Checkout full release history
package/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  All notable changes to this project are documented in this file.
4
4
 
5
+ ## [0.50.0](https://github.com/dyphn1/Harness-everything/compare/v0.49.0...v0.50.0) (2026-11-09)
6
+
7
+ ### Features
8
+
9
+ * **governance:** split the guard workflow and protect only authoritative doc membership ([5a46e67](https://github.com/dyphn1/Harness-everything/commit/5a46e673525a28f9df770b8eefe2dce4bf7ae9e8))
10
+
11
+ ### Bug Fixes
12
+
13
+ * **governance:** keep the extracted guard job in the release gate ([24c3b10](https://github.com/dyphn1/Harness-everything/commit/24c3b10a6530dd33fff22788a2219bd36ceb1447))
14
+
5
15
  ## [0.49.0](https://github.com/dyphn1/Harness-everything/compare/v0.48.0...v0.49.0) (2026-11-08)
6
16
 
7
17
  ### Features
@@ -1625,15 +1625,25 @@ checkCase('2ax. frozen Git-object guard with bootstrap and tamper controls', ()
1625
1625
  'plugins/harness-everything/skills/harness-everything/scripts/project-docs-resolver.js',
1626
1626
  '.agents/harness-everything/project-docs.json',
1627
1627
  '.agents/harness-everything/document-inventory.json',
1628
- '.github/workflows/ci.yml',
1628
+ '.agents/harness-everything/errors/README.md',
1629
+ '.github/workflows/doc-governance.yml',
1629
1630
  'package.json',
1630
1631
  'self-evolve/scripts/self-regression.js',
1631
1632
  'ci/mechanism-test.js',
1632
1633
  'ci/mutation-check.js',
1633
1634
  'ci/lib/isolated-test-process.js',
1634
1635
  'ci/test-helper.js',
1636
+ 'scripts/errors-sync.js',
1635
1637
  'scripts/verify-all.js'
1636
1638
  ];
1639
+ const membershipPaths = ['AGENTS.md', 'SECURITY.md', 'CODE_OF_CONDUCT.md', 'PRIVACY.md', 'TERMS.md', 'docs/document-lifecycle.md'];
1640
+ const membershipPrefixPaths = ['docs/policy/existing.md', 'docs/prd/future.md'];
1641
+ const fixtureSupportPaths = [
1642
+ '.github/workflows/ci.yml',
1643
+ 'benchmarks/issue290-pilot/README.md',
1644
+ '.agents/harness-everything/errors/E-0001-fixture.md',
1645
+ ...membershipPrefixPaths
1646
+ ];
1637
1647
  const frozenGuard = fs.readFileSync(governanceGuardPath);
1638
1648
  const frozenTest = fs.readFileSync(__filename);
1639
1649
  const frozenGuardPath = path.join(tempRoot, 'frozen-doc-governance-guard.js');
@@ -1680,6 +1690,13 @@ checkCase('2ax. frozen Git-object guard with bootstrap and tamper controls', ()
1680
1690
  : fs.readFileSync(path.join(REPOSITORY_ROOT, ...relativePath.split('/')));
1681
1691
  writeRelative(root, relativePath, bytes);
1682
1692
  }
1693
+ for (const relativePath of membershipPaths) writeRelative(root, relativePath, fs.readFileSync(path.join(REPOSITORY_ROOT, relativePath)));
1694
+ for (const relativePath of fixtureSupportPaths) {
1695
+ const bytes = relativePath === '.github/workflows/ci.yml'
1696
+ ? fs.readFileSync(path.join(REPOSITORY_ROOT, relativePath))
1697
+ : Buffer.from(`baseline ${relativePath}\n`);
1698
+ writeRelative(root, relativePath, bytes);
1699
+ }
1683
1700
  const copyScenarioTree = (from, into) => {
1684
1701
  fs.mkdirSync(into, { recursive: true });
1685
1702
  for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
@@ -1715,6 +1732,9 @@ checkCase('2ax. frozen Git-object guard with bootstrap and tamper controls', ()
1715
1732
  }
1716
1733
 
1717
1734
  const fixture = createFixture();
1735
+ resetFixture(fixture);
1736
+ writeRelative(fixture.root, 'docs/adr/GRPH-001.md', 'baseline ADR\n');
1737
+ fixture.base = commitFixture(fixture.root, 'add ADR fixture member');
1718
1738
  const ordinaryPath = path.join(fixture.root, 'docs', 'ordinary.md');
1719
1739
  fs.writeFileSync(ordinaryPath, 'ordinary document edit\n');
1720
1740
  // Git for Windows rejects LF paths even when they are added directly to the index.
@@ -1737,6 +1757,9 @@ checkCase('2ax. frozen Git-object guard with bootstrap and tamper controls', ()
1737
1757
  assert.strictEqual(positive.report.verifier.sha256, crypto.createHash('sha256').update(frozenGuard).digest('hex'));
1738
1758
  assert.strictEqual(positive.report.verifier.matchesBaseObject, true, 'running verifier bytes match the baseline guard blob');
1739
1759
  assert.deepStrictEqual(positive.report.trustSurface.paths.slice(0, trustedPaths.length).map(item => item.path), trustedPaths, 'fixed trust surface is explicit');
1760
+ for (const relativePath of [...membershipPaths, ...membershipPrefixPaths]) {
1761
+ assert.ok(positive.report.trustSurface.paths.some(item => item.path === relativePath), `${relativePath} is an explicit membership-only path`);
1762
+ }
1740
1763
  const scenarioExpectedPath = 'ci/fixtures/doc-lifecycle/scenarios/active-to-active/expected.json';
1741
1764
  const scenarioOracle = positive.report.trustSurface.paths.find(item => item.path === scenarioExpectedPath);
1742
1765
  assert.ok(scenarioOracle, 'tracked scenario oracle is part of the baseline trust surface');
@@ -1784,6 +1807,90 @@ checkCase('2ax. frozen Git-object guard with bootstrap and tamper controls', ()
1784
1807
  assert.strictEqual(unchanged.status, 0, 'an unchanged tree passes');
1785
1808
  assert.strictEqual(unchanged.report.verdict, 'PASS');
1786
1809
 
1810
+ resetFixture(fixture);
1811
+ const ciWorkflowPath = path.join(fixture.root, '.github/workflows/ci.yml');
1812
+ const ciWorkflow = fs.readFileSync(ciWorkflowPath, 'utf8');
1813
+ assert.ok(ciWorkflow.includes(' pilot-offline-evidence:\n runs-on: ubuntu-latest'), 'unrelated CI job fixture exists');
1814
+ writeRelative(fixture.root, '.github/workflows/ci.yml', ciWorkflow.replace(' pilot-offline-evidence:\n runs-on: ubuntu-latest', ' pilot-offline-evidence:\n name: Updated unrelated CI job\n runs-on: ubuntu-latest'));
1815
+ const unrelatedCiCandidate = commitFixture(fixture.root, 'change unrelated CI job');
1816
+ const unrelatedCi = runFrozenGuard(fixture, fixture.base, unrelatedCiCandidate);
1817
+ assert.strictEqual(unrelatedCi.status, 0, 'unrelated ci.yml job changes pass');
1818
+ assert.strictEqual(unrelatedCi.report.verdict, 'PASS');
1819
+
1820
+ resetFixture(fixture);
1821
+ writeRelative(fixture.root, '.github/workflows/doc-governance.yml', `${fs.readFileSync(path.join(fixture.root, '.github/workflows/doc-governance.yml'), 'utf8')}\n# guard workflow change\n`);
1822
+ const governanceWorkflowCandidate = commitFixture(fixture.root, 'change guard workflow');
1823
+ const governanceWorkflowChange = runFrozenGuard(fixture, fixture.base, governanceWorkflowCandidate);
1824
+ assert.strictEqual(governanceWorkflowChange.status, 2, 'the standalone guard workflow remains review-required');
1825
+ assert.strictEqual(governanceWorkflowChange.report.verdict, 'REVIEW_REQUIRED');
1826
+
1827
+ resetFixture(fixture);
1828
+ for (const relativePath of ['benchmarks/issue290-pilot/README.md', '.agents/harness-everything/errors/E-0001-fixture.md']) {
1829
+ fs.appendFileSync(path.join(fixture.root, ...relativePath.split('/')), '\nrecord update\n');
1830
+ }
1831
+ const recordsCandidate = commitFixture(fixture.root, 'change records only');
1832
+ const recordsOnly = runFrozenGuard(fixture, fixture.base, recordsCandidate);
1833
+ assert.strictEqual(recordsOnly.status, 0, 'benchmark and error-ledger records are outside the governance verdict');
1834
+ assert.strictEqual(recordsOnly.report.verdict, 'PASS');
1835
+
1836
+ resetFixture(fixture);
1837
+ writeRelative(fixture.root, '.github/workflows/doc-governance.yml', `${fs.readFileSync(path.join(fixture.root, '.github/workflows/doc-governance.yml'), 'utf8')}\n# guard workflow change\n`);
1838
+ fs.appendFileSync(path.join(fixture.root, '.agents/harness-everything/errors/E-0001-fixture.md'), '\nrecord update\n');
1839
+ const mixedCandidate = commitFixture(fixture.root, 'change T1 and T3');
1840
+ assert.strictEqual(runFrozenGuard(fixture, fixture.base, mixedCandidate).report.verdict, 'REVIEW_REQUIRED', 'a T1 change controls a mixed T1 and T3 change');
1841
+
1842
+ for (const relativePath of membershipPaths) {
1843
+ resetFixture(fixture);
1844
+ fs.appendFileSync(path.join(fixture.root, relativePath), '\nordinary content revision\n');
1845
+ const candidate = commitFixture(fixture.root, `revise ${relativePath}`);
1846
+ const result = runFrozenGuard(fixture, fixture.base, candidate);
1847
+ assert.strictEqual(result.report.verdict, 'PASS', `${relativePath} content revision does not require U5`);
1848
+ }
1849
+
1850
+ for (const relativePath of membershipPrefixPaths) {
1851
+ resetFixture(fixture);
1852
+ fs.appendFileSync(path.join(fixture.root, ...relativePath.split('/')), '\nordinary content revision\n');
1853
+ const candidate = commitFixture(fixture.root, `revise ${relativePath}`);
1854
+ assert.strictEqual(runFrozenGuard(fixture, fixture.base, candidate).report.verdict, 'PASS', `${relativePath} content revision does not require U5`);
1855
+ }
1856
+
1857
+ for (const relativePath of ['docs/policy/new.md', 'docs/prd/new.md']) {
1858
+ resetFixture(fixture);
1859
+ writeRelative(fixture.root, relativePath, 'new T1 member\n');
1860
+ const candidate = commitFixture(fixture.root, `add ${relativePath}`);
1861
+ assert.strictEqual(runFrozenGuard(fixture, fixture.base, candidate).report.verdict, 'REVIEW_REQUIRED', `adding ${relativePath} changes protected membership`);
1862
+ }
1863
+
1864
+ resetFixture(fixture);
1865
+ fixtureGit(fixture.root, ['rm', '--quiet', '--', 'AGENTS.md']);
1866
+ const deletedRootAgentsCandidate = commitFixture(fixture.root, 'delete root AGENTS');
1867
+ assert.strictEqual(runFrozenGuard(fixture, fixture.base, deletedRootAgentsCandidate).report.verdict, 'REVIEW_REQUIRED', 'deleting root AGENTS changes protected membership');
1868
+
1869
+ resetFixture(fixture);
1870
+ const changedRootAgentsModeCandidate = commitFixture(fixture.root, 'change root AGENTS mode', 'AGENTS.md');
1871
+ assert.strictEqual(runFrozenGuard(fixture, fixture.base, changedRootAgentsModeCandidate).report.verdict, 'REVIEW_REQUIRED', 'changing root AGENTS mode is not a content-only revision');
1872
+
1873
+ resetFixture(fixture);
1874
+ fixtureGit(fixture.root, ['mv', '--', 'AGENTS.md', 'docs/AGENTS.md']);
1875
+ const renamedRootAgentsCandidate = commitFixture(fixture.root, 'move root AGENTS');
1876
+ assert.strictEqual(runFrozenGuard(fixture, fixture.base, renamedRootAgentsCandidate).report.verdict, 'REVIEW_REQUIRED', 'moving root AGENTS out changes protected membership');
1877
+
1878
+ resetFixture(fixture);
1879
+ writeRelative(fixture.root, 'docs/adr/GRPH-001.md', 'revised ADR contents\n');
1880
+ const adrContentCandidate = commitFixture(fixture.root, 'revise ADR contents');
1881
+ assert.strictEqual(runFrozenGuard(fixture, fixture.base, adrContentCandidate).report.verdict, 'PASS', 'ADR content revisions do not require U5');
1882
+
1883
+ resetFixture(fixture);
1884
+ fixtureGit(fixture.root, ['rm', '--quiet', '--', 'docs/adr/GRPH-001.md']);
1885
+ const deletedAdrCandidate = commitFixture(fixture.root, 'delete ADR');
1886
+ assert.strictEqual(runFrozenGuard(fixture, fixture.base, deletedAdrCandidate).report.verdict, 'REVIEW_REQUIRED', 'deleting an ADR changes protected membership');
1887
+
1888
+ resetFixture(fixture);
1889
+ fs.mkdirSync(path.join(fixture.root, 'docs/records'), { recursive: true });
1890
+ fixtureGit(fixture.root, ['mv', '--', 'docs/adr/GRPH-001.md', 'docs/records/GRPH-001.md']);
1891
+ const movedAdrCandidate = commitFixture(fixture.root, 'move ADR out of governed folder');
1892
+ assert.strictEqual(runFrozenGuard(fixture, fixture.base, movedAdrCandidate).report.verdict, 'REVIEW_REQUIRED', 'moving an ADR out of the governed folder requires review');
1893
+
1787
1894
  const rawIdentity = entry => ({ mode: entry.mode, type: entry.type, oid: entry.oid });
1788
1895
  const configuredIdentity = (commit, relativePath) => {
1789
1896
  const tree = parseTreeOracle(fixtureGit(fixture.root, ['ls-tree', '-r', '-z', '--full-tree', commit]));
@@ -1813,11 +1920,12 @@ checkCase('2ax. frozen Git-object guard with bootstrap and tamper controls', ()
1813
1920
  writeRelative(fixture.root, 'docs/governed-index.md', 'changed derived output\n');
1814
1921
  const derivedCandidate = commitFixture(fixture.root, 'change derived document');
1815
1922
  const derivedChange = runFrozenGuard(fixture, fixture.base, derivedCandidate);
1816
- assert.strictEqual(derivedChange.status, 2, 'derived content change still requires review');
1817
- assert.strictEqual(derivedChange.report.verdict, 'REVIEW_REQUIRED');
1818
- const derivedEntry = derivedChange.report.policyPreservation.configuredPaths.missingOrChanged.find(item => item.path === 'docs/governed-index.md');
1819
- assert.ok(derivedEntry, 'derived content change remains in review-required paths');
1820
- assert.strictEqual(derivedEntry.state, 'review_required');
1923
+ assert.strictEqual(derivedChange.status, 0, 'generated-view content changes are left to ordinary CI');
1924
+ assert.strictEqual(derivedChange.report.verdict, 'PASS');
1925
+ const derivedEntry = derivedChange.report.policyPreservation.configuredPaths.paths.find(item => item.path === 'docs/governed-index.md');
1926
+ assert.ok(derivedEntry, 'derived content change remains visible in the report');
1927
+ assert.strictEqual(derivedEntry.state, 'pass');
1928
+ assert.match(derivedEntry.reason, /regenerate-equals CI/);
1821
1929
  assert.deepStrictEqual(derivedEntry.base, configuredIdentity(fixture.base, 'docs/governed-index.md'));
1822
1930
  assert.deepStrictEqual(derivedEntry.candidate, configuredIdentity(derivedCandidate, 'docs/governed-index.md'));
1823
1931
 
@@ -1930,8 +2038,10 @@ checkCase('2ax. frozen Git-object guard with bootstrap and tamper controls', ()
1930
2038
  writeRelative(globFixture.root, 'generated/view.md', 'changed derived glob output\n');
1931
2039
  const derivedGlobCandidate = commitFixture(globFixture.root, 'change derived glob member');
1932
2040
  const derivedGlob = runFrozenGuard(globFixture, globFixture.base, derivedGlobCandidate);
1933
- assert.strictEqual(derivedGlob.status, 2, 'a derived glob member content change requires review');
1934
- assert.strictEqual(derivedGlob.report.policyPreservation.configuredPaths.paths.find(item => item.path === 'generated/view.md').state, 'review_required');
2041
+ assert.strictEqual(derivedGlob.status, 0, 'a generated-view revision is left to ordinary CI');
2042
+ const derivedGlobEntry = derivedGlob.report.policyPreservation.configuredPaths.paths.find(item => item.path === 'generated/view.md');
2043
+ assert.strictEqual(derivedGlobEntry.state, 'pass');
2044
+ assert.match(derivedGlobEntry.reason, /regenerate-equals CI/);
1935
2045
 
1936
2046
  if (process.platform !== 'win32') {
1937
2047
  resetFixture(globFixture);
@@ -2119,11 +2229,20 @@ checkCase('2ax. frozen Git-object guard with bootstrap and tamper controls', ()
2119
2229
  assert.notStrictEqual(missingCommit.status, 0);
2120
2230
  assert.notStrictEqual(missingCommit.report.verdict, 'PASS');
2121
2231
 
2122
- checkCase('2ax. CI workflow keeps the base-owned guard in a failing standalone job', () => {
2123
- const workflowPath = path.join(REPOSITORY_ROOT, '.github/workflows/ci.yml');
2232
+ checkCase('2ax. guard has a standalone workflow outside the general CI workflow', () => {
2233
+ const workflowPath = path.join(REPOSITORY_ROOT, '.github/workflows/doc-governance.yml');
2234
+ assert.ok(fs.existsSync(workflowPath), 'dedicated document-governance workflow exists');
2235
+ });
2236
+
2237
+ checkCase('2ax. standalone workflow keeps the base-owned guard in a failing job', () => {
2238
+ const workflowPath = path.join(REPOSITORY_ROOT, '.github/workflows/doc-governance.yml');
2124
2239
  const workflow = require('js-yaml').load(fs.readFileSync(workflowPath, 'utf8'));
2240
+ const ciWorkflow = require('js-yaml').load(fs.readFileSync(path.join(REPOSITORY_ROOT, '.github/workflows/ci.yml'), 'utf8'));
2125
2241
  const job = workflow.jobs['doc-governance'];
2126
2242
  assert.ok(job, 'standalone doc-governance job exists');
2243
+ assert.ok(!ciWorkflow.jobs['doc-governance'], 'general CI workflow no longer carries the governance runner');
2244
+ assert.deepStrictEqual(workflow.on, ciWorkflow.on, 'standalone guard keeps the original CI workflow triggers');
2245
+ assert.ok(!/"paths(?:-ignore)?"/.test(JSON.stringify(workflow.on)), 'the guard workflow is not path-filtered');
2127
2246
  assert.strictEqual(job['runs-on'], 'ubuntu-latest');
2128
2247
  assert.notStrictEqual(job['continue-on-error'], true, 'the job does not mask guard failures');
2129
2248
  assert.ok(job.steps.some(step => step.uses === 'actions/checkout@v7' && step.with['fetch-depth'] === 0), 'job fetches base Git objects');
@@ -2131,7 +2250,6 @@ checkCase('2ax. frozen Git-object guard with bootstrap and tamper controls', ()
2131
2250
  const guardStep = job.steps.find(step => step.name === 'Run base-owned document governance guard');
2132
2251
  assert.ok(guardStep, 'standalone job owns the guard step');
2133
2252
  assert.notStrictEqual(guardStep['continue-on-error'], true, 'the guard step propagates nonzero exits');
2134
- assert.ok(!workflow.jobs['self-regression'].steps.some(step => step.name === guardStep.name), 'self-regression no longer runs the guard');
2135
2253
  const command = String(guardStep.run || '').trimEnd();
2136
2254
  const inline = command.match(/^node -e '([\s\S]*)'$/);
2137
2255
  assert.ok(inline, 'guard step remains a directly executable Node script');
@@ -2169,7 +2287,7 @@ checkCase('2ax. frozen Git-object guard with bootstrap and tamper controls', ()
2169
2287
  assert.match(passEvent.output, /"verdict": "PASS"/);
2170
2288
 
2171
2289
  resetFixture(fixture);
2172
- writeRelative(fixture.root, 'docs/governed-index.md', 'changed derived output for CI simulation\n');
2290
+ writeRelative(fixture.root, '.github/workflows/doc-governance.yml', `${fs.readFileSync(path.join(fixture.root, '.github/workflows/doc-governance.yml'), 'utf8')}\n# guard workflow change\n`);
2173
2291
  const reviewCandidate = commitFixture(fixture.root, 'simulate guard review failure');
2174
2292
  const reviewEvent = simulate('pull_request', fixture.base, reviewCandidate);
2175
2293
  assert.strictEqual(reviewEvent.status, 2, 'REVIEW_REQUIRED stays a failing workflow job');
@@ -2183,6 +2301,18 @@ checkCase('2ax. frozen Git-object guard with bootstrap and tamper controls', ()
2183
2301
  assert.strictEqual(result.status, exitCode, `the standalone job propagates guard exit ${exitCode}`);
2184
2302
  }
2185
2303
  });
2304
+
2305
+ checkCase('2ax. release gate calls and waits for the standalone document-governance workflow', () => {
2306
+ const releaseWorkflow = require('js-yaml').load(fs.readFileSync(path.join(REPOSITORY_ROOT, '.github/workflows/release.yml'), 'utf8'));
2307
+ const ciWorkflow = require('js-yaml').load(fs.readFileSync(path.join(REPOSITORY_ROOT, '.github/workflows/ci.yml'), 'utf8'));
2308
+ const governanceWorkflow = require('js-yaml').load(fs.readFileSync(path.join(REPOSITORY_ROOT, '.github/workflows/doc-governance.yml'), 'utf8'));
2309
+
2310
+ assert.strictEqual(releaseWorkflow.jobs.gate.uses, './.github/workflows/ci.yml', 'release keeps the CI reusable-workflow gate');
2311
+ assert.strictEqual(releaseWorkflow.jobs['doc-governance']?.uses, './.github/workflows/doc-governance.yml', 'release calls the extracted governance workflow');
2312
+ assert.deepStrictEqual(releaseWorkflow.jobs.release.needs, ['gate', 'doc-governance'], 'release waits for CI and document governance');
2313
+ assert.ok(!ciWorkflow.jobs['doc-governance'], 'CI does not duplicate the extracted governance runner');
2314
+ assert.ok(governanceWorkflow.jobs['doc-governance'], 'the called standalone workflow owns the governance job');
2315
+ });
2186
2316
  });
2187
2317
 
2188
2318
  helper.finish();
@@ -4,6 +4,25 @@ Contract for the documentation family: project docs configuration (D5), document
4
4
 
5
5
  Documentation is a **consumer** of the #320 workflow/obligation abstractions. Nothing here is a second router, a standing maintenance loop or a cognitive lock: every check is a pure function that reports diagnostics.
6
6
 
7
+ ## Governance tiers and placement (#401)
8
+
9
+ ```mermaid
10
+ flowchart TD
11
+ A["Document or path change"] --> B{"T1 scope?"}
12
+ B -->|membership, policy or guard implementation| C["REVIEW_REQUIRED"]
13
+ B -->|ordinary content revision| D["No U5; run lifecycle, conflict and link checks"]
14
+ B -->|no| E{"T3 evidence or notes?"}
15
+ E -->|yes| F["No document-governance verdict; ordinary CI still applies"]
16
+ E -->|no| G["T2: ordinary CI; generated views use regenerate-equals"]
17
+ ```
18
+
19
+ | Tier or convention | Scope and handling |
20
+ | --- | --- |
21
+ | **T1 — strict** | ADRs, `docs/policy/`, future `docs/prd/`, named authoritative docs, root `AGENTS.md`, `SECURITY.md`, `CODE_OF_CONDUCT.md`, `PRIVACY.md` and `TERMS.md`, the guard workflow, guard/doc-check/lifecycle/resolver code, policy JSON, the error-ledger schema/generator, `package.json`, generic runners/helpers and policy-bearing fixtures. Membership, delete/rename, policy-map and governance-implementation changes require review. Ordinary content revisions do not require U5; normal lifecycle, conflict and link checks still apply. Root files are membership-only. |
22
+ | **T2 — ordinary CI** | Other docs and skills, plus generated views checked by regenerate-equals. No document-governance verdict. |
23
+ | **T3 — records** | Benchmark/evidence payloads, non-policy fixtures, error-ledger records, `CHANGELOG.md` and notes receive no document-governance verdict; ordinary CI, security and provenance checks still apply. The ledger schema/generator and policy-bearing fixtures remain T1. |
24
+ | **Placement** | Put new authoritative docs in `docs/adr/`, `docs/policy/` or future `docs/prd/`; put observations in `docs/records/`. Keep existing authoritative files such as `docs/architecture.md`, `docs/doc-workflow-reconciliation.md` and this contract as named exceptions. No existing files move in this slice. |
25
+
7
26
  ## Scope of this slice (original 2026-10-20 checkpoint; Phase 3b/3c status updated 2026-10-25)
8
27
 
9
28
  This table distinguishes merged mechanism code from end-to-end workflow integration. The last reviewed `main` commit is `29b1d65b398e2622af97bf067078373b7fa76b26` (v0.36.2). That release commit carries `[skip ci]` and only bumps package/distribution versions on top of `e5d22584e243d5b5e587ee0279625e97a9033160`, whose full-gate `CI` run [39374474269](https://github.com/dyphn1/Harness-everything/actions/runs/39374474269) completed with every job succeeded. The historical Windows System One resident teardown (`S1-RS15`) was observed in attempt 1 of [run 39175240370](https://github.com/dyphn1/Harness-everything/actions/runs/39175240370) on `main@75e04359ae5bf11a41739f8eb04d25a76ee8f3a7`; that run completed successfully on attempt 2 and says nothing about `29b1d65`. Treat the resident teardown as a known platform limitation only when a current run on the exact SHA reproduces it, and require an all-green full-gate run for that SHA before calling any baseline green.
@@ -20,6 +20,7 @@ This page is generated from checked-in runtime and workflow sources. Do not edit
20
20
  | --- | --- | --- | --- |
21
21
  | `behavioral-evals.yml` | 24 | schedule, workflow_dispatch | n/a |
22
22
  | `ci.yml` | 22, 24 | workflow_call | `test`, `test:mutations`, `test:release:semantics`, `waza:check`, `test:consistency`, `test:references`, `test:release`, `test:collision`, `test:plugin:openai`, `test:plugin:submission`, `test:platform:compatibility`, `test:portability` |
23
+ | `doc-governance.yml` | 24 | workflow_call | n/a |
23
24
  | `release.yml` | 24 | workflow_dispatch | n/a |
24
25
 
25
26
  ## Drift prevention
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "harness-enforcement",
3
3
  "description": "Repository inventory for the opencode enforcement plugin and its standalone helper",
4
- "version": "0.49.0",
4
+ "version": "0.50.0",
5
5
  "runtime": "index.mjs",
6
6
  "hooks": {
7
7
  "verify": "hooks/verify.js"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harness-everything",
3
- "version": "0.49.0",
3
+ "version": "0.50.0",
4
4
  "description": "Harness OS: An Orchestrated Agent Operating System for AI-driven Development",
5
5
  "main": "harness-everything/scripts/bootstrap.js",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harness-everything",
3
- "version": "0.49.0",
3
+ "version": "0.50.0",
4
4
  "description": "Invariant-first engineering harness for routing, verification, and failure recovery while preserving agent autonomy.",
5
5
  "author": {
6
6
  "name": "Miya Daniel",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "harness-everything",
4
- "version": "0.49.0",
4
+ "version": "0.50.0",
5
5
  "description": "Invariant-first engineering harness for routing, verification, and failure recovery while preserving agent autonomy.",
6
6
  "author": {
7
7
  "name": "Miya Daniel",
package/sbom.cdx.json CHANGED
@@ -2,23 +2,23 @@
2
2
  "$schema": "https://cyclonedx.org/schema/bom-1.7.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.7",
5
- "serialNumber": "urn:uuid:962b7322-7ab3-5bae-8399-c8e93c5d6717",
5
+ "serialNumber": "urn:uuid:d9117a7d-8bb7-597a-91d9-1bb5afbdcbb0",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-11-08T20:01:04.125Z",
8
+ "timestamp": "2026-11-09T00:33:16.486Z",
9
9
  "tools": [
10
10
  {
11
11
  "vendor": "Harness Everything",
12
12
  "name": "generate-sbom.js",
13
- "version": "0.49.0"
13
+ "version": "0.50.0"
14
14
  }
15
15
  ],
16
16
  "component": {
17
17
  "type": "application",
18
- "bom-ref": "pkg:npm/harness-everything@0.49.0",
18
+ "bom-ref": "pkg:npm/harness-everything@0.50.0",
19
19
  "name": "harness-everything",
20
- "version": "0.49.0",
21
- "purl": "pkg:npm/harness-everything@0.49.0"
20
+ "version": "0.50.0",
21
+ "purl": "pkg:npm/harness-everything@0.50.0"
22
22
  }
23
23
  },
24
24
  "components": [
@@ -2033,7 +2033,7 @@
2033
2033
  ]
2034
2034
  },
2035
2035
  {
2036
- "ref": "pkg:npm/harness-everything@0.49.0",
2036
+ "ref": "pkg:npm/harness-everything@0.50.0",
2037
2037
  "dependsOn": [
2038
2038
  "pkg:npm/%40commitlint/cli@19.8.1",
2039
2039
  "pkg:npm/%40commitlint/config-conventional@19.8.1",
@@ -17,15 +17,19 @@ const TRUST_SURFACE_PATHS = [
17
17
  'plugins/harness-everything/skills/harness-everything/scripts/project-docs-resolver.js',
18
18
  '.agents/harness-everything/project-docs.json',
19
19
  '.agents/harness-everything/document-inventory.json',
20
- '.github/workflows/ci.yml',
20
+ '.agents/harness-everything/errors/README.md',
21
+ '.github/workflows/doc-governance.yml',
21
22
  'package.json',
22
23
  'self-evolve/scripts/self-regression.js',
23
24
  'ci/mechanism-test.js',
24
25
  'ci/mutation-check.js',
25
26
  'ci/lib/isolated-test-process.js',
26
27
  'ci/test-helper.js',
28
+ 'scripts/errors-sync.js',
27
29
  'scripts/verify-all.js'
28
30
  ];
31
+ const MEMBERSHIP_ONLY_PATHS = ['AGENTS.md', 'SECURITY.md', 'CODE_OF_CONDUCT.md', 'PRIVACY.md', 'TERMS.md', 'docs/document-lifecycle.md'];
32
+ const MEMBERSHIP_ONLY_PREFIXES = ['docs/policy/', 'docs/prd/'];
29
33
  const TRUST_SURFACE_PREFIXES = ['ci/fixtures/doc-lifecycle/scenarios/'];
30
34
 
31
35
  function sha256(bytes) {
@@ -207,9 +211,25 @@ function sameIdentity(left, right) {
207
211
  return stable(identity(left)) === stable(identity(right));
208
212
  }
209
213
 
214
+ function sameMembership(left, right) {
215
+ if (!left || !right) return left === right;
216
+ return regularBlob(left) && regularBlob(right) && left.mode === right.mode;
217
+ }
218
+
210
219
  function trustSurfacePaths(baseTree, candidateTree) {
211
220
  const fixed = TRUST_SURFACE_PATHS.map(relativePath => ({ key: pathKey(relativePath), path: relativePath }));
212
- const fixedKeys = new Set(fixed.map(item => item.key));
221
+ const membershipPaths = new Map(MEMBERSHIP_ONLY_PATHS.map(relativePath => [pathKey(relativePath), relativePath]));
222
+ for (const sourceTree of [baseTree, candidateTree]) {
223
+ for (const [key, entry] of sourceTree) {
224
+ if (MEMBERSHIP_ONLY_PREFIXES.some(prefix => entry.pathBytes.subarray(0, Buffer.byteLength(prefix)).equals(Buffer.from(prefix)))) {
225
+ membershipPaths.set(key, pathText(entry.pathBytes));
226
+ }
227
+ }
228
+ }
229
+ const membership = [...membershipPaths]
230
+ .sort(([left], [right]) => Buffer.compare(Buffer.from(left, 'base64'), Buffer.from(right, 'base64')))
231
+ .map(([key, path]) => ({ key, path, membershipOnly: true }));
232
+ const fixedKeys = new Set([...fixed, ...membership].map(item => item.key));
213
233
  const fixtureEntries = new Map();
214
234
  for (const sourceTree of [baseTree, candidateTree]) {
215
235
  for (const [key, entry] of sourceTree) {
@@ -220,7 +240,7 @@ function trustSurfacePaths(baseTree, candidateTree) {
220
240
  const fixtures = [...fixtureEntries]
221
241
  .sort(([left], [right]) => Buffer.compare(Buffer.from(left, 'base64'), Buffer.from(right, 'base64')))
222
242
  .map(([key, path]) => ({ key, path }));
223
- return [...fixed, ...fixtures];
243
+ return [...fixed, ...membership, ...fixtures];
224
244
  }
225
245
 
226
246
  function documentPath(pathBytes) {
@@ -278,7 +298,7 @@ function checkConfiguredPaths(baseTree, candidateTree, config) {
278
298
  const after = candidateMembers.get(key) || null;
279
299
  const entry = before || after;
280
300
  const sameRegularMode = regularBlob(before) && regularBlob(after) && before.mode === after.mode;
281
- const revised = kind === 'governed' && sameRegularMode && before.oid !== after.oid;
301
+ const revised = sameRegularMode && before.oid !== after.oid;
282
302
  const identityPreserved = sameRegularMode && before.oid === after.oid;
283
303
  const memberPath = pathText(entry.pathBytes);
284
304
  details.push({
@@ -286,10 +306,12 @@ function checkConfiguredPaths(baseTree, candidateTree, config) {
286
306
  ...(glob ? { pattern: record.path } : {}),
287
307
  path: memberPath,
288
308
  ...(memberPath === null ? { pathBase64: key } : {}),
289
- state: revised ? 'revised' : identityPreserved ? 'pass' : 'review_required',
309
+ state: kind === 'derived' ? 'pass' : revised ? 'revised' : identityPreserved ? 'pass' : 'review_required',
290
310
  base: identity(before),
291
311
  candidate: identity(after),
292
- ...(revised ? { reason: 'Content revisions are tracked by Git identity but are not semantically validated by the guard.' } : {})
312
+ ...(kind === 'derived' && !sameIdentity(before, after)
313
+ ? { reason: 'Generated-view changes are left to ordinary regenerate-equals CI.' }
314
+ : revised ? { reason: 'Content revisions are tracked by Git identity but are not semantically validated by the guard.' } : {})
293
315
  });
294
316
  }
295
317
  }
@@ -373,10 +395,11 @@ function run(argv) {
373
395
  report.policyPreservation.state = [report.policyPreservation.governed, report.policyPreservation.derived, report.policyPreservation.configuredPaths]
374
396
  .some(item => item.state !== 'pass') ? 'review_required' : 'pass';
375
397
 
376
- report.trustSurface.paths = trustSurfacePaths(baseTree, candidateTree).map(({ key, path }) => {
398
+ report.trustSurface.paths = trustSurfacePaths(baseTree, candidateTree).map(({ key, path, membershipOnly }) => {
377
399
  const before = baseTree.get(key);
378
400
  const after = candidateTree.get(key);
379
- return { path, ...(path === null ? { pathBase64: key } : {}), base: identity(before), candidate: identity(after), changed: !sameIdentity(before, after) };
401
+ const changed = membershipOnly ? !sameMembership(before, after) : !sameIdentity(before, after);
402
+ return { path, ...(path === null ? { pathBase64: key } : {}), base: identity(before), candidate: identity(after), changed };
380
403
  });
381
404
  report.trustSurface.changed = report.trustSurface.paths.filter(item => item.changed);
382
405
  report.trustSurface.state = report.trustSurface.changed.length ? 'review_required' : 'pass';