@twinsearth/w2m-dsh-plugin 0.19.0 → 0.21.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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,97 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
5
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.21.0] — 2026-10-15
8
+
9
+ **The capability table has a gate now, so it is a policy rather than a description of one.**
10
+
11
+ `scripts/layer-model.mjs` has declared since 0.12.0 what each operational capability costs in authority —
12
+ `read_fleet_state` at ordinary, `dispatch_writing` at captain, `publish_release` at org admin,
13
+ `write_config` at system admin — and **nothing consulted it**. A table nothing reads is true on the day it is
14
+ written and unenforced afterwards; that is the same failure this project already paid for with the layering,
15
+ where a prose description was quietly violated while nothing failed.
16
+
17
+ `src/agent/permission.mjs` compares the table against `authority.mjs`'s bands and returns one of three
18
+ verdicts. The interesting part is not `ALLOW`:
19
+
20
+ ### The escalation window is exactly one band
21
+
22
+ `NEEDS_APPROVAL` is granted to an actor **one band below** the requirement and to nobody further down.
23
+ `DENY` covers everything else.
24
+
25
+ **Two bands below is refused rather than escalated**, and that is a security decision rather than a
26
+ convenience one: if any shortfall could be forwarded, an ordinary actor could request a system-admin action
27
+ and **the request itself would be the attack** — one inattentive approver away from a write to
28
+ `allowedCommands`. One band means an actor can ask for the next thing up and no more, which is what
29
+ "escalate to your supervisor" actually means.
30
+
31
+ ### An undeclared capability is DENIED
32
+
33
+ Not `ALLOW`, which would make the table grant whatever is added next; not `NEEDS_APPROVAL`, which would make
34
+ it requestable. Either would turn an **omission** into a permission. `decide('teleport', 100)` refuses, because
35
+ authority answers *how much may this actor do*, not *is this a thing that exists*.
36
+
37
+ ### A level nobody defined is refused rather than rounded
38
+
39
+ `50.5`, `-1`, `101`, `NaN` and `'50'` are all denied with the requirement still reported. Treating them as `0`
40
+ would silently downgrade an actor; treating them as high would silently promote one. Neither is a decision
41
+ this module is entitled to make.
42
+
43
+ ### Nothing destructive settles itself
44
+
45
+ `mayAutoSettle()` returns false for any destructive capability **even on an `ALLOW` at maximum authority** —
46
+ the level answers "may this actor", not "should this happen now". A release published on a machine's own
47
+ judgement is a release nobody reviewed.
48
+
49
+ ### The table is audited, not trusted
50
+
51
+ `auditCapabilities()` reports any entry **no band can reach**, because an unreachable capability reads as
52
+ policy while doing nothing — worse than an absent one, since it looks like a control.
53
+
54
+ **18 tests, 0 failures.** One property is asserted across the whole table rather than by example: for every
55
+ declared capability and every level from 0 to 100, a `NEEDS_APPROVAL` verdict implies the actor is exactly one
56
+ band below.
57
+
58
+ ## [0.20.0] — 2026-10-15
59
+
60
+ **Version skew is refuted too, and the direct-path failure is now counted instead of read one at a time.**
61
+
62
+ ### Third hypothesis, third refutation
63
+
64
+ The previous release established that this machine runs plugin **0.9.5** while the source tree is **0.19.0**,
65
+ and that the failing `P2P_PUNCH_FAILED` is raised by the plugin process. The natural next step was to suspect a
66
+ digest name that 0.9.5 used and later versions removed.
67
+
68
+ **Measured, and refuted:** reading `crypto.mjs`, `p2p.mjs` and `p2p-node.mjs` **at tag `v0.9.5`** through the
69
+ API shows the same two digest names the current tree uses — `blake2s256` and `sha256`. Then a search for
70
+ **dynamic** algorithm arguments (`createHash(variable)`) across `src/` found **none**: every `createHash` and
71
+ `createHmac` in this codebase is called with a string literal.
72
+
73
+ **So there is no call in this codebase that can produce `Digest method not supported`.** Three hypotheses have
74
+ now been measured and excluded — BLAKE2s, the wider primitive set, and version skew — and the cause remains
75
+ **UNVERIFIED**. What is *not* in doubt is where it is raised: the plugin process on this machine, running
76
+ 0.9.5 while the source under study is 0.20.0.
77
+
78
+ ### What this release delivers instead of a fourth guess
79
+
80
+ `p2pReasons()` and `classifyP2PReason()` turn the P2P reason strings into a **count**, because the finding that
81
+ matters was invisible in every individual report: **twelve of twelve tasks travelled by relay, and no aggregate
82
+ anywhere said so.**
83
+
84
+ * `relay_only_share` is the fleet's avoidable latency as one number, and it is **`null`** for an empty
85
+ denominator — "no lease carried a p2p block" and "every lease failed" must not look alike.
86
+ * an unrecognised code is counted as `OTHER` **with the raw code travelling with it**, so a new refusal name
87
+ is reported rather than swallowed.
88
+ * a missing reason is distinguished from a present one, because *"no reason recorded" is not "no failure"*.
89
+
90
+ ### The version skew is still unresolved and still blocks diagnosis
91
+
92
+ The installed plugin is **0.9.5**; the source is **0.20.0**. Until they match, every source-level conclusion
93
+ about this machine's P2P behaviour is read from code that is not running. This needs a person: updating the
94
+ plugin installs files into the DSH profile, which is outside what this session changes on its own.
95
+
96
+ **7 new tests, 0 failures.**
97
+
7
98
  ## [0.19.0] — 2026-10-15
