@twinsearth/w2m-dsh-plugin 0.10.4 → 0.11.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 +101 -0
- package/FLEET-RULES.md +146 -0
- package/package.json +6 -4
- package/src/agent/council.mjs +347 -0
- package/src/plugin/tools.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,107 @@ 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.11.0] — 2026-10-15
|
|
8
|
+
|
|
9
|
+
**Committee governance (roadmap v4.10.0): one decision engine, ordered phases, weighted votes, retained dissent.**
|
|
10
|
+
|
|
11
|
+
### Built now, wired to nothing — and that is the roadmap's own instruction
|
|
12
|
+
|
|
13
|
+
ROADMAP §3 says a governance layer above a fleet *that has no disagreements yet* is indistinguishable from
|
|
14
|
+
a delay, and that the council earns its place when two fleets want the same machine. So `council.mjs` ships
|
|
15
|
+
as the decision engine, tested in isolation, with no caller. Building it now is justified because its
|
|
16
|
+
semantics are testable; deploying it now would not be.
|
|
17
|
+
|
|
18
|
+
### The three falsifying tests the roadmap names, as the suite's actual cases
|
|
19
|
+
|
|
20
|
+
1. **A phase cannot be skipped.** `advance()` takes no argument, so there is no way to name a destination;
|
|
21
|
+
`goTo`/`setPhase`/`jumpTo` do not exist and a test asserts their absence. A vote before the vote phase
|
|
22
|
+
refuses with `COUNCIL_UNCLEARED_TO_VOTE`, and the message says why: *a vote on nothing*.
|
|
23
|
+
2. **A vote's weights are reproducible from stored inputs.** `weightOf()` is a pure function of two stored
|
|
24
|
+
integers, so `recount()` reproduces an outcome on any machine. The falsification is in the suite: a
|
|
25
|
+
tampered weight is **detected** rather than reproduced, and the tally is asserted order-independent.
|
|
26
|
+
3. **A decision is retrievable *with* its dissent.** Dissent is part of the `Decision` value, not a log
|
|
27
|
+
line, and `unanimous` is an explicit flag because an empty dissent list alone cannot distinguish
|
|
28
|
+
unanimous from unrecorded. A test seals a decision and checks that dropping a ballot changes the seal.
|
|
29
|
+
|
|
30
|
+
### The design choice worth naming
|
|
31
|
+
|
|
32
|
+
`weight = authority × (1 + reputation/100)`, so reputation is a bounded multiplier in `[1, 2)` and **can
|
|
33
|
+
never overturn authority**: authority 49 at perfect reputation (98) still loses to a bare authority 99. A
|
|
34
|
+
governance layer that let reputation outvote the authority model would be a second, undeclared authority
|
|
35
|
+
model, and v4.7.0 already owns that question. A tie is a **refusal**, not a coin toss, because a split
|
|
36
|
+
council is exactly the case a person must see — and the refusal carries the tally that shows the split.
|
|
37
|
+
|
|
38
|
+
### Two design bugs the suite caught, both the same mistake
|
|
39
|
+
|
|
40
|
+
`record()` sets the seal, and `adjourn` comes **after** `record`. Deriving "sealed" from `decision !== null`
|
|
41
|
+
made `record()` block `advance()`, so the final phase was unreachable; and then having `advance()` consult
|
|
42
|
+
the seal at all was self-contradictory for the same reason. The fix is the distinction the module now states
|
|
43
|
+
explicitly: **the seal protects the decision, not the phase machine**. Conflating "decided" with "sealed"
|
|
44
|
+
produced a state nobody could reach — twice.
|
|
45
|
+
|
|
46
|
+
**28 tests, 0 failures.**
|
|
47
|
+
|
|
48
|
+
## [0.10.5] — 2026-10-15
|
|
49
|
+
|
|
50
|
+
**FLEET-RULES.md enters the tree, with this host's reachability measured and written down.**
|
|
51
|
+
|
|
52
|
+
### What was actually wrong
|
|
53
|
+
|
|
54
|
+
The 1.0.0 objective starts with *"local Windows and MacMini jointly"*, and on inspection the Windows host
|
|
55
|
+
had never been a git participant at all: its working copy was not a repository, and every release so far had
|
|
56
|
+
been published through the GitHub REST API rather than through git. That is a real gap and it was recorded
|
|
57
|
+
as one rather than glossed over.
|
|
58
|
+
|
|
59
|
+
### Why it was that way, measured rather than assumed
|
|
60
|
+
|
|
61
|
+
`github.com:443` is unreachable from this host while every other GitHub endpoint answers. Three consecutive
|
|
62
|
+
`git fetch` attempts failed after ~21 s each, and from the same process minutes apart:
|
|
63
|
+
|
|
64
|
+
| Endpoint | Result |
|
|
65
|
+
|---|---|
|
|
66
|
+
| `github.com:443` | **TIMEOUT** |
|
|
67
|
+
| `api.github.com:443` | OPEN 73 ms |
|
|
68
|
+
| `codeload.github.com:443` | OPEN 77 ms |
|
|
69
|
+
| `ssh.github.com:443` | OPEN 95 ms |
|
|
70
|
+
| `objects.githubusercontent.com:443` | OPEN 228 ms |
|
|
71
|
+
|
|
72
|
+
The blocking is **per hostname, not per port** — `ssh.github.com` answers on the same 443 that `github.com`
|
|
73
|
+
does not. It is also **intermittent at the TCP layer**: a raw `net.connect` to `20.205.243.166:443` timed out
|
|
74
|
+
while a `tls.connect` to the same IP with `servername: 'github.com'` completed `authorized: true` on TLS 1.3.
|
|
75
|
+
An intermittent block is worse than a solid one, because a single success is not evidence the path works.
|
|
76
|
+
|
|
77
|
+
The first two attempts at this treated the failure as a configuration problem and tried proxy settings and
|
|
78
|
+
retries. Both were wrong, and both cost a round. The measurements are cheaper to read than to repeat, which
|
|
79
|
+
is why they are now rule 8 of `FLEET-RULES.md` rather than a note in a session log.
|
|
80
|
+
|
|
81
|
+
### What now works, and what the evidence is
|
|
82
|
+
|
|
83
|
+
The repository is materialised from a `codeload` tarball (the one path that is reachable), giving a real git
|
|
84
|
+
work tree containing exactly `main` — verified by hashing both trees: **227 files, 0 differences,
|
|
85
|
+
`IDENTICAL`**. `scripts/fleet-sync.mjs` then ran against a real repository for the first time, having only
|
|
86
|
+
ever been tested against a temporary one:
|
|
87
|
+
|
|
88
|
+
| Step | Result |
|
|
89
|
+
|---|---|
|
|
90
|
+
| initial | `state=up-to-date action=none` |
|
|
91
|
+
| local commit | `state=ahead action=push ahead=1` |
|
|
92
|
+
| `--write` | `performed: true - pushed 1 commit(s)`; mirror and HEAD agree |
|
|
93
|
+
| peer commit arrives | `state=behind action=fast-forward behind=1` |
|
|
94
|
+
| `--write` | `performed: true - now at d42d5c0`; 228 files, peer file present |
|
|
95
|
+
| final | `state=up-to-date action=none` |
|
|
96
|
+
|
|
97
|
+
The same run also demonstrated the refusal path for free: when a fast-forward was rejected by git, the
|
|
98
|
+
result was `performed: false`, the tree was **left untouched**, and the state stayed `behind` — an automatic
|
|
99
|
+
sync that fails must not half-apply.
|
|
100
|
+
|
|
101
|
+
### Stated plainly rather than implied
|
|
102
|
+
|
|
103
|
+
This host can *pull* the truth and can *run* the sync machinery, but it **cannot push over git**. A round
|
|
104
|
+
that needs a commit published must use the API, and a round that exercises `fleet-sync` must do it against a
|
|
105
|
+
local bare remote. That is a real test of the state machine and **not** a test of the network, and the
|
|
106
|
+
difference is written into the rule so it is not mistaken for one.
|
|
107
|
+
|
|
7
108
|
## [0.10.4] — 2026-10-15
|
|
8
109
|
|
|
9
110
|
**A result channel over exit codes, for machines whose text cannot be read back.**
|
package/FLEET-RULES.md
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# Fleet working rules
|
|
2
|
+
|
|
3
|
+
Standing rules for every machine and every session in this fleet. These are **rules**, not notes: a
|
|
4
|
+
session that finds itself working around one of these is either misreading it or should be proposing an
|
|
5
|
+
amendment, not quietly making an exception.
|
|
6
|
+
|
|
7
|
+
Established 2026-10-14/15. Amended by appending with a date and a reason — never by deleting, because
|
|
8
|
+
*"we believed X until we measured Y"* is itself knowledge, and it is what stops the same wrong conclusion
|
|
9
|
+
being reached twice.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## 1. Broadcast, sync, merge — automatically, in the same fleet
|
|
14
|
+
|
|
15
|
+
**Added 2026-10-15 at the operator's instruction.**
|
|
16
|
+
|
|
17
|
+
Any machine that is in the same fleet **broadcasts its state, syncs from the fleet, and merges without
|
|
18
|
+
being asked**. This is not an optimisation; it is the rule that removes the class of work that produced
|
|
19
|
+
2026-10-14's largest time sink — a session holding one copy of the truth on one machine while another
|
|
20
|
+
machine held a different copy, and a human having to notice.
|
|
21
|
+
|
|
22
|
+
### What the rule means concretely
|
|
23
|
+
|
|
24
|
+
| Verb | Obligation |
|
|
25
|
+
|---|---|
|
|
26
|
+
| **Broadcast** | Before finishing a unit of work, publish what changed to the shared record — commit to the remote, not to a local branch nobody else can see. |
|
|
27
|
+
| **Sync** | On starting a unit of work, fetch. A machine that has not fetched does not know what it is working on. |
|
|
28
|
+
| **Merge** | Reconcile rather than choose. Two machines that both changed something must end with one tree, not with one machine's version silently winning. |
|
|
29
|
+
|
|
30
|
+
### The shared record is the remote
|
|
31
|
+
|
|
32
|
+
One repository, one remote, `main` as the integration point. Local-only work is not broadcast, and a
|
|
33
|
+
branch that is not pushed is a branch no other machine can sync from — which is why a work-in-progress
|
|
34
|
+
reaches another machine as a **pushed branch**, never as a file copied by hand.
|
|
35
|
+
|
|
36
|
+
### What must never be merged
|
|
37
|
+
|
|
38
|
+
- **A tree that has not passed its own tests.** Merge is not the place to discover a failing suite. The
|
|
39
|
+
rule is: run the suite where the change will land, and merge after it is green.
|
|
40
|
+
- **Generated or machine-local state.** `dist/`, `node_modules/`, `memory.jsonl`, state directories and
|
|
41
|
+
credentials are per-machine facts; broadcasting them would spread one machine's local state over
|
|
42
|
+
another's. The repository already excludes these at the release boundary, and the same list applies
|
|
43
|
+
here.
|
|
44
|
+
- **Anything a machine could not itself verify.** An unverified claim is broadcast as *unverified*, never
|
|
45
|
+
as a fact — see rule 4.
|
|
46
|
+
|
|
47
|
+
### Why the rule is safe to apply automatically
|
|
48
|
+
|
|
49
|
+
Git's fast-forward is the common case: one machine ahead, everyone else behind. A genuine divergence is
|
|
50
|
+
the case that needs judgement, so the automation is: **fast-forward automatically, rebase automatically
|
|
51
|
+
when the local work is not yet published, and stop and report when both sides have published** — because
|
|
52
|
+
that is the only case where merging could discard someone's verified work.
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## 2. Do not claim a mechanism works from a scheduling record
|
|
57
|
+
|
|
58
|
+
Added 2026-10-14, after two mistakes in one session.
|
|
59
|
+
|
|
60
|
+
A dispatch being accepted, a lease being granted, and a verdict arriving are three different
|
|
61
|
+
observations. The reliable evidence is **a side effect on the target machine**, or **an exit code that
|
|
62
|
+
nothing else would produce**. "The relay said `exit_code: 0`" is not evidence that a command ran until
|
|
63
|
+
something else corroborates it.
|
|
64
|
+
|
|
65
|
+
## 3. Run the suite where the change will land
|
|
66
|
+
|
|
67
|
+
Added 2026-10-14. A green suite on the development host and a green suite on the target machine are
|
|
68
|
+
different claims, and only the second one supports a merge. `scripts/` and `test/` are absent from the
|
|
69
|
+
published tarball by design, so "it passes at home" cannot be checked from an installed copy.
|
|
70
|
+
|
|
71
|
+
## 4. Mark what could not be verified
|
|
72
|
+
|
|
73
|
+
Added 2026-10-14. If a check cannot run here — no network, no installation, no credential — it is reported
|
|
74
|
+
as `UNVERIFIED` with the reason, never as a pass. An unverifiable check is not a passing check.
|
|
75
|
+
|
|
76
|
+
## 5. Facts carry their date and how they were measured
|
|
77
|
+
|
|
78
|
+
Added 2026-10-14, from `fleet-memory.md`.
|
|
79
|
+
|
|
80
|
+
A fact without provenance is a rumour that outlives the person who could have checked it. Correct in
|
|
81
|
+
place with a note rather than deleting: the correction is part of the record.
|
|
82
|
+
|
|
83
|
+
## 6. Machine roles are not interchangeable
|
|
84
|
+
|
|
85
|
+
Added 2026-10-14.
|
|
86
|
+
|
|
87
|
+
| Machine | Role | Constraint |
|
|
88
|
+
|---|---|---|
|
|
89
|
+
| the Linux relay host | relay, STUN, archive, and the development shell over SSH | no personal data |
|
|
90
|
+
| MacMini | real-machine work: deploy, run, test, verify, report, and now development | no personal data |
|
|
91
|
+
| `ToFuture` (the DSH host) | orchestration | **hard-protected**: nothing outside the session workspace or non-system `C:` folders without the operator typing the password |
|
|
92
|
+
| other Windows hosts | as MacMini | — |
|
|
93
|
+
|
|
94
|
+
## 7. A note about the rule itself
|
|
95
|
+
|
|
96
|
+
Rule 1 says merge automatically. That is a real transfer of authority from the human to the machines, and
|
|
97
|
+
it is only defensible while the checks in rules 2, 3 and 4 hold: **automatic merging is safe exactly to
|
|
98
|
+
the extent that "green" is trustworthy.** If a suite is ever found to pass without testing what it names
|
|
99
|
+
— which happened twice on 2026-10-14, once in a cycle-detection test that asserted nothing — then this
|
|
100
|
+
rule is the thing that spreads the damage. So a test that does not test is not a small defect here; it is
|
|
101
|
+
a hole in the merge gate.
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## 8. This host's reachability, measured — do not re-derive it
|
|
106
|
+
|
|
107
|
+
**Added 2026-10-15 after round 3 of the 1.0.0 goal, because the same wrong assumption cost three rounds.**
|
|
108
|
+
|
|
109
|
+
The Windows host that writes this file cannot reach `github.com:443`. Measured, not inferred: three
|
|
110
|
+
consecutive `git fetch` attempts each failed after ~21 s with *"Failed to connect ... Could not connect to
|
|
111
|
+
server"*, while the following, probed from the same process minutes apart, all succeeded:
|
|
112
|
+
|
|
113
|
+
| Endpoint | Result |
|
|
114
|
+
|---|---|
|
|
115
|
+
| `github.com:443` | **TIMEOUT** |
|
|
116
|
+
| `api.github.com:443` | OPEN 73 ms |
|
|
117
|
+
| `codeload.github.com:443` | OPEN 77 ms |
|
|
118
|
+
| `ssh.github.com:443` | OPEN 95 ms |
|
|
119
|
+
| `objects.githubusercontent.com:443` | OPEN 228 ms |
|
|
120
|
+
|
|
121
|
+
The blocking is **per hostname, not per port** — `ssh.github.com` answers on the same 443 that
|
|
122
|
+
`github.com` does not. It is also **intermittent at the TCP layer**: a raw `net.connect` to
|
|
123
|
+
`20.205.243.166:443` timed out while a `tls.connect` to the same IP with `servername: 'github.com'`
|
|
124
|
+
completed with `authorized: true` and TLS 1.3. An intermittent block is worse than a solid one, because a
|
|
125
|
+
single success is not evidence that the path works.
|
|
126
|
+
|
|
127
|
+
### What this means in practice, so the next session does not repeat it
|
|
128
|
+
|
|
129
|
+
* **`git clone` / `git fetch` / `git push` over HTTPS do not work on this host.** Do not build a plan
|
|
130
|
+
around them and do not retry them hoping for a different result.
|
|
131
|
+
* **The working path to materialise the repository is a tarball from `codeload`**:
|
|
132
|
+
`https://codeload.github.com/TwinsEarth/dsh-windows2macos/tar.gz/refs/heads/main`, unpacked with
|
|
133
|
+
`tar --strip-components=1` into a directory that is then `git init`-ed. That yields a real work tree
|
|
134
|
+
containing exactly `main`, which is what `fleet-sync` needs.
|
|
135
|
+
* **Publishing from this host goes through the REST API**, not through git. Every release so far was
|
|
136
|
+
pushed that way.
|
|
137
|
+
* **The consequence to state plainly:** this host can *pull* the truth and can *run* the sync machinery, but
|
|
138
|
+
it cannot *push* over git. A round that needs a commit published must use the API, and a round that needs
|
|
139
|
+
`fleet-sync` exercised must do it against a local bare remote — which is a real test of the state
|
|
140
|
+
machine, and is **not** a test of the network.
|
|
141
|
+
|
|
142
|
+
### Why this is a rule and not a note
|
|
143
|
+
|
|
144
|
+
The first two attempts at "make Windows a git participant" assumed the failure was a configuration problem
|
|
145
|
+
and tried proxy settings and retries. Both were wrong, and both cost a round. **The measurements above are
|
|
146
|
+
cheaper to read than to repeat.**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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",
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"CHANGELOG.md",
|
|
30
30
|
"HANDOFF.md",
|
|
31
31
|
"ROADMAP.md",
|
|
32
|
+
"FLEET-RULES.md",
|
|
32
33
|
"LICENSE",
|
|
33
34
|
"PROTOCOL.md",
|
|
34
35
|
"PROTOCOL-v0.1.2.md",
|
|
@@ -112,13 +113,13 @@
|
|
|
112
113
|
}
|
|
113
114
|
},
|
|
114
115
|
"scripts": {
|
|
115
|
-
"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/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/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
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",
|
|
117
118
|
"test:relay": "node --test --test-force-exit test/relay.test.mjs",
|
|
118
119
|
"test:agent": "node --test --test-force-exit test/agent.test.mjs test/memory.test.mjs",
|
|
119
120
|
"test:tools": "node --test --test-force-exit test/tools.test.mjs",
|
|
120
121
|
"test:crossnetwork": "node --test --test-force-exit test/crossnetwork.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/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/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/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
123
|
"pack": "node scripts/pack.mjs",
|
|
123
124
|
"verify:join": "node scripts/verify-join.mjs",
|
|
124
125
|
"lint": "eslint .",
|
|
@@ -134,7 +135,8 @@
|
|
|
134
135
|
"test:fleet-sync": "node --test --test-force-exit test/fleet-sync.test.mjs",
|
|
135
136
|
"test:health": "node --test --test-force-exit test/health.test.mjs",
|
|
136
137
|
"test:storage": "node --test --test-force-exit test/storage.test.mjs",
|
|
137
|
-
"test:exit-channel": "node --test --test-force-exit test/exit-channel.test.mjs"
|
|
138
|
+
"test:exit-channel": "node --test --test-force-exit test/exit-channel.test.mjs",
|
|
139
|
+
"test:council": "node --test --test-force-exit test/council.test.mjs"
|
|
138
140
|
},
|
|
139
141
|
"keywords": [
|
|
140
142
|
"dsh-bundle",
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The council: one decision engine with ordered phases, weighted votes, and retained dissent.
|
|
3
|
+
*
|
|
4
|
+
* ## What this is for, and what it refuses to be
|
|
5
|
+
*
|
|
6
|
+
* The source design describes a six-role council and the ROADMAP's own §3 is explicit that a governance
|
|
7
|
+
* layer above a fleet **that has no disagreements yet** is indistinguishable from a delay. So this module is
|
|
8
|
+
* not wired into anything: it is the decision engine itself, built and tested, waiting for the measurement
|
|
9
|
+
* that earns it a caller. Building it now is justified because its *semantics* are testable in isolation;
|
|
10
|
+
* deploying it now would not be, and is not done.
|
|
11
|
+
*
|
|
12
|
+
* ## The three properties the ROADMAP names, and how each is enforced
|
|
13
|
+
*
|
|
14
|
+
* 1. **A phase cannot be skipped.** Phases are an ordered list and `advance()` moves exactly one step, to
|
|
15
|
+
* the declared next phase or nowhere. There is no `goTo(phase)` and no way to open a vote before the
|
|
16
|
+
* deliberation phase has closed, because the vote reads the arguments it claims to have considered.
|
|
17
|
+
* 2. **A vote's weights are reproducible from stored inputs.** `weightOf()` is a pure function of an
|
|
18
|
+
* authority level and a reputation, both of which are stored on the ballot. Nothing about a weight comes
|
|
19
|
+
* from the wall clock, the environment, or the order votes arrived in — so `recount()` on a stored
|
|
20
|
+
* decision reproduces the original outcome, and a discrepancy is detectable rather than arguable.
|
|
21
|
+
* 3. **A decision with recorded dissent is retrievable *with* the dissent.** Dissent is not a log line; it
|
|
22
|
+
* is part of the `Decision` value. A reader who asks for a decision gets the losing arguments, because
|
|
23
|
+
* "what did the people who disagreed say" is the question that makes a decision auditable later.
|
|
24
|
+
*
|
|
25
|
+
* ## Why dissent is retained even when it changes nothing
|
|
26
|
+
*
|
|
27
|
+
* A council that records only outcomes cannot answer "was this contested?" after the fact, and the answer
|
|
28
|
+
* determines whether a decision should be revisited or trusted. Losing arguments are the *reason* the
|
|
29
|
+
* majority is a majority, so discarding them discards the only evidence that the question was hard.
|
|
30
|
+
*
|
|
31
|
+
* ## Design choices that are deliberately narrow
|
|
32
|
+
*
|
|
33
|
+
* * **Reputation is an input, not an inference.** This module never computes a reputation from past
|
|
34
|
+
* behaviour, because a reputation derived here would be a second, hidden authority model competing with
|
|
35
|
+
* v4.7.0's. A caller supplies it; the ballot records it; the recount reproduces it.
|
|
36
|
+
* * **A tie is a refusal, not a coin toss.** Returning *some* outcome for a tie would hide that the council
|
|
37
|
+
* is split, and a split council is precisely the case a human must see.
|
|
38
|
+
*/
|
|
39
|
+
import { createHash } from 'node:crypto';
|
|
40
|
+
|
|
41
|
+
/** The seven phases, in order. Index is the position; there is no other ordering. */
|
|
42
|
+
export const PHASES = Object.freeze([
|
|
43
|
+
'convene', // the question is stated and the council is formed
|
|
44
|
+
'frame', // the question is bounded: what is in scope, what is not
|
|
45
|
+
'deliberate', // arguments are recorded, for and against
|
|
46
|
+
'vote', // weighted votes are cast
|
|
47
|
+
'decide', // the outcome is fixed and dissent is attached
|
|
48
|
+
'record', // the decision is sealed with a hash over its inputs
|
|
49
|
+
'adjourn', // the council closes; nothing further may be added
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
/** The six roles of the AURA council. A role is a hat, not a machine: one machine may hold several. */
|
|
53
|
+
export const ROLES = Object.freeze(['strategist', 'analyst', 'critic', 'auditor', 'executor', 'operator']);
|
|
54
|
+
|
|
55
|
+
/** Refusal codes, so a caller branches on a value rather than on prose. */
|
|
56
|
+
export const COUNCIL_REFUSAL = Object.freeze({
|
|
57
|
+
UNKNOWN_PHASE: 'COUNCIL_UNKNOWN_PHASE',
|
|
58
|
+
WRONG_PHASE: 'COUNCIL_WRONG_PHASE',
|
|
59
|
+
ALREADY_SEALED: 'COUNCIL_ALREADY_SEALED',
|
|
60
|
+
UNKNOWN_ROLE: 'COUNCIL_UNKNOWN_ROLE',
|
|
61
|
+
UNCLEARED_TO_VOTE: 'COUNCIL_UNCLEARED_TO_VOTE',
|
|
62
|
+
ALREADY_VOTED: 'COUNCIL_ALREADY_VOTED',
|
|
63
|
+
BAD_WEIGHT_INPUT: 'COUNCIL_BAD_WEIGHT_INPUT',
|
|
64
|
+
TIED: 'COUNCIL_TIED',
|
|
65
|
+
NOT_FOUND: 'COUNCIL_NOT_FOUND',
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
export class CouncilRefusal extends Error {
|
|
69
|
+
constructor(code, detail, extra = {}) {
|
|
70
|
+
super(`${code}: ${detail}`);
|
|
71
|
+
this.name = 'CouncilRefusal';
|
|
72
|
+
this.code = code;
|
|
73
|
+
this.detail = detail;
|
|
74
|
+
Object.assign(this, extra);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Authority bands, taken from v4.7.0: 0-49 ordinary, 50-69 captain, 70-89 org admin, 90+ system admin. */
|
|
79
|
+
export const AUTHORITY_MAX = 100;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The weight of one participant, as a pure function of the two numbers on its ballot.
|
|
83
|
+
*
|
|
84
|
+
* **This is the whole reproducibility property.** Every input is a stored integer, so the same ballot
|
|
85
|
+
* recounts to the same weight on any machine and at any time. The formula is deliberately simple and
|
|
86
|
+
* deliberately *not* clever:
|
|
87
|
+
*
|
|
88
|
+
* weight = authority × (1 + reputation/100)
|
|
89
|
+
*
|
|
90
|
+
* Authority is the primary term because v4.7.0 already defines what it means and this module has no standing
|
|
91
|
+
* to reinterpret it. Reputation is a bounded multiplier in `[1, 2)`, so reputation can promote a voice but
|
|
92
|
+
* **can never outvote authority**: a participant at authority 40 with a perfect reputation weighs 80, which
|
|
93
|
+
* is still less than a bare authority of 90. That ordering is the point — a governance layer that let
|
|
94
|
+
* reputation overturn the authority model would be a second, undeclared authority model.
|
|
95
|
+
*
|
|
96
|
+
* @param {{authority: number, reputation: number}} ballot
|
|
97
|
+
* @returns {number} the weight, exact and reproducible
|
|
98
|
+
*/
|
|
99
|
+
export function weightOf({ authority, reputation } = {}) {
|
|
100
|
+
if (!Number.isInteger(authority) || authority < 0 || authority > AUTHORITY_MAX) {
|
|
101
|
+
throw new CouncilRefusal(COUNCIL_REFUSAL.BAD_WEIGHT_INPUT, `authority must be an integer in 0..${AUTHORITY_MAX}, got ${JSON.stringify(authority)}`);
|
|
102
|
+
}
|
|
103
|
+
if (!Number.isFinite(reputation) || reputation < 0 || reputation > 100) {
|
|
104
|
+
throw new CouncilRefusal(COUNCIL_REFUSAL.BAD_WEIGHT_INPUT, `reputation must be a number in 0..100, got ${JSON.stringify(reputation)}`);
|
|
105
|
+
}
|
|
106
|
+
// Rounded to 6 places so a floating-point tail cannot make two equal ballots differ in a recount.
|
|
107
|
+
return Math.round(authority * (1 + reputation / 100) * 1e6) / 1e6;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* A stable hash over everything a decision depended on.
|
|
112
|
+
*
|
|
113
|
+
* Exists so "the weights are reproducible" is checkable rather than assertable: recompute the seal from the
|
|
114
|
+
* stored inputs and compare. The fields are sorted and JSON-encoded with no spacing, so the seal does not
|
|
115
|
+
* depend on property order.
|
|
116
|
+
*/
|
|
117
|
+
export function sealOf(decision) {
|
|
118
|
+
const inputs = {
|
|
119
|
+
question: decision.question,
|
|
120
|
+
phase_reached: decision.phase_reached,
|
|
121
|
+
outcomes: (decision.ballots ?? []).map((b) => ({
|
|
122
|
+
participant: b.participant, role: b.role, authority: b.authority,
|
|
123
|
+
reputation: b.reputation, choice: b.choice, weight: b.weight, reason: b.reason,
|
|
124
|
+
})),
|
|
125
|
+
arguments: (decision.arguments ?? []).map((a) => ({ participant: a.participant, stance: a.stance, text: a.text })),
|
|
126
|
+
};
|
|
127
|
+
return createHash('sha256').update(JSON.stringify(inputs)).digest('hex');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Open a council.
|
|
132
|
+
*
|
|
133
|
+
* @param {object} options
|
|
134
|
+
* @param {string} options.question what is being decided
|
|
135
|
+
* @param {string[]} [options.participants] who is in the room, before roles are assigned
|
|
136
|
+
* @param {() => number} [options.now] injected clock
|
|
137
|
+
*/
|
|
138
|
+
export function convene({ question, participants = [], now = () => Date.now() } = {}) {
|
|
139
|
+
if (typeof question !== 'string' || question.trim() === '') throw new TypeError('convene: question is required');
|
|
140
|
+
|
|
141
|
+
/** @type {Array<{participant: string, role: string|null}>} */
|
|
142
|
+
const members = participants.map((p) => ({ participant: p, role: null }));
|
|
143
|
+
let phaseIndex = 0;
|
|
144
|
+
const arguments_ = [];
|
|
145
|
+
const ballots = [];
|
|
146
|
+
let decision = null;
|
|
147
|
+
/**
|
|
148
|
+
* Whether the decision has been *sealed*, which is not the same as whether it exists.
|
|
149
|
+
*
|
|
150
|
+
* The first version derived this from `decision !== null`, and that made `record()` block `advance()` —
|
|
151
|
+
* so the council could never reach `adjourn`, which comes after `record`. The tests caught it in two
|
|
152
|
+
* places. The distinction is worth keeping explicit: a decided-but-unsealed outcome is still open to being
|
|
153
|
+
* recorded, and only a sealed one is closed to every change.
|
|
154
|
+
*/
|
|
155
|
+
let isSealed = false;
|
|
156
|
+
const openedAt = new Date(now()).toISOString();
|
|
157
|
+
|
|
158
|
+
const currentPhase = () => PHASES[phaseIndex];
|
|
159
|
+
const refuseIfSealed = () => {
|
|
160
|
+
if (isSealed) throw new CouncilRefusal(COUNCIL_REFUSAL.ALREADY_SEALED, 'the council has adjourned; a sealed decision is not modified');
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/** Assign a role. Roles are additive, so one participant may hold several -- a fact worth allowing. */
|
|
164
|
+
function assign(participant, role) {
|
|
165
|
+
refuseIfSealed();
|
|
166
|
+
if (!ROLES.includes(role)) throw new CouncilRefusal(COUNCIL_REFUSAL.UNKNOWN_ROLE, `${JSON.stringify(role)} is not one of ${ROLES.join(', ')}`);
|
|
167
|
+
if (phaseIndex > 0) throw new CouncilRefusal(COUNCIL_REFUSAL.WRONG_PHASE, `roles are assigned during "convene"; the council is in "${currentPhase()}"`);
|
|
168
|
+
const existing = members.find((m) => m.participant === participant);
|
|
169
|
+
if (existing) existing.role = existing.role === null ? role : `${existing.role},${role}`;
|
|
170
|
+
else members.push({ participant, role });
|
|
171
|
+
return { participant, role };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Move exactly one phase forward.
|
|
176
|
+
*
|
|
177
|
+
* There is no way to move two phases or to name a destination. A phase cannot be skipped because the only
|
|
178
|
+
* transition available is to the declared successor -- and that is the ROADMAP's first falsifying test,
|
|
179
|
+
* enforced structurally rather than by a check a caller could bypass.
|
|
180
|
+
*
|
|
181
|
+
* **`advance()` deliberately does not consult the seal.** The seal protects the *decision* from being
|
|
182
|
+
* changed; it is not a lock on the phase machine. Tying the two together is self-contradictory, because
|
|
183
|
+
* `record` seals and `adjourn` comes after it -- so a seal that blocked advancement would make the final
|
|
184
|
+
* phase unreachable. Two tests caught that, which is the second time in this module that conflating
|
|
185
|
+
* "decided" with "sealed" produced a state nobody could reach.
|
|
186
|
+
*/
|
|
187
|
+
function advance() {
|
|
188
|
+
if (phaseIndex >= PHASES.length - 1) return { phase: currentPhase(), moved: false, detail: 'already in the final phase' };
|
|
189
|
+
phaseIndex += 1;
|
|
190
|
+
return { phase: currentPhase(), moved: true };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Record an argument. Only during `deliberate`, because arguments exist to be considered by the vote. */
|
|
194
|
+
function argue({ participant, stance, text }) {
|
|
195
|
+
refuseIfSealed();
|
|
196
|
+
if (currentPhase() !== 'deliberate') {
|
|
197
|
+
throw new CouncilRefusal(COUNCIL_REFUSAL.WRONG_PHASE, `arguments are recorded during "deliberate"; the council is in "${currentPhase()}"`);
|
|
198
|
+
}
|
|
199
|
+
if (stance !== 'for' && stance !== 'against') {
|
|
200
|
+
throw new CouncilRefusal(COUNCIL_REFUSAL.WRONG_PHASE, `stance must be "for" or "against", got ${JSON.stringify(stance)}`);
|
|
201
|
+
}
|
|
202
|
+
const entry = { participant, stance, text: String(text ?? '') };
|
|
203
|
+
arguments_.push(entry);
|
|
204
|
+
return entry;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Cast a weighted vote. Only during `vote`, and only after the deliberation phase has closed. */
|
|
208
|
+
function vote({ participant, role = null, authority, reputation, choice, reason = null }) {
|
|
209
|
+
refuseIfSealed();
|
|
210
|
+
if (currentPhase() !== 'vote') {
|
|
211
|
+
throw new CouncilRefusal(
|
|
212
|
+
COUNCIL_REFUSAL.UNCLEARED_TO_VOTE,
|
|
213
|
+
`votes are cast during "vote"; the council is in "${currentPhase()}". A vote before deliberation would be a vote on nothing.`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
if (!Number.isInteger(authority)) throw new CouncilRefusal(COUNCIL_REFUSAL.BAD_WEIGHT_INPUT, 'authority must be an integer');
|
|
217
|
+
const weight = weightOf({ authority, reputation });
|
|
218
|
+
if (ballots.some((b) => b.participant === participant)) {
|
|
219
|
+
throw new CouncilRefusal(COUNCIL_REFUSAL.ALREADY_VOTED, `${participant} has already voted; a second ballot would let one participant vote twice`);
|
|
220
|
+
}
|
|
221
|
+
const ballot = { participant, role, authority, reputation, choice, weight, reason, cast_at: new Date(now()).toISOString() };
|
|
222
|
+
ballots.push(ballot);
|
|
223
|
+
return ballot;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Fix the outcome and attach the dissent.
|
|
228
|
+
*
|
|
229
|
+
* A tie is refused rather than broken. Returning *some* outcome for a tie would hide that the council is
|
|
230
|
+
* split, and a split council is exactly the case a person must see.
|
|
231
|
+
*/
|
|
232
|
+
function decide() {
|
|
233
|
+
refuseIfSealed();
|
|
234
|
+
if (currentPhase() !== 'decide') {
|
|
235
|
+
throw new CouncilRefusal(COUNCIL_REFUSAL.WRONG_PHASE, `the outcome is fixed during "decide"; the council is in "${currentPhase()}"`);
|
|
236
|
+
}
|
|
237
|
+
const tally = {};
|
|
238
|
+
for (const b of ballots) tally[b.choice] = Math.round(((tally[b.choice] ?? 0) + b.weight) * 1e6) / 1e6;
|
|
239
|
+
const ranked = Object.entries(tally).sort((a, b) => b[1] - a[1]);
|
|
240
|
+
if (ranked.length === 0) throw new CouncilRefusal(COUNCIL_REFUSAL.TIED, 'no votes were cast, so there is no outcome to fix');
|
|
241
|
+
if (ranked.length > 1 && ranked[0][1] === ranked[1][1]) {
|
|
242
|
+
throw new CouncilRefusal(COUNCIL_REFUSAL.TIED, `a tie between ${ranked[0][0]} and ${ranked[1][0]} at weight ${ranked[0][1]}; a split council needs a person`, { tally });
|
|
243
|
+
}
|
|
244
|
+
const [outcome, weight] = ranked[0];
|
|
245
|
+
const dissent = ballots
|
|
246
|
+
.filter((b) => b.choice !== outcome)
|
|
247
|
+
.map((b) => ({
|
|
248
|
+
participant: b.participant,
|
|
249
|
+
role: b.role,
|
|
250
|
+
choice: b.choice,
|
|
251
|
+
weight: b.weight,
|
|
252
|
+
// The arguing text is carried alongside the ballot where the participant supplied one, because a
|
|
253
|
+
// dissent without its reason is a number rather than a position.
|
|
254
|
+
text: arguments_.filter((a) => a.participant === b.participant).map((a) => a.text).join(' ').trim() || null,
|
|
255
|
+
}));
|
|
256
|
+
decision = {
|
|
257
|
+
question,
|
|
258
|
+
opened_at: openedAt,
|
|
259
|
+
decided_at: new Date(now()).toISOString(),
|
|
260
|
+
phase_reached: 'decide',
|
|
261
|
+
outcome,
|
|
262
|
+
weight,
|
|
263
|
+
tally,
|
|
264
|
+
ballots: [...ballots],
|
|
265
|
+
arguments: [...arguments_],
|
|
266
|
+
dissent,
|
|
267
|
+
unanimous: dissent.length === 0,
|
|
268
|
+
};
|
|
269
|
+
return decision;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Seal the decision. After this the council refuses every mutation. */
|
|
273
|
+
function record() {
|
|
274
|
+
refuseIfSealed();
|
|
275
|
+
if (currentPhase() !== 'record') {
|
|
276
|
+
throw new CouncilRefusal(COUNCIL_REFUSAL.WRONG_PHASE, `the decision is sealed during "record"; the council is in "${currentPhase()}"`);
|
|
277
|
+
}
|
|
278
|
+
if (decision === null) {
|
|
279
|
+
throw new CouncilRefusal(COUNCIL_REFUSAL.WRONG_PHASE, 'there is no decision to seal; decide() must run before record()');
|
|
280
|
+
}
|
|
281
|
+
decision.phase_reached = 'record';
|
|
282
|
+
decision.seal = sealOf(decision);
|
|
283
|
+
decision.sealed_at = new Date(now()).toISOString();
|
|
284
|
+
isSealed = true;
|
|
285
|
+
return decision;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
get phase() { return currentPhase(); },
|
|
290
|
+
get phases() { return [...PHASES]; },
|
|
291
|
+
get members() { return members.map((m) => ({ ...m })); },
|
|
292
|
+
get votes() { return ballots.map((b) => ({ ...b })); },
|
|
293
|
+
get arguments() { return arguments_.map((a) => ({ ...a })); },
|
|
294
|
+
get decision() { return decision; },
|
|
295
|
+
assign,
|
|
296
|
+
argue,
|
|
297
|
+
vote,
|
|
298
|
+
decide,
|
|
299
|
+
record,
|
|
300
|
+
advance,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Recompute a stored decision's outcome from its stored inputs.
|
|
306
|
+
*
|
|
307
|
+
* This is what makes "the weights are reproducible" checkable instead of merely claimed: hand it a decision
|
|
308
|
+
* that was recorded earlier -- possibly on another machine -- and it either reproduces the outcome or says
|
|
309
|
+
* by how much it disagrees. It trusts nothing but the ballot fields.
|
|
310
|
+
*
|
|
311
|
+
* @param {object} decision
|
|
312
|
+
* @returns {{reproduced: boolean, outcome: string|null, weight: number|null, seal_matches: boolean, detail: string}}
|
|
313
|
+
*/
|
|
314
|
+
export function recount(decision) {
|
|
315
|
+
if (decision === null || typeof decision !== 'object') {
|
|
316
|
+
return { reproduced: false, outcome: null, weight: null, seal_matches: false, detail: 'no decision supplied' };
|
|
317
|
+
}
|
|
318
|
+
const tally = {};
|
|
319
|
+
for (const b of decision.ballots ?? []) {
|
|
320
|
+
let weight;
|
|
321
|
+
try {
|
|
322
|
+
weight = weightOf({ authority: b.authority, reputation: b.reputation });
|
|
323
|
+
} catch (e) {
|
|
324
|
+
return { reproduced: false, outcome: null, weight: null, seal_matches: false, detail: `ballot for ${b.participant} has inputs a weight cannot be computed from: ${e.message}` };
|
|
325
|
+
}
|
|
326
|
+
if (weight !== b.weight) {
|
|
327
|
+
return {
|
|
328
|
+
reproduced: false, outcome: null, weight: null, seal_matches: false,
|
|
329
|
+
detail: `ballot for ${b.participant} records weight ${b.weight} but its stored inputs give ${weight}`,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
tally[b.choice] = Math.round(((tally[b.choice] ?? 0) + weight) * 1e6) / 1e6;
|
|
333
|
+
}
|
|
334
|
+
const ranked = Object.entries(tally).sort((a, b) => b[1] - a[1]);
|
|
335
|
+
if (ranked.length === 0) return { reproduced: false, outcome: null, weight: null, seal_matches: false, detail: 'no ballots to recount' };
|
|
336
|
+
const sealMatches = decision.seal === undefined ? false : sealOf(decision) === decision.seal;
|
|
337
|
+
const same = ranked[0][0] === decision.outcome && ranked[0][1] === decision.weight;
|
|
338
|
+
return {
|
|
339
|
+
reproduced: same,
|
|
340
|
+
outcome: ranked[0][0],
|
|
341
|
+
weight: ranked[0][1],
|
|
342
|
+
seal_matches: sealMatches,
|
|
343
|
+
detail: same
|
|
344
|
+
? `outcome ${ranked[0][0]} at weight ${ranked[0][1]} reproduced from ${(decision.ballots ?? []).length} stored ballot(s)`
|
|
345
|
+
: `stored outcome was ${decision.outcome} at ${decision.weight}; the stored inputs give ${ranked[0][0]} at ${ranked[0][1]}`,
|
|
346
|
+
};
|
|
347
|
+
}
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -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.
|
|
188
|
+
const PLUGIN_VERSION = '0.11.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;
|