@twinsearth/w2m-dsh-plugin 0.9.7 → 0.9.8

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,76 @@ 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.9.8] — 2026-10-14
8
+
9
+ **The numeric authority model — the layer four of the five roadmap layers depend on — plus a roadmap and a memory write that reports its own outcome.**
10
+
11
+ ### `ROADMAP.md`
12
+
13
+ The operator's architecture document specifies five layers across v4.7.0–v4.13.0. It lives in the
14
+ repository now, and it states the delta honestly before it states the plan: this repository has **one
15
+ relay and five machines**, against a target of 1,000+ agents. Every release row carries both a
16
+ deliverable and **the observation that would falsify it**, because a row without the second is a feature
17
+ list rather than a plan.
18
+
19
+ It also refuses three things on the record: it does not adopt a named protocol (InterMesh, A2A, NLIP,
20
+ AIX, MMP) before a line of its wire format exists; it does not build BFT consensus among five machines on
21
+ one relay, where it would add latency and failure modes without adding trust; and it does not specify
22
+ 1,000-agent behaviour, because the source document's own §6.4 reports 30–50 agents as the practical limit
23
+ and superlinear "coordination collapse" beyond 100 — **a design not measured at 50 cannot honestly
24
+ specify 1,000.**
25
+
26
+ ### `src/agent/authority.mjs` — v4.7.0
27
+
28
+ An integer `authority_level` on every actor, the four bands (0–49 ordinary, 50–69 captain, 70–89 org
29
+ admin, 90+ system admin), `matching_exempt_level` as the Unix-root-style exemption, and a decision
30
+ function returning `allow | needs_approval | deny` **with the rule that produced it**.
31
+
32
+ Why an integer rather than role names: authority appears in four of the five layers — permissions, the
33
+ approval flow, committee voting weights, and the defence hierarchy where a Coordinator outranks an
34
+ Analyst. An enumerated set of names would be re-partitioned by each layer, and every re-partition is a
35
+ place for two of them to disagree.
36
+
37
+ Three decisions are load-bearing and each is pinned by a test:
38
+
39
+ - **A rule with no `matching_exempt_level` exempts nobody.** The tempting default is "absent means
40
+ unconstrained", and it is wrong in the one direction that matters: a rule author who forgets the field
41
+ would silently create an unrestricted action.
42
+ - **An exemption bypasses membership, not the level floor.** The Unix analogy is exact — root bypasses a
43
+ file's permission bits, not the requirement that the file exists. A rule that says both is
44
+ self-contradictory, and the strict half wins.
45
+ - **A derived actor never exceeds its parent.** No rule in the source design grants delegation, and the
46
+ default an implementation reaches for — "a captain's delegate is a captain" — turns one compromised
47
+ actor into an escalation path. Where the source is silent, the narrow reading is the safe one.
48
+
49
+ ### `evaluateAll` — a design corrected by its own test
50
+
51
+ The first draft evaluated every rule and returned the strictest verdict. A test asserting that the rules
52
+ after a gate are not evaluated failed, and the failure was right. `decisive` must be **unique**, or a
53
+ list can hold several non-allow decisions and whichever the caller picks, the *stated* reason for a
54
+ refusal stops being the reason. And a gate is a gate whether it says `deny` or `needs_approval`: in both
55
+ cases the caller's next action is determined and the remaining rules are decoration. It now stops at the
56
+ first non-allow.
57
+
58
+ ### `envelope.memory` — a silent write that now reports
59
+
60
+ `appendMemory`'s result was deliberately unchecked, so a memory write could not fail a task. That remains
61
+ true — but leaving it entirely *silent* was the right call for the control flow and the wrong one for the
62
+ reader: **a machine whose memory is not being written is indistinguishable from one that has nothing to
63
+ remember**, which is precisely the class of invisible failure this layer exists to end. The envelope now
64
+ carries `memory: {written, file, rotated}` or `{written: false, reason_code}`. It is not a comparable
65
+ field, so no verdict moves and two machines cannot disagree over it.
66
+
67
+ ### Verified
68
+
69
+ - `test/authority.test.mjs` — **27 tests, 0 failures.** Every one is about a boundary, because the only
70
+ interesting thing an authority model can get wrong is being off by one: a level too permissive is an
71
+ escalation and one too strict is an outage, so the band edges and the exemption boundary are asserted
72
+ **from both sides** rather than by a middle value that would pass either way.
73
+ - Band partition is proven total and non-overlapping across 0..100 by walking every level, so a seam that
74
+ belongs to two bands — a bug that only appears once someone is assigned that level — cannot exist.
75
+ - `eslint .` — 0 errors on the new module and test.
76
+
7
77
  ## [0.9.7] — 2026-10-14
8
78
 
9
79
  **`w2m_memory` gains the collective layer and the one write a machine may not make for itself.**