8
99
 
9
100
  **The plugin this machine runs is 0.9.5. The source tree is 0.18.0. Every source-level conclusion about the P2P failure was read from code that may not have run.**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "DSH: Windows2MacOS — drive the same project on online Windows and macOS machines from one DeepSeek account, across networks and regions. P2P hole punching is the default path (relay + shared server as rendezvous, ledger and fallback), with a bundled STUN responder, Tailscale/public-VPS/tunnel shapes, Rabbit relay + per-machine Localside + eight DSH tools (replicate / split), nine aggregate verdicts and signed requests.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -113,13 +113,13 @@
113
113
  }
114
114
  },
115
115
  "scripts": {
116
- "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/layer-model.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/chatops-fleet-join.test.mjs",
116
+ "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/layer-model.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/chatops-fleet-join.test.mjs",
117
117
  "test:e2e": "node --test --test-force-exit test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/p2p-agent.test.mjs",
118
118
  "test:relay": "node --test --test-force-exit test/relay.test.mjs",
119
119
  "test:agent": "node --test --test-force-exit test/agent.test.mjs test/memory.test.mjs",
120
120
  "test:tools": "node --test --test-force-exit test/tools.test.mjs",
121
121
  "test:crossnetwork": "node --test --test-force-exit test/crossnetwork.test.mjs",
122
- "test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/layer-model.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-agent.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/p2p-crypto.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/chatops-fleet-join.test.mjs",
122
+ "test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/layer-model.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-agent.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/p2p-crypto.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/chatops-fleet-join.test.mjs",
123
123
  "pack": "node scripts/pack.mjs",
124
124
  "verify:join": "node scripts/verify-join.mjs",
125
125
  "lint": "eslint .",
@@ -144,7 +144,8 @@
144
144
  "test:fleet-scale": "node --test --test-force-exit test/fleet-scale.test.mjs",
145
145
  "test:fleet-state": "node --test --test-force-exit test/fleet-state.test.mjs",
146
146
  "test:crypto-blake2s": "node --test --test-force-exit test/crypto-blake2s.test.mjs",
147
- "test:installed-version": "node --test --test-force-exit test/installed-version.test.mjs"
147
+ "test:installed-version": "node --test --test-force-exit test/installed-version.test.mjs",
148
+ "test:permission": "node --test --test-force-exit test/permission.test.mjs"
148
149
  },