package/ROADMAP.md ADDED
@@ -0,0 +1,135 @@
1
+ # Fleet roadmap — from a 5-machine fleet to a governed multi-agent system
2
+
3
+ Status: **plan, with one consumer already written.** This file exists so the roadmap lives in the
4
+ repository rather than in a conversation, and so each version's deliverable can be measured against
5
+ something rather than remembered.
6
+
7
+ Source: the operator's architecture document (2026-10-14), which specifies five layers — coordination,
8
+ memory, authority/scheduling, committee governance, and defence — across releases v4.7.0 to v4.13.0.
9
+
10
+ ---
11
+
12
+ ## 0. What this repository already is, stated honestly
13
+
14
+ Before the roadmap, the delta, because a plan that does not say where it starts is a wish.
15
+
16
+ | Layer | This repository today | The document's target |
17
+ |---|---|---|
18
+ | Networking | one relay, one fleet, five machines; P2P direct path when NAT allows | InterMesh/A2A/NATS-MQTT/libp2p/NLIP federation |
19
+ | Memory | **individual per-machine (shipped 0.9.6), collective curated document (0.9.7), conclusions (0.9.7)** | three-layer memory with field-level acceptance, lineage, consolidation and forgetting |
20
+ | Authority | one operator token; write scope; a global approval rate limiter | numeric `authority_level`, `matching_exempt_level`, a pending-approval index |
21
+ | Governance | none | AURA seven-phase council, weighted voting, recursive Sentinel/MetaSentinel |
22
+ | Scale | five machines, tasks in the hundreds | 1,000+ agents, sub-10 ms coordination, "coordination collapse" mitigation |
23
+ | Defence | hash-only ledger, HMAC signing, BFT-free consensus | three-tier sensor/analyst/coordinator with BFT |
24
+
25
+ **The gap is four to six orders of magnitude in agent count.** Nothing below claims otherwise, and the
26
+ roadmap is ordered so that what is built first is what the larger system would still need.
27
+
28
+ ---
29
+
30
+ ## 1. The one thing to build first, and why it is the only honest starting point
31
+
32
+ The document's own structure gives the answer: `authority_level` appears in **four of its five layers**.
33
+ It is in the permission model (§4.1), the approval flow (§4.2), the committee's weighted voting (§5.3,
34
+ weight includes authority) and the defence hierarchy (§2.3, where a Coordinator outranks an Analyst).
35
+ Nothing in the roadmap can be built without it, and building any of the rest first would mean inventing
36
+ authority implicitly — twice, differently.
37
+
38
+ **And it already has a consumer waiting.** §3.3's P1 is *field-level acceptance*: each agent decides
39
+ per field what to take from a peer, rather than accepting or rejecting a whole message. That is a
40
+ permission decision about a *field*, which is exactly what a numeric authority model expresses. The
41
+ memory layer shipped in 0.9.6/0.9.7 accepts everything or nothing today; `authority_level` is what turns
42
+ it into P1. So this is not a foundation poured in advance of a use — the use is already written.
43
+
44
+ ## 2. Release plan
45
+
46
+ Each row states its **deliverable** and its **falsifying test** — the observation that would show it is
47
+ not working. A row without the second is a feature list, not a plan.
48
+
49
+ ### v4.7.0 — authority model ← **next**
50
+
51
+ **Deliverable.** `src/agent/authority.mjs`: an integer `authority_level` on every actor, the four bands
52
+ (0–49 ordinary, 50–69 captain, 70–89 org admin, 90+ system admin), a rule evaluator carrying
53
+ `matching_exempt_level`, and a decision function returning `allow | needs_approval | deny` with the rule
54
+ that produced it.
55
+
56
+ **Falsifying tests.**
57
+ - an actor at exactly `matching_exempt_level` bypasses the membership check, and one level below does not
58
+ (an off-by-one here is an escalation, so the boundary is tested from both sides);
59
+ - an actor never inherits another's level by acting on its behalf — delegation is not assumed, because
60
+ the document does not grant it and inventing it would be the worst possible default;
61
+ - a rule with no `matching_exempt_level` exempts nobody, rather than exempting everybody.
62
+
63
+ ### v4.8.0 — memory: field-level acceptance (MMP P1/P2)
64
+
65
+ **Deliverable.** Extend the shipped memory layer: a CAT7 seven-field cognitive block, per-field
66
+ acceptance driven by §2's authority model, and lineage — each accepted claim records the content hash of
67
+ its parent, so a returned claim is identifiable as the receiver's own earlier thought.
68
+
69
+ **Falsifying tests.** a field refused by policy is *absent* from the stored block rather than null; a
70
+ claim's ancestor chain survives a round trip; a claim whose parent is missing is refused rather than
71
+ silently re-rooted.
72
+
73
+ ### v4.9.0 — approval queue and scheduling
74
+
75
+ **Deliverable.** A cross-session pending-approval index (§4.2's "supervisor reviews every agent's pending
76
+ approvals from one queue"), with the already-shipped rate limiter as its front door. Scheduling stays
77
+ out of scope here — MARL is not buildable at five machines, and pretending otherwise would be the most
78
+ expensive kind of dishonesty in this roadmap.
79
+
80
+ ### v4.10.0 — committee governance
81
+
82
+ **Deliverable.** The AURA six-role council as a *single* decision engine with the seven phases, weighted
83
+ voting whose weights come from v4.7.0's authority plus reputation, and **dissent retention** — the losing
84
+ arguments are stored, because §5.1's "preserves dissenting agents' arguments" is the property that makes
85
+ a decision auditable later.
86
+
87
+ **Falsifying tests.** a phase cannot be skipped; a vote's weights are reproducible from stored inputs; a
88
+ decision with recorded dissent is retrievable *with* the dissent.
89
+
90
+ ### v4.11.0–v4.13.0 — scale, defence, integration
91
+
92
+ Named for completeness, not planned in detail, because planning them now would produce fiction. The
93
+ document's own §6.4 is the reason: it reports 30–50 agents as the practical limit of current frameworks
94
+ and describes superlinear "coordination collapse" beyond 100. **A design that has not been measured at 50
95
+ cannot honestly specify 1,000**, and the bitmask/sub-10 ms claims are targets to be tested, not facts to
96
+ be designed around.
97
+
98
+ ## 3. What this roadmap deliberately refuses
99
+
100
+ - **It does not adopt a protocol by name.** InterMesh, A2A, NLIP, AIX and MMP are cited in the source
101
+ document; none is implemented here yet, and listing one as "chosen" before a line of its wire format
102
+ exists would be a claim no test could falsify. What is implementable now is the *semantics* they share
103
+ — identity, authority, field-level acceptance, lineage — and that is what v4.7.0 and v4.8.0 build.
104
+ - **It does not build consensus where there is nothing to agree on.** BFT among five machines on one
105
+ relay adds latency and failure modes without adding trust. It becomes meaningful when there are
106
+ mutually-distrusting organisations, which is a v4.12.0 precondition, not a v4.7.0 one.
107
+ - **It does not put a governance layer above a fleet that has no disagreements yet.** A council that
108
+ decides nothing is indistinguishable from a delay. The council earns its place when two fleets want the
109
+ same machine — and that is measurable, so it can wait for the measurement.
110
+ - **It does not scale the agent count by declaring it scaled.** §6.4's collapse is a real reported
111
+ phenomenon; the response here is a benchmark before a design, not the reverse.
112
+
113
+ ## 4. Where the numbers in the source document are treated as claims, not facts
114
+
115
+ Several figures in the source (20–40% faster convergence, 45% storage reduction, 37% stability
116
+ improvement, 85–120 ms decision latency at 100+ agents, 35–42% cache hit rates, 35–78% token reduction)
117
+ come from cited literature on other systems. They are recorded as *expectations to test at this fleet's
118
+ scale*, and no roadmap item above is justified by them. The one number this repository has measured
119
+ itself — the ~50 s task ceiling, from `heartbeatIntervalMs × missedHeartbeats + leaseGraceMs` — is in
120
+ `fleet-memory.md` with its three measurements, and it is the reason every long job here is started
121
+ detached.
122
+
123
+ ## 5. Relationship to the memory layer already shipped
124
+
125
+ The shipped layer is not a detour from this roadmap; it is §3.1's L2 and part of L3, built early because
126
+ it was cheap and the gap it closed had been paid for repeatedly. Concretely:
127
+
128
+ - `memory.jsonl` per machine = **L2 individual memory**, persisted, owned by the machine.
129
+ - `fleet-memory.md` on the archive host = **L3 collective memory**, curated rather than generated.
130
+ - `w2m_memory` = the retrieval entry point for both, plus `record` for conclusions.
131
+ - **L1 conversation memory** and §3.2's *emergent* collective memory (environment traces) are **not**
132
+ built.
133
+
134
+ The honest summary: the memory layer is at the point where the next thing it needs is authority, which is
135
+ why v4.7.0 is the next thing this roadmap builds.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "0.9.7",
3
+ "version": "0.9.8",
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",
@@ -28,6 +28,7 @@
28
28
  "README.md",
29
29
  "CHANGELOG.md",
30
30
  "HANDOFF.md",
31
+ "ROADMAP.md",
31
32
  "LICENSE",
32
33
  "PROTOCOL.md",
33
34
  "PROTOCOL-v0.1.2.md",
@@ -111,13 +112,13 @@
111
112
  }
112
113
  },
113
114
  "scripts": {
114
- "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.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",
115
+ "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/authority.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",
115
116
  "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",
116
117
  "test:relay": "node --test --test-force-exit test/relay.test.mjs",
117
118
  "test:agent": "node --test --test-force-exit test/agent.test.mjs test/memory.test.mjs",
118
119
  "test:tools": "node --test --test-force-exit test/tools.test.mjs",
119
120
  "test:crossnetwork": "node --test --test-force-exit test/crossnetwork.test.mjs",
120
- "test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.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",
121
+ "test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/authority.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",
121
122
  "pack": "node scripts/pack.mjs",
122
123
  "verify:join": "node scripts/verify-join.mjs",
123
124
  "lint": "eslint .",
@@ -126,7 +127,8 @@
126
127
  "format:check": "prettier --check .",
127
128
  "verify": "node scripts/check-entrypoints.mjs && node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/tools.test.mjs test/e2e.test.mjs test/crossnetwork.test.mjs test/p2p-node.test.mjs test/p2p-agent.test.mjs test/client-bundle.test.mjs test/loader-entry.test.mjs",
128
129
  "test:p2p": "node --test --test-force-exit test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/stun-server.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-agent.test.mjs",
129
- "test:memory": "node --test --test-force-exit test/memory.test.mjs"
130
+ "test:memory": "node --test --test-force-exit test/memory.test.mjs",
131
+ "test:authority": "node --test --test-force-exit test/authority.test.mjs"
130
132
  },