149
150
  "keywords": [
150
151
  "dsh-bundle",
@@ -111,6 +111,78 @@ export function isFleetFailure(entry) {
111
111
  return classifyLease(entry).outcome === LEASE_OUTCOME.NO_RESULT;
112
112
  }
113
113
 
114
+ /** Refusal codes the relay writes into `p2p.reason`, grouped by what they mean for a fleet. */
115
+ export const P2P_FAILURE_KIND = Object.freeze({
116
+ NO_CANDIDATES: 'no candidate address was known, so no punch was attempted',
117
+ PUNCH_FAILED: 'a punch was attempted and did not complete',
118
+ PUNCH_TIMEOUT: 'a punch was attempted and nothing answered in time',
119
+ LEASE_NOT_RUNNABLE: 'the lease was not in a state to be handed the command',
120
+ REVERSE_DIAL: 'the reverse dial did not complete',
121
+ BLOCKED: 'a local condition stopped the direct path',
122
+ OTHER: 'a reason the table does not classify',
123
+ });
124
+
125
+ /**
126
+ * Classify one `p2p.reason` string.
127
+ *
128
+ * Reasons are prose with a machine-readable prefix (`P2P_NO_CANDIDATES: ...`), and reading them one at a time
129
+ * is how a fleet's direct-path problem stays invisible: the sample that prompted this had **twelve of twelve
130
+ * tasks on the relay** and no aggregate anywhere that said so.
131
+ *
132
+ * Returns `OTHER` for anything unrecognised rather than throwing or guessing, and the raw reason travels with
133
+ * the classification so a new code is reported rather than swallowed.
134
+ */
135
+ export function classifyP2PReason(reason) {
136
+ if (typeof reason !== 'string' || reason === '') {
137
+ return { kind: null, code: null, detail: 'no reason recorded, which is not the same as no failure' };
138
+ }
139
+ const code = (reason.split(':')[0] ?? '').trim();
140
+ if (code === 'P2P_NO_CANDIDATES') return { kind: P2P_FAILURE_KIND.NO_CANDIDATES, code, detail: reason };
141
+ if (code === 'P2P_PUNCH_FAILED') return { kind: P2P_FAILURE_KIND.PUNCH_FAILED, code, detail: reason };
142
+ if (code === 'P2P_PUNCH_TIMEOUT') return { kind: P2P_FAILURE_KIND.PUNCH_TIMEOUT, code, detail: reason };
143
+ if (code === 'P2P_LEASE_NOT_RUNNABLE') return { kind: P2P_FAILURE_KIND.LEASE_NOT_RUNNABLE, code, detail: reason };
144
+ if (code.startsWith('P2P_REVERSE_DIAL')) return { kind: P2P_FAILURE_KIND.REVERSE_DIAL, code, detail: reason };
145
+ if (code.startsWith('P2P_UNAVAILABLE') || code.startsWith('P2P_CRYPTO')) return { kind: P2P_FAILURE_KIND.BLOCKED, code, detail: reason };
146
+ return { kind: P2P_FAILURE_KIND.OTHER, code, detail: reason };
147
+ }
148
+
149
+ /**
150
+ * Count why the direct path was not used, across a set of reports.
151
+ *
152
+ * The number worth acting on is `relay_only_share`: the fraction of leases that travelled by relay while a
153
+ * direct path was nominally configured. That is the fleet's avoidable latency, and it is invisible in any
154
+ * single report.
155
+ */
156
+ export function p2pReasons(reports) {
157
+ const byCode = new Map();
158
+ let leasesWithP2P = 0;
159
+ let onRelay = 0;
160
+ let onDirect = 0;
161
+ for (const report of Array.isArray(reports) ? reports : []) {
162
+ for (const m of Array.isArray(report?.machines) ? report.machines : []) {
163
+ const p = m?.p2p;
164
+ if (p === null || p === undefined || typeof p !== 'object') continue;
165
+ leasesWithP2P += 1;
166
+ if (p.encrypted === true || (p.peer !== null && p.peer !== undefined)) onDirect += 1;
167
+ else onRelay += 1;
168
+ const c = classifyP2PReason(p.reason ?? p.encryption_reason ?? null);
169
+ if (c.code !== null) byCode.set(c.code, (byCode.get(c.code) ?? 0) + 1);
170
+ }
171
+ }
172
+ return {
173
+ leases_with_p2p: leasesWithP2P,
174
+ on_relay: onRelay,
175
+ on_direct: onDirect,
176
+ // Null rather than 0 for an empty denominator, for the same reason every other rate here is: "no leases"
177
+ // and "every lease failed" must not look alike.
178
+ relay_only_share: leasesWithP2P === 0 ? null : Math.round((onRelay / leasesWithP2P) * 1000) / 1000,
179
+ by_code: Object.fromEntries([...byCode].sort((a, b) => b[1] - a[1])),
180
+ detail: leasesWithP2P === 0
181
+ ? 'no lease carried a p2p block, so nothing can be said about the direct path'
182
+ : `${onRelay} of ${leasesWithP2P} lease(s) used the relay; ${byCode.size} distinct reason code(s)`,
183
+ };
184
+ }
185
+
114
186
  /**
115
187
  * Summarise a set of task reports.
116
188
  *
@@ -0,0 +1,198 @@
1
+ /**
2
+ * The permission gate: turning a declared capability table into a decision.
3
+ *
4
+ * ## Why this exists, and what it is not
5
+ *
6
+ * `scripts/layer-model.mjs` declares what each operational capability costs in authority, and **nothing used
7
+ * it**. A table nothing consults is not a policy; it is a description of a policy that happens to be true on
8
+ * the day it is written. This module is the thing that consults it.
9
+ *
10
+ * What it is **not**: it does not know what a capability *means*. Classification — is this a write, a scope
11
+ * change, a registration — belongs to whatever knows the action, which is `authority.mjs`'s own stated rule
12
+ * and the reason that file declines to enumerate actions. So the two files are split by that line:
13
+ * `layer-model.mjs` says what each capability costs, `authority.mjs` owns the bands, and this module compares
14
+ * the two and returns a verdict.
15
+ *
16
+ * ## The three answers, and why there are exactly three
17
+ *
18
+ * `ALLOW` — the actor's level meets the capability's cost.
19
+ * `NEEDS_APPROVAL` — the actor is **one band below**, so somebody above them may authorise it.
20
+ * `DENY` — anything else, including a capability nobody declared.
21
+ *
22
+ * The `NEEDS_APPROVAL` window is deliberately **one band**, not "anything below". If any shortfall could be
23
+ * escalated, an ordinary actor could request a system-admin action and a single inattentive approver would
24
+ * grant it; the request itself would be the attack. One band means an actor can ask for the *next* thing up
25
+ * and no more, which is what "escalate to your supervisor" actually means.
26
+ *
27
+ * ## The refusal that matters most
28
+ *
29
+ * **An undeclared capability is DENIED.** Answering `ALLOW` for something the table has not heard of makes the
30
+ * table grant whatever is added next, and answering `NEEDS_APPROVAL` makes it requestable. Both are worse than
31
+ * a refusal, because both hide the omission.
32
+ */
33
+ import { CAPABILITIES, permissionFor, isDestructive } from '../../scripts/layer-model.mjs';
34
+ import { AUTHORITY_BANDS, AUTHORITY_MAX, AUTHORITY_VERDICT, bandOf, isAuthorityLevel } from './authority.mjs';
35
+
36
+ export { AUTHORITY_VERDICT };
37
+
38
+ /** Why a decision came out the way it did. Codes, so a caller branches on a value rather than on prose. */
39
+ export const GATE_REFUSAL = Object.freeze({
40
+ UNKNOWN_CAPABILITY: 'GATE_UNKNOWN_CAPABILITY',
41
+ NOT_A_LEVEL: 'GATE_NOT_A_LEVEL',
42
+ BELOW_BAND: 'GATE_BELOW_BAND',
43
+ SUFFICIENT: 'GATE_SUFFICIENT',
44
+ ONE_BAND_BELOW: 'GATE_ONE_BAND_BELOW',
45
+ });
46
+
47
+ /**
48
+ * The band index a level falls in, or `-1` when the level is unusable.
49
+ *
50
+ * Uses `bandOf` from `authority.mjs` rather than re-deriving the bands, so a change to the band table cannot
51
+ * make this module disagree with the one that owns it.
52
+ */
53
+ function bandIndex(level) {
54
+ if (!isAuthorityLevel(level)) return -1;
55
+ const band = bandOf(level);
56
+ if (band === null || band === undefined) return -1;
57
+ const index = AUTHORITY_BANDS.findIndex((b) => b.name === (band.name ?? band));
58
+ return index;
59
+ }
60
+
61
+ /**
62
+ * Decide whether an actor may exercise a capability.
63
+ *
64
+ * @param {string} capability as named in the capability table
65
+ * @param {number} level the actor's authority level
66
+ * @returns {{
67
+ * verdict: string, capability: string|null, level: number|null,
68
+ * required: number|null, shortfall: number|null, destructive: boolean|null,
69
+ * reason_code: string, detail: string
70
+ * }}
71
+ */
72
+ export function decide(capability, level) {
73
+ const required = permissionFor(capability);
74
+
75
+ if (required === null) {
76
+ // The most important branch. A capability the table has never heard of is refused, not permitted and not
77
+ // escalated: either answer would turn an omission into a grant.
78
+ return {
79
+ verdict: AUTHORITY_VERDICT.DENY,
80
+ capability: typeof capability === 'string' ? capability : null,
81
+ level: isAuthorityLevel(level) ? level : null,
82
+ required: null,
83
+ shortfall: null,
84
+ destructive: null,
85
+ reason_code: GATE_REFUSAL.UNKNOWN_CAPABILITY,
86
+ detail: `no capability named ${JSON.stringify(capability)} is declared, so nothing may exercise it; an undeclared capability is refused rather than granted`,
87
+ };
88
+ }
89
+
90
+ if (!isAuthorityLevel(level)) {
91
+ // A level nobody defined cannot be compared, and treating it as 0 would silently downgrade an actor while
92
+ // treating it as high would silently promote one. Neither is a decision this module is entitled to make.
93
+ return {
94
+ verdict: AUTHORITY_VERDICT.DENY,
95
+ capability,
96
+ level: null,
97
+ required,
98
+ shortfall: null,
99
+ destructive: isDestructive(capability),
100
+ reason_code: GATE_REFUSAL.NOT_A_LEVEL,
101
+ detail: `${JSON.stringify(level)} is not an authority level (an integer in 0..${AUTHORITY_MAX}), so it cannot be compared against the required ${required}`,
102
+ };
103
+ }
104
+
105
+ const destructive = isDestructive(capability);
106
+
107
+ if (level >= required) {
108
+ return {
109
+ verdict: AUTHORITY_VERDICT.ALLOW,
110
+ capability,
111
+ level,
112
+ required,
113
+ shortfall: 0,
114
+ destructive,
115
+ reason_code: GATE_REFUSAL.SUFFICIENT,
116
+ detail: `level ${level} meets the ${required} required for ${capability}${destructive ? ' (destructive)' : ''}`,
117
+ };
118
+ }
119
+
120
+ const actorBand = bandIndex(level);
121
+ const requiredBand = bandIndex(required);
122
+ const oneBelow = actorBand >= 0 && requiredBand >= 0 && requiredBand - actorBand === 1;
123
+
124
+ if (oneBelow) {
125
+ return {
126
+ verdict: AUTHORITY_VERDICT.NEEDS_APPROVAL,
127
+ capability,
128
+ level,
129
+ required,
130
+ shortfall: required - level,
131
+ destructive,
132
+ reason_code: GATE_REFUSAL.ONE_BAND_BELOW,
133
+ detail: `level ${level} is one band below the ${required} required for ${capability}, so it may be escalated to an approver${destructive ? '; the action is destructive and must not be auto-approved' : ''}`,
134
+ };
135
+ }
136
+
137
+ return {
138
+ verdict: AUTHORITY_VERDICT.DENY,
139
+ capability,
140
+ level,
141
+ required,
142
+ shortfall: required - level,
143
+ destructive,
144
+ reason_code: GATE_REFUSAL.BELOW_BAND,
145
+ detail: `level ${level} is ${required - level} below the ${required} required for ${capability}, which is more than one band; the request itself is refused rather than escalated`,
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Whether a decision may be settled automatically.
151
+ *
152
+ * Only `ALLOW` and only for a non-destructive capability. A destructive action that reaches an approver's
153
+ * queue is a decision for a person even when the approver's level is far above the requirement — the level
154
+ * answers "may this actor", not "should this happen now".
155
+ *
156
+ * @returns {{auto: boolean, detail: string}}
157
+ */
158
+ export function mayAutoSettle(decision) {
159
+ if (decision === null || typeof decision !== 'object') return { auto: false, detail: 'no decision supplied' };
160
+ if (decision.verdict !== AUTHORITY_VERDICT.ALLOW) {
161
+ return { auto: false, detail: `verdict is ${decision.verdict}, which is not an allow` };
162
+ }
163
+ if (decision.destructive === true) {
164
+ return { auto: false, detail: `${decision.capability} is destructive, so a person settles it however high the level` };
165
+ }
166
+ return { auto: true, detail: `${decision.capability} is allowed and not destructive` };
167
+ }
168
+
169
+ /**
170
+ * Check that every declared capability is reachable by somebody.
171
+ *
172
+ * **An unreachable capability is a defect in the table, not a safety feature.** If its minimum authority
173
+ * exceeds `AUTHORITY_MAX`, no actor can ever exercise it and the entry is dead — which reads as policy while
174
+ * doing nothing. Called at test time rather than at load time, because a table with a mistake in it should
175
+ * fail its own tests rather than make a machine refuse to start.
176
+ *
177
+ * @returns {{ok: boolean, unreachable: string[], detail: string}}
178
+ */
179
+ export function auditCapabilities() {
180
+ const unreachable = [];
181
+ for (const c of CAPABILITIES) {
182
+ if (!Number.isInteger(c.min_authority) || c.min_authority < 0 || c.min_authority > AUTHORITY_MAX) {
183
+ unreachable.push(`${c.name} (min_authority ${c.min_authority} is outside 0..${AUTHORITY_MAX})`);
184
+ continue;
185
+ }
186
+ // Reachability by a real actor, not merely by the number: a level in a declared band must exist at or
187
+ // above the requirement.
188
+ const reachable = AUTHORITY_BANDS.some((b) => b.min >= c.min_authority || (b.max ?? AUTHORITY_MAX) >= c.min_authority);
189
+ if (!reachable) unreachable.push(`${c.name} (no band reaches ${c.min_authority})`);
190
+ }
191
+ return {
192
+ ok: unreachable.length === 0,
193
+ unreachable,
194
+ detail: unreachable.length === 0
195
+ ? `all ${CAPABILITIES.length} declared capability(ies) are reachable by some band`
196
+ : `${unreachable.length} declared capability(ies) no actor can exercise: ${unreachable.join('; ')}`,
197
+ };
198
+ }
@@ -185,7 +185,7 @@ const MAX_STAGES = 16;
185
185
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
186
186
  * overrides it at runtime for tests and packagers.
187
187
  */
188
- const PLUGIN_VERSION = '0.19.0';
188
+ const PLUGIN_VERSION = '0.21.0';
189
189
 
190
190
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
191
191
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;