131
133
  "keywords": [
132
134
  "dsh-bundle",
@@ -1,4 +1,4 @@
1
- /**
1
+ /**
2
2
  * The Localside agent: everything between "Rabbit hands us an offer" and
3
3
  * "Rabbit has our envelope".
4
4
  *
@@ -48,7 +48,7 @@ import {
48
48
  resolveProjectCwd,
49
49
  } from './git.mjs';
50
50
  import { createSpool } from './spool.mjs';
51
- import { appendMemory, memoryRecord } from './memory.mjs';
51
+ import { MEMORY_FILE, appendMemory, memoryRecord } from './memory.mjs';
52
52
  import { matchAllowedCommand, parseAllowedCommands } from './allowed-commands.mjs';
53
53
  import { CRYPTO_REASON, DEVICE_KEY_FILE, DeviceKey, PeerKeyStore } from './crypto.mjs';
54
54
  import { DEFAULT_P2P_MODE, P2P_DEFAULTS, P2PNode, normalizeP2PMode, normalizeP2PPort } from './p2p-node.mjs';
@@ -1094,7 +1094,7 @@ export function createAgent(options) {
1094
1094
  // Loaded here, at construction, rather than lazily at `start()`: the key file is also what a
1095
1095
  // *dispatcher* publishes in an offer, and "which key will this machine present" must not depend on
1096
1096
  // whether a particular code path happened to have run yet. `loadOrCreate` is the documented rule
1097
- // ("generated the first time a machine pairs"), and a failure is never fatal — see `deviceKeyError`.
1097
+ // ("generated the first time a machine pairs"), and a failure is never fatal - see `deviceKeyError`.
1098
1098
  const deviceKeyOutcome = loadDeviceKey(options.deviceKey ?? null, stateDir, identity.machine_id, identityPath);
1099
1099
  /** @type {object|null} This machine's static keypair, or null when there is none. */
1100
1100
  const deviceKey = deviceKeyOutcome.key;
@@ -1103,7 +1103,7 @@ export function createAgent(options) {
1103
1103
  /**
1104
1104
  * A key adopted from the pre-v0.6.2 location (`--state/device.key`). Reported once at startup,
1105
1105
  * because a machine whose key moved is a machine whose public key a peer may have learned under the
1106
- * old file — silence here would make "my peers stopped talking to me" have no visible cause.
1106
+ * old file - silence here would make "my peers stopped talking to me" have no visible cause.
1107
1107
  */
1108
1108
  const deviceKeyMigratedFrom = deviceKeyOutcome.migratedFrom ?? null;
1109
1109
  /**
@@ -1120,7 +1120,7 @@ export function createAgent(options) {
1120
1120
  * What each task's direct channel actually was, for the result envelope.
1121
1121
  *
1122
1122
  * Keyed by `task_id`, alongside the channel map rather than on the channel itself, because the
1123
- * envelope is built *after* `planDirectDelivery` may have released the channel — and
1123
+ * envelope is built *after* `planDirectDelivery` may have released the channel - and
1124
1124
  * `p2p.encrypted` has to describe the channel that carried this attempt, not whichever channel
1125
1125
  * happens to be live when the envelope is written.
1126
1126
  *
@@ -1166,7 +1166,7 @@ export function createAgent(options) {
1166
1166
  const p2pPeers = new Map();
1167
1167
 
1168
1168
  /**
1169
- * One reverse-dial attempt per `task_id` — the executor's own half of a two-way punch.
1169
+ * One reverse-dial attempt per `task_id` - the executor's own half of a two-way punch.
1170
1170
  *
1171
1171
  * A record exists for every offer this agent takes on, including the ones it decides *not* to
1172
1172
  * dial for, and that is deliberate: "we did not try, because a channel was already live" and "we
@@ -1444,8 +1444,8 @@ export function createAgent(options) {
1444
1444
  * happened rather than what was possible. `reason` is the named code from that same channel, so
1445
1445
  * `p2p.encrypted: false` always arrives with a *why*.
1446
1446
  *
1447
- * It is called from both places a channel becomes a task's channel — the offer that arrived on one
1448
- * and the dial that opened one — and the most recent call wins, which is right: a task's result
1447
+ * It is called from both places a channel becomes a task's channel - the offer that arrived on one
1448
+ * and the dial that opened one - and the most recent call wins, which is right: a task's result
1449
1449
  * travels over the channel that is live *now*.
1450
1450
  *
1451
1451
  * @param {string} taskId
@@ -1479,7 +1479,7 @@ export function createAgent(options) {
1479
1479
  *
1480
1480
  * Without this, a task executed entirely over the relay reports `encrypted: false` with no
1481
1481
  * explanation, and a reader cannot tell "this machine has no crypto" from "no direct channel was the
1482
- * answer for this task" — entirely different operational facts. The reason is the *same* string
1482
+ * answer for this task" - entirely different operational facts. The reason is the *same* string
1483
1483
  * `offerTransportOf` puts in `p2p.reason`, deliberately: one sentence, decided in one place.
1484
1484
  *
1485
1485
  * It never overwrites a channel's own record: once a channel carried something for this task, which
@@ -2118,7 +2118,7 @@ export function createAgent(options) {
2118
2118
  * Signing headers for one request, or nothing at all.
2119
2119
  *
2120
2120
  * The signed `path` is the **API-relative** path plus its query
2121
- * (`/v1/stream?machine_id=…&seq=5`), never the URL as sent.
2121
+ * (`/v1/stream?machine_id= - seq=5`), never the URL as sent.
2122
2122
  *
2123
2123
  * That looks backwards -- surely the signature should cover the bytes on the
2124
2124
  * wire? -- so here is the case that settles it. With `rabbitUrl =
@@ -2283,7 +2283,7 @@ export function createAgent(options) {
2283
2283
  * * a **pairing code** is the operator saying *"let this machine in"*, and it is the only way to
2284
2284
  * place a device in a fleet the device could not compute for itself;
2285
2285
  * * a **fleet key** is the account saying *"this machine is mine"*, and the relay derives the
2286
- * fleet from it — so it can only ever reach a fleet its holder is already entitled to.
2286
+ * fleet from it - so it can only ever reach a fleet its holder is already entitled to.
2287
2287
  *
2288
2288
  * Passing neither remains allowed: the relay answers `PAIRING_INVALID` and the caller reports it,
2289
2289
  * which is the pre-v0.8.1 behaviour for an unpaired machine.
@@ -2298,8 +2298,7 @@ export function createAgent(options) {
2298
2298
  /**
2299
2299
  * Send only the discriminator that was actually given.
2300
2300
  *
2301
- * `pairing_code: null` would be *present*, and a presence check reads a null as "provided" —
2302
- * the same trap that made `createILinkChannel` pass `{ fetch: undefined }` and throw on mount in
2301
+ * `pairing_code: null` would be *present*, and a presence check reads a null as "provided" - * the same trap that made `createILinkChannel` pass `{ fetch: undefined }` and throw on mount in
2303
2302
  * v0.7.0. An absent code must be an absent key, not a null one.
2304
2303
  */
2305
2304
  if (typeof pairingCode === 'string' && pairingCode !== '') body.pairing_code = pairingCode;
@@ -2419,7 +2418,7 @@ export function createAgent(options) {
2419
2418
  * states, and a bound nobody measures is a claim: this is the number that says
2420
2419
  * whether the result path really stayed inside it.
2421
2420
  * * `result_error` -- why the direct copy of *this result* did not land, when one was attempted
2422
- * and not acknowledged (`P2P_ACK_TIMEOUT: …`). Null otherwise; this is the
2421
+ * and not acknowledged (`P2P_ACK_TIMEOUT: - ). Null otherwise; this is the
2423
2422
  * `delivery.error` that `p2pDeliveryMark` has always carried and the envelope
2424
2423
  * never reported.
2425
2424
  *
@@ -2438,8 +2437,7 @@ export function createAgent(options) {
2438
2437
  *
2439
2438
  * Read from what the agent *recorded* when it held the channel, not from a live object: by the
2440
2439
  * time the envelope is built the channel may have been released, and `p2p.encrypted` has to
2441
- * describe the channel that carried this attempt. `null` means no channel was ever involved —
2442
- * a task executed entirely over the relay — which reports `encrypted: false` because that is the
2440
+ * describe the channel that carried this attempt. `null` means no channel was ever involved - * a task executed entirely over the relay - which reports `encrypted: false` because that is the
2443
2441
  * truth about this task's data plane, whatever this machine's keypair could have done.
2444
2442
  */
2445
2443
  const encryption = encryptionFor(offer?.task_id);
@@ -2653,17 +2651,25 @@ export function createAgent(options) {
2653
2651
  * The write happens **after** the envelope is complete, so the record it stores is the same one the
2654
2652
  * relay will judge -- not a second, independently assembled opinion about what happened.
2655
2653
  *
2656
- * `appendMemory` never throws and its result is deliberately not returned or checked: the task is
2657
- * the work and this is a note about it, so a full disk or an unwritable directory must not turn a
2658
- * successful command into a failed task. A memory that can fail work is worse than no memory, and
2659
- * the failure mode is silent here on purpose -- the envelope's own fields already say what happened.
2654
+ * `appendMemory` never throws and its result never changes the verdict: the task is the work and
2655
+ * this is a note about it, so a full disk or an unwritable directory must not turn a successful
2656
+ * command into a failed task. A memory that can fail work is worse than no memory.
2657
+ *
2658
+ * v0.9.8: its *outcome* is nonetheless reported, as `memory` on the envelope. Leaving it entirely
2659
+ * silent was the right call for the control flow and the wrong one for the reader -- a machine whose
2660
+ * memory is not being written is indistinguishable from one that has nothing to remember, which is
2661
+ * exactly the class of invisible failure this layer exists to end. One optional field, no verdict
2662
+ * moved: it is not a comparable field, so two machines cannot disagree over it.
2660
2663
  */
2661
- appendMemory(stateDir, memoryRecord({
2664
+ const memoryWrite = appendMemory(stateDir, memoryRecord({
2662
2665
  envelope,
2663
2666
  commandArgv,
2664
2667
  cwdRel,
2665
2668
  ms: (Date.parse(endedAt) - Date.parse(startedAt)) || 0,
2666
2669
  }));
2670
+ envelope.memory = memoryWrite.ok === true
2671
+ ? { written: true, file: MEMORY_FILE, rotated: memoryWrite.rotated === true }
2672
+ : { written: false, reason_code: memoryWrite.error };
2667
2673
 
2668
2674
  return buildEnvelope(envelope);
2669
2675
  }
@@ -3220,7 +3226,7 @@ export function createAgent(options) {
3220
3226
  }
3221
3227
 
3222
3228
  if (channelFor(offer?.task_id) === null) {
3223
- // No channel at all, so nothing about this task's data plane was ever sealed — and the reason
3229
+ // No channel at all, so nothing about this task's data plane was ever sealed - and the reason
3224
3230
  // recorded here is the one `offerTransportOf` already decided for `p2p.reason`.
3225
3231
  rememberNoChannel(offer?.task_id, offerTransportOf(offer, p2pStatusSnapshot()).reason);
3226
3232
  return p2pDeliveryMark();
@@ -3282,7 +3288,7 @@ export function createAgent(options) {
3282
3288
 
3283
3289
  const startedAt = process.hrtime.bigint();
3284
3290
  // `machine_id` travels with the notice because the dispatcher has to be able to say *which*
3285
- // machine finished without waiting for the relay's copy — the plugin names its direct-result
3291
+ // machine finished without waiting for the relay's copy - the plugin names its direct-result
3286
3292
  // inbox file after it. The frame still carries no envelope: `result_path` is decided by the
3287
3293
  // acknowledgement this frame is waiting for, and `envelope_sha256` covers `result_path`, so
3288
3294
  // sending the envelope first and stamping it afterwards would break the hash it is signed with.
@@ -3415,7 +3421,7 @@ export function createAgent(options) {
3415
3421
  code = null;
3416
3422
  }
3417
3423
  const error = new Error(`stream HTTP ${response.status} ${body.slice(0, 200)}`.trim());
3418
- // Preserve the relay's own code (SIGNATURE_REQUIRED, SIGNATURE_EXPIRED, …)
3424
+ // Preserve the relay's own code (SIGNATURE_REQUIRED, SIGNATURE_EXPIRED, -
3419
3425
  // so a signed link can be diagnosed without guessing.
3420
3426
  error.code = code ?? `HTTP_${response.status}`;
3421
3427
  state.lastRelayError = {
@@ -3568,7 +3574,7 @@ export function createAgent(options) {
3568
3574
  // peer tried to put on the wire is overwritten, so a relay frame can never claim the
3569
3575
  // direct path -- the only thing that sets `'p2p'` here is the channel having delivered it.
3570
3576
  const offer = { ...payload, p2p_transport: source.transport };
3571
- // v0.6.0: the block's `static_keys` is the *entire* key-distribution mechanism — a machine
3577
+ // v0.6.0: the block's `static_keys` is the *entire* key-distribution mechanism - a machine
3572
3578
  // learns the keys of exactly the peers it was asked to talk to, from the offer that asked it,
3573
3579
  // and never from a config file. Learned here, before the offer is queued, because the reverse
3574
3580
  // dial starts inside `handleOffer` and a key merged any later would miss the handshake this
@@ -4127,8 +4133,8 @@ export function createAgent(options) {
4127
4133
  /**
4128
4134
  * Load this machine's static keypair, or explain why there is none (v0.6.0).
4129
4135
  *
4130
- * Never throws. A machine whose key file is unreadable is still a working W2M machine — every task
4131
- * still travels over the relay — and `agent.state.p2p.encryption.error` names the file that could not
4136
+ * Never throws. A machine whose key file is unreadable is still a working W2M machine - every task
4137
+ * still travels over the relay - and `agent.state.p2p.encryption.error` names the file that could not
4132
4138
  * be read so an operator can fix it. Refusing to start an agent because a *security upgrade* could not
4133
4139
  * complete is exactly the fleet-losing failure `docs/P2P-CRYPTO.md` §4 forbids.
4134
4140
  *
@@ -4138,15 +4144,15 @@ export function createAgent(options) {
4138
4144
  * used to break it: it loaded from the `--state` directory, justified by a comment claiming that
4139
4145
  * `identity.mjs` puts `device.json` there "when the agent is run with `--state`". That claim is false.
4140
4146
  * `device.json` is fixed at `<DSH_HOME>/xclient/device.json` by the identity contract and `--state`
4141
- * does not move it — which is why an agent run as `--state ~/.dsh/xclient/localside-mac` had its
4147
+ * does not move it - which is why an agent run as `--state ~/.dsh/xclient/localside-mac` had its
4142
4148
  * identity one directory *above* its key.
4143
4149
  *
4144
4150
  * The measured consequence, on one machine running both halves of W2M: the plugin (which loads the key
4145
4151
  * correctly, from `dirname(device.json)`) and the agent each generated a *different* pair, and both
4146
- * announced to the one `/v1/peer/{machine_id}` slot — the relay's record alternated between the two
4152
+ * announced to the one `/v1/peer/{machine_id}` slot - the relay's record alternated between the two
4147
4153
  * nodes' ports every few seconds, and the `static_key` a dispatcher read was one node's or the other's.
4148
4154
  * A dispatcher that learns the wrong key cannot arm a handshake, so every direct channel came back
4149
- * `P2P_CRYPTO_PLAINTEXT_PEER` — reported as "the peer is an older build" when both ends were the same
4155
+ * `P2P_CRYPTO_PLAINTEXT_PEER` - reported as "the peer is an older build" when both ends were the same
4150
4156
  * build on the same host.
4151
4157
  *
4152
4158
  * MIGRATION. `DeviceKey.load` warns that "silently generating a replacement key would make every peer
@@ -4168,7 +4174,7 @@ function loadDeviceKey(injected, stateDir, machineId, deviceFile = undefined) {
4168
4174
  return { key: injected, error: null, migratedFrom: null };
4169
4175
  }
4170
4176
 
4171
- // Beside `device.json` — the documented rule. `identityPath` is the path the caller actually
4177
+ // Beside `device.json` - the documented rule. `identityPath` is the path the caller actually
4172
4178
  // loaded, never a fresh global resolution: resolving `DSH_HOME` here would make a test (or any
4173
4179
  // caller with an injected identity) read and *create* a key in the real user's home.
4174
4180
  let canonicalDir = null;
@@ -4179,7 +4185,7 @@ function loadDeviceKey(injected, stateDir, machineId, deviceFile = undefined) {
4179
4185
  /**
4180
4186
  * Candidates in order. `mayCreate` is the whole subtlety: only a location that is *the* documented
4181
4187
  * home may mint a key. A legacy directory is consulted to adopt a key that is already there, never
4182
- * to create one — `DeviceKey.load` warns that generating a replacement "would make every peer that
4188
+ * to create one - `DeviceKey.load` warns that generating a replacement "would make every peer that
4183
4189
  * knows the old one refuse this machine", so an upgrade must not silently change this machine's
4184
4190
  * public key.
4185
4191
  *
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Numeric authority: who may do what, expressed as an integer.
3
+ *
4
+ * ## Why an integer rather than a list of names
5
+ *
6
+ * The roadmap has authority appearing in four of its five layers -- the permission model, the approval
7
+ * flow, committee voting weights, and the defence hierarchy where a Coordinator outranks an Analyst. An
8
+ * enumerated set of role names would have to be re-partitioned by each of those layers, and each
9
+ * re-partition would be a place for two of them to disagree. An integer orders every actor on one axis,
10
+ * so "does A outrank B" is a comparison rather than a lookup table that has to be kept in step.
11
+ *
12
+ * The model is Unix-like on purpose: `matching_exempt_level` mirrors root's exemption from file
13
+ * permission checks. That is a *deliberate* hole in the rules, so it is the most carefully bounded thing
14
+ * in this file -- see `evaluate`.
15
+ *
16
+ * ## The four bands
17
+ *
18
+ * | Band | Range | May |
19
+ * |---|---|---|
20
+ * | ordinary | 0–49 | create goals and session data within its own scope |
21
+ * | captain | 50–69 | create team goals, manage team membership |
22
+ * | org admin | 70–89 | create organisation goals, register actors, set overrides |
23
+ * | system admin | 90+ | create global goals, configure across organisations, override any lower constraint |
24
+ *
25
+ * ## What this module deliberately does not do
26
+ *
27
+ * It does not decide *what* an action is, only whether an actor's level satisfies a stated rule. Action
28
+ * classification -- is this a write, a scope change, a registration -- belongs to whatever knows the
29
+ * action, and putting it here would mean this file had to be edited every time a new action appeared.
30
+ * That is how an authority model becomes a bottleneck and then gets bypassed.
31
+ */
32
+
33
+ /** The bands, as data rather than as branches, so a reader can enumerate them. */
34
+ export const AUTHORITY_BANDS = Object.freeze([
35
+ { name: 'ordinary', min: 0, max: 49, may: 'create goals and session data within its own scope' },
36
+ { name: 'captain', min: 50, max: 69, may: 'create team goals, manage team membership' },
37
+ { name: 'org_admin', min: 70, max: 89, may: 'create organisation goals, register actors, set overrides' },
38
+ { name: 'system_admin', min: 90, max: null, may: 'create global goals, configure across organisations, override any lower constraint' },
39
+ ]);
40
+
41
+ /** The highest level this model will accept. Above it, a level is a mistake rather than a promotion. */
42
+ export const AUTHORITY_MAX = 100;
43
+
44
+ /** Verdicts an authority check can reach. */
45
+ export const AUTHORITY_VERDICT = Object.freeze({
46
+ ALLOW: 'allow',
47
+ NEEDS_APPROVAL: 'needs_approval',
48
+ DENY: 'deny',
49
+ });
50
+
51
+ /**
52
+ * Is this a usable authority level?
53
+ *
54
+ * Rejects non-integers as well as out-of-range values. `50.5` is not "between captain and org admin"; it
55
+ * is a value nobody defined, and accepting it would make band boundaries behave differently for actors
56
+ * that are supposed to be equal.
57
+ *
58
+ * @param {unknown} level
59
+ * @returns {boolean}
60
+ */
61
+ export function isAuthorityLevel(level) {
62
+ return Number.isInteger(level) && level >= 0 && level <= AUTHORITY_MAX;
63
+ }
64
+
65
+ /**
66
+ * Which band a level falls in, or `null` for a value that is not a level.
67
+ *
68
+ * @param {unknown} level
69
+ * @returns {{name: string, min: number, max: number|null, may: string}|null}
70
+ */
71
+ export function bandOf(level) {
72
+ if (!isAuthorityLevel(level)) return null;
73
+ for (const band of AUTHORITY_BANDS) {
74
+ if (level >= band.min && (band.max === null || level <= band.max)) return band;
75
+ }
76
+ return null;
77
+ }
78
+
79
+ /**
80
+ * A rule about an action.
81
+ *
82
+ * `minLevel` is the floor for performing it at all. `matchingExemptLevel`, when present, is the level at
83
+ * or above which the *membership* part of the rule is skipped -- the Unix-root analogy. `scope` names what
84
+ * the action is performed against, so a refusal can say which rule refused it.
85
+ *
86
+ * @typedef {object} AuthorityRule
87
+ * @property {string} action
88
+ * @property {number} minLevel
89
+ * @property {number|null} [matchingExemptLevel]
90
+ * @property {string|null} [scope]
91
+ * @property {string} [why]
92
+ */
93
+
94
+ /**
95
+ * Cheap structural validation, separate from evaluation.
96
+ *
97
+ * A malformed rule is a configuration defect and should be caught where it is written, not silently
98
+ * treated as a rule that permits nothing -- "denied" and "misconfigured" look identical from the outside
99
+ * and only one of them means someone should fix something.
100
+ *
101
+ * @param {unknown} rule
102
+ * @returns {string|null} a reason, or `null` when the rule is well formed.
103
+ */
104
+ export function ruleProblem(rule) {
105
+ if (rule === null || typeof rule !== 'object') return 'rule must be an object';
106
+ if (typeof rule.action !== 'string' || rule.action.trim() === '') return 'rule.action must be a non-empty string';
107
+ if (!isAuthorityLevel(rule.minLevel)) return `rule.minLevel must be an integer 0..${AUTHORITY_MAX}`;
108
+ if (rule.matchingExemptLevel !== undefined && rule.matchingExemptLevel !== null
109
+ && !isAuthorityLevel(rule.matchingExemptLevel)) {
110
+ return `rule.matchingExemptLevel must be an integer 0..${AUTHORITY_MAX} or null`;
111
+ }
112
+ return null;
113
+ }
114
+
115
+ /**
116
+ * Evaluate one rule against one actor.
117
+ *
118
+ * The order of the three checks is the whole security property, so it is stated rather than implied:
119
+ *
120
+ * 1. **Exemption**, and only when the rule names one. An exempt actor skips the *membership* check --
121
+ * not the level check, and not the existence of the rule. This is the Unix-root analogy: root
122
+ * bypasses a file's permission bits, not the fact that the file must exist.
123
+ * 2. **Membership**, when the rule has a scope and the caller supplied one. An actor outside the scope
124
+ * needs a supervisor, not a refusal, which is why this yields `needs_approval` rather than `deny`.
125
+ * 3. **Level**, the plain floor.
126
+ *
127
+ * A rule with no `matchingExemptLevel` exempts **nobody**. That is the decision most worth stating: the
128
+ * tempting default is "absent means unconstrained", and it is wrong in the one direction that matters --
129
+ * a rule author who forgets the field would silently create an unrestricted action.
130
+ *
131
+ * @param {AuthorityRule} rule
132
+ * @param {{level: number, inScope?: boolean|null, id?: string|null}} actor
133
+ * @returns {{verdict: string, action: string|null, level: number|null, exempt: boolean, reason: string}}
134
+ */
135
+ export function evaluate(rule, actor) {
136
+ const problem = ruleProblem(rule);
137
+ if (problem !== null) {
138
+ return {
139
+ verdict: AUTHORITY_VERDICT.DENY,
140
+ action: rule?.action ?? null,
141
+ level: actor?.level ?? null,
142
+ exempt: false,
143
+ reason: `malformed rule: ${problem}`,
144
+ };
145
+ }
146
+ if (!isAuthorityLevel(actor?.level)) {
147
+ return {
148
+ verdict: AUTHORITY_VERDICT.DENY,
149
+ action: rule.action,
150
+ level: actor?.level ?? null,
151
+ exempt: false,
152
+ reason: `actor has no usable authority level (got ${JSON.stringify(actor?.level ?? null)})`,
153
+ };
154
+ }
155
+
156
+ const exemptLevel = rule.matchingExemptLevel ?? null;
157
+ const exempt = exemptLevel !== null && actor.level >= exemptLevel;
158
+
159
+ // 2. Membership, skipped entirely for an exempt actor.
160
+ if (!exempt && rule.scope !== undefined && rule.scope !== null && actor.inScope === false) {
161
+ return {
162
+ verdict: AUTHORITY_VERDICT.NEEDS_APPROVAL,
163
+ action: rule.action,
164
+ level: actor.level,
165
+ exempt: false,
166
+ reason: `outside the scope of "${rule.action}" (${rule.scope}); a supervisor within it may approve`,
167
+ };
168
+ }
169
+
170
+ // 3. The floor. Checked even for an exempt actor -- see the header.
171
+ if (actor.level < rule.minLevel) {
172
+ return {
173
+ verdict: AUTHORITY_VERDICT.NEEDS_APPROVAL,
174
+ action: rule.action,
175
+ level: actor.level,
176
+ exempt,
177
+ reason: `level ${actor.level} is below the minimum ${rule.minLevel} for "${rule.action}"`,
178
+ };
179
+ }
180
+
181
+ return {
182
+ verdict: AUTHORITY_VERDICT.ALLOW,
183
+ action: rule.action,
184
+ level: actor.level,
185
+ exempt,
186
+ reason: exempt
187
+ ? `allowed: level ${actor.level} meets the exemption ${exemptLevel} for "${rule.action}"`
188
+ : `allowed: level ${actor.level} meets the minimum ${rule.minLevel} for "${rule.action}"`,
189
+ };
190
+ }
191
+
192
+ /**
193
+ * Evaluate several rules, stopping at the first one that is not a plain allow.
194
+ *
195
+ * ## Why it stops, rather than evaluating everything
196
+ *
197
+ * An earlier draft evaluated all rules and returned the strictest verdict. It is the more obvious design
198
+ * and the less useful one, for two reasons that both matter to the person reading the answer:
199
+ *
200
+ * * **`decisive` must be unique.** With every rule evaluated, a list can hold several non-allow
201
+ * decisions and the caller has to pick one -- and whichever it picks, the *stated* reason for a
202
+ * refusal stops being the reason. An operator asking "why was this not allowed" needs one rule.
203
+ * * **The unevaluated rules may not even apply.** A rule is evaluated against an actor, and an actor's
204
+ * scope can be learned from the outcome of an earlier rule; continuing past a gate would evaluate
205
+ * later rules against a context the gate had already made moot.
206
+ *
207
+ * Stopping at the *first* non-allow -- not merely at the first deny -- follows from the same argument. A
208
+ * gate is a gate whether it says `deny` or `needs_approval`, and in both cases the caller's next action
209
+ * is determined and the rest of the list is decoration.
210
+ *
211
+ * @param {AuthorityRule[]} rules
212
+ * @param {{level: number, inScope?: boolean|null, id?: string|null}} actor
213
+ * @returns {{verdict: string, decisions: object[], decisive: object|null}}
214
+ */
215
+ export function evaluateAll(rules, actor) {
216
+ const decisions = [];
217
+ for (const rule of Array.isArray(rules) ? rules : []) {
218
+ const decision = evaluate(rule, actor);
219
+ decisions.push(decision);
220
+ if (decision.verdict !== AUTHORITY_VERDICT.ALLOW) {
221
+ return { verdict: decision.verdict, decisions, decisive: decision };
222
+ }
223
+ }
224
+ return { verdict: AUTHORITY_VERDICT.ALLOW, decisions, decisive: null };
225
+ }
226
+
227
+ /**
228
+ * The level required to sign off on a refusal.
229
+ *
230
+ * One band up, floored at the captain band, because the document's approval flow is a chain --
231
+ * captain, then org admin, then system admin -- and a chain that can be satisfied by the actor's own peer
232
+ * group is not a chain. An actor already at the top band has no higher approver, so the top band signs
233
+ * for itself; saying otherwise would make a system-admin action unapprovable.
234
+ *
235
+ * @param {number} level
236
+ * @returns {number|null}
237
+ */
238
+ export function requiredApproverLevel(level) {
239
+ const band = bandOf(level);
240
+ if (band === null) return null;
241
+ if (band.name === 'system_admin') return 90;
242
+ const higher = AUTHORITY_BANDS[AUTHORITY_BANDS.indexOf(band) + 1];
243
+ return higher === undefined ? null : higher.min;
244
+ }
245
+
246
+ /**
247
+ * Is `approver` allowed to sign off on `subject`'s pending action?
248
+ *
249
+ * Strictly higher, not equal-or-higher. Two captains approving each other is a peer ring, and the whole
250
+ * point of the chain is that a decision can be escalated past the level that wanted it. Self-approval is
251
+ * refused for the same reason, stated separately because it is the case an implementation is most likely
252
+ * to let through by comparing only levels.
253
+ *
254
+ * @param {{level: number, id?: string|null}} approver
255
+ * @param {{level: number, id?: string|null}} subject
256
+ * @returns {{ok: boolean, reason: string, required: number|null}}
257
+ */
258
+ export function mayApprove(approver, subject) {
259
+ const required = requiredApproverLevel(subject?.level);
260
+ if (required === null) {
261
+ return { ok: false, required: null, reason: `subject has no usable authority level (got ${JSON.stringify(subject?.level ?? null)})` };
262
+ }
263
+ if (!isAuthorityLevel(approver?.level)) {
264
+ return { ok: false, required, reason: `approver has no usable authority level (got ${JSON.stringify(approver?.level ?? null)})` };
265
+ }
266
+ if (approver.id !== undefined && approver.id !== null && approver.id === subject?.id) {
267
+ return { ok: false, required, reason: 'an actor may not approve its own request' };
268
+ }
269
+ if (approver.level < required) {
270
+ return { ok: false, required, reason: `approver level ${approver.level} is below the required ${required}` };
271
+ }
272
+ return { ok: true, required, reason: `approver level ${approver.level} meets the required ${required}` };
273
+ }
274
+
275
+ /**
276
+ * Compose a child actor's authority from its parent's.
277
+ *
278
+ * **This never increases it.** No rule in the source design grants delegation, and the default an
279
+ * implementation reaches for -- "a captain's delegate is a captain" -- turns any single compromised
280
+ * actor into an escalation path. Where the source is silent, the safe reading is the narrow one, and the
281
+ * `reason` says which of the two rules applied so a later reader can tell a deliberate cap from a bug.
282
+ *
283
+ * @param {{level: number, id?: string|null}} parent
284
+ * @param {{level?: number, cap?: number|null, id?: string|null}} child
285
+ * @returns {{level: number|null, capped: boolean, reason: string}}
286
+ */
287
+ export function deriveAuthority(parent, child = {}) {
288
+ if (!isAuthorityLevel(parent?.level)) {
289
+ return { level: null, capped: false, reason: `parent has no usable authority level (got ${JSON.stringify(parent?.level ?? null)})` };
290
+ }
291
+ if (child.level !== undefined && !isAuthorityLevel(child.level)) {
292
+ return { level: null, capped: false, reason: `child level is not usable (got ${JSON.stringify(child.level)})` };
293
+ }
294
+ const asked = child.level ?? parent.level;
295
+ const cap = isAuthorityLevel(child.cap) ? child.cap : parent.level;
296
+ const ceiling = Math.min(parent.level, cap);
297
+ const level = Math.min(asked, ceiling);
298
+ return {
299
+ level,
300
+ capped: level < asked,
301
+ reason: level < asked
302
+ ? `reduced from ${asked} to ${level}: a derived actor never exceeds its parent (${parent.level}) or its cap (${cap})`
303
+ : `granted ${level}: within parent (${parent.level}) and cap (${cap})`,
304
+ };
305
+ }
@@ -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.9.7';
188
+ const PLUGIN_VERSION = '0.9.8';
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;