@twinsearth/w2m-dsh-plugin 0.27.0 → 0.29.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 +133 -0
- package/ROADMAP.md +12 -1
- package/package.json +6 -4
- package/src/agent/field-authority.mjs +202 -0
- package/src/plugin/tools.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,139 @@ 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.29.0] — 2026-10-15
|
|
8
|
+
|
|
9
|
+
**v4.8.0 was half shipped, and the ROADMAP said otherwise. The missing half is now built.**
|
|
10
|
+
|
|
11
|
+
### What the check found
|
|
12
|
+
|
|
13
|
+
`ROADMAP.md` described v4.8.0 as *"a CAT7 seven-field cognitive block, **per-field acceptance driven by §2's
|
|
14
|
+
authority model**, and lineage"* and marked it **shipped in 0.9.9**. Reading the tree instead of the sentence:
|
|
15
|
+
|
|
16
|
+
| Claim | Verdict |
|
|
17
|
+
|---|---|
|
|
18
|
+
| CAT7 seven-field block | **real**, asserted |
|
|
19
|
+
| lineage, with a missing parent refused rather than re-rooted | **real**, asserted |
|
|
20
|
+
| a refused field *absent* from the stored block rather than null | **real**, asserted |
|
|
21
|
+
| **"driven by §2's authority model"** | **never built** |
|
|
22
|
+
|
|
23
|
+
Measured: **`src/agent/blocks.mjs` did not import `authority.mjs` at all**, and `defaultChoose` — the only
|
|
24
|
+
chooser that shipped — refused `confidence` by name and accepted everything else. **A roadmap sentence is a
|
|
25
|
+
claim, and this one was checkable in a single grep.** It stood for several releases because nothing tested the
|
|
26
|
+
sentence itself.
|
|
27
|
+
|
|
28
|
+
### `src/agent/field-authority.mjs`
|
|
29
|
+
|
|
30
|
+
Per-field acceptance now goes through `authority.evaluate`, so the two models cannot drift into different
|
|
31
|
+
notions of what a rule is. Three refusals apply in a fixed order:
|
|
32
|
+
|
|
33
|
+
1. **not a CAT7 field** — before any rule, because a rule cannot make a field exist;
|
|
34
|
+
2. **no declared rule** — refused, never granted and never escalated;
|
|
35
|
+
3. **the authority verdict** — `allow` accepts, anything else refuses with the model's own reason.
|
|
36
|
+
|
|
37
|
+
**A field with no declared rule is refused**, which is the same decision `authority.mjs` already states for
|
|
38
|
+
exemptions and `permission.mjs` states for capabilities. Three modules, one rule, because the failure mode is
|
|
39
|
+
identical: **a table that grants what it has not heard of grants whatever is added next.**
|
|
40
|
+
|
|
41
|
+
### The three inherited fields are deliberately undeclared, and that is not dead policy
|
|
42
|
+
|
|
43
|
+
Measured: the chooser is asked about `subject`, `body` and `evidence` — **never** `origin`, `observed_at` or
|
|
44
|
+
`lineage`, because `acceptBlock` copies the inherited three directly. A rule for them would read as policy
|
|
45
|
+
while doing nothing, so they are left out and `acceptField` reports them `UNDECLARED`. A test asserts a rule
|
|
46
|
+
can never be added for them by accident.
|
|
47
|
+
|
|
48
|
+
`confidence` is refused **at every level including `AUTHORITY_MAX`**, because if it were merely expensive a
|
|
49
|
+
system administrator would be *permitted by this model* to adopt the sender's own certainty about the
|
|
50
|
+
sender's own assertion.
|
|
51
|
+
|
|
52
|
+
### Four of my own errors, and how each surfaced
|
|
53
|
+
|
|
54
|
+
* I invented `scope_claim` and `meaning` as field names. **Neither exists** — the real seven are `origin`,
|
|
55
|
+
`subject`, `body`, `evidence`, `confidence`, `observed_at`, `lineage`. My "undeclared field" test was
|
|
56
|
+
therefore testing `NOT_A_FIELD` and passing for the wrong reason; the fields that are genuinely
|
|
57
|
+
governed-but-undeclared are the three inherited ones.
|
|
58
|
+
* I asserted `accepted_fields` held only the receiver's two, when it also carries the three inherited ones.
|
|
59
|
+
* I asserted one chooser ruling per choosable field; there are **three, not four**, because `acceptBlock`
|
|
60
|
+
hard-codes the `confidence` refusal and `continue`s **before** calling the chooser.
|
|
61
|
+
* `observed_at: 'T'` is not an ISO timestamp and `makeBlock` refused it — **the block layer was right and my
|
|
62
|
+
probe was wrong**, which is the sixth time in this project that a failing test was the test's fault.
|
|
63
|
+
|
|
64
|
+
Every one of the four was found by **measuring the real behaviour** rather than by reading the source and
|
|
65
|
+
predicting it.
|
|
66
|
+
|
|
67
|
+
**17 tests, 0 failures.**
|
|
68
|
+
|
|
69
|
+
## [0.28.0] — 2026-10-15
|
|
70
|
+
|
|
71
|
+
**First real-machine verification of the recent work, and a tool that packs four answers into one exit code.**
|
|
72
|
+
|
|
73
|
+
### The measurement
|
|
74
|
+
|
|
75
|
+
The MacMini's checkout was updated to 0.27.0 and its own suites run there, with **the exit code produced by
|
|
76
|
+
that machine** as the evidence:
|
|
77
|
+
|
|
78
|
+
| Suite on MacMini | Result |
|
|
79
|
+
|---|---|
|
|
80
|
+
| `permission.test.mjs` | pass |
|
|
81
|
+
| `fleet-scale.test.mjs` | pass |
|
|
82
|
+
| `fleet-state.test.mjs` | pass |
|
|
83
|
+
| `crypto-blake2s.test.mjs` | pass |
|
|
84
|
+
|
|
85
|
+
Those four cover the code written in this span — the permission gate, the fleet arithmetic, the endpoint
|
|
86
|
+
reconciliation, and the crypto capability report. This is the goal's "verify on the real machine with exit
|
|
87
|
+
codes and artifacts" satisfied for that body of work, rather than inferred.
|
|
88
|
+
|
|
89
|
+
### `scripts/machine-test-packet.mjs`
|
|
90
|
+
|
|
91
|
+
A dispatch carries **one** exit code, so running four suites in one `node --test` answers only "did anything
|
|
92
|
+
fail". This packs **two bits per file** into a single byte: `10` passed, `01` failed, `00` absent, `11`
|
|
93
|
+
not measured. **`00` is distinguished from `01` on purpose** — "the file is not on this machine" is a
|
|
94
|
+
deployment finding, "the file ran and failed" is a code finding.
|
|
95
|
+
|
|
96
|
+
**Four dispatches were spent on this channel before the tool existed, two of them on my own misreadings:**
|
|
97
|
+
|
|
98
|
+
* a TAP parser matching `^ok ` that counted **nothing**, because Node indents its subtests. It returned
|
|
99
|
+
`pass 0 fail 0` and I nearly read that as "no tests ran".
|
|
100
|
+
* a summary regex that matched nothing, so the exit code described **my parser** rather than the code under
|
|
101
|
+
test.
|
|
102
|
+
|
|
103
|
+
Both were the same mistake in different clothes, and it is the mistake this project keeps making: **assuming
|
|
104
|
+
the shape of output instead of reading it.**
|
|
105
|
+
|
|
106
|
+
### Three defects in the tool itself, all caught by its own suite
|
|
107
|
+
|
|
108
|
+
* **The docstring claimed "zero is the all-clear", and that is false.** One passing file is `2`, four are
|
|
109
|
+
`170`. **Zero means every file was ABSENT** — a total deployment failure the old claim would have had a
|
|
110
|
+
reader take as a clean run. `allPassedCode()` now computes the value and the test asserts the arithmetic.
|
|
111
|
+
* **The guard against emitting `126`/`127` was never reached** by the inputs I guessed. Enumerating the whole
|
|
112
|
+
two-bit space found the only two combinations that land on a shell-owned value
|
|
113
|
+
(`[PASSED, WAITED, WAITED, FAILED]` and `[WAITED, WAITED, WAITED, FAILED]`).
|
|
114
|
+
* **The reassuring message was unreachable**, because it tested `code === 0` rather than the outcomes.
|
|
115
|
+
`packOutcomes([])` returns `0`, so the one code that reads as success was attached to the one input that
|
|
116
|
+
measured nothing.
|
|
117
|
+
|
|
118
|
+
### The budget, and the fifth time I hit the same ceiling
|
|
119
|
+
|
|
120
|
+
The first version took a 40-second timeout **per file** and ran four files: a 160-second worst case against
|
|
121
|
+
this fleet's **~50-second lease ceiling** (`heartbeatIntervalMs 10s × missedHeartbeats 2 + leaseGraceMs 30s`).
|
|
122
|
+
It **expired** on the real machine, and an expired lease carries **no exit code at all** — so the measurement
|
|
123
|
+
was lost rather than reported negative. That is the fifth time in this project that a dispatch was built
|
|
124
|
+
without respect for the ceiling.
|
|
125
|
+
|
|
126
|
+
The fix is a **total budget** (`DEFAULT_BUDGET_MS`, 30s) with a per-file cap, and files that miss their turn
|
|
127
|
+
are named `WAITED` rather than dropped — a packet that quietly omitted the last file would look like a clean
|
|
128
|
+
run of the ones it managed. A per-file timeout answers "is this file hung"; only a total budget answers "will
|
|
129
|
+
the answer survive the trip".
|
|
130
|
+
|
|
131
|
+
### What the real machine refused, which is a finding and not a bug
|
|
132
|
+
|
|
133
|
+
`node scripts/machine-test-packet.mjs ...` came back **`COMMAND_NOT_ALLOWED`**. The MacMini's allow-list
|
|
134
|
+
admits `node -e` and `node --test`; it does not admit an arbitrary script path, and the packet **spawns
|
|
135
|
+
`node --test` internally**, so it would be refused a second time. The tool is correct and tested; it needs an
|
|
136
|
+
allow-list entry to run there, and **that is a change nobody has made.**
|
|
137
|
+
|
|
138
|
+
**11 tests, 0 failures.**
|
|
139
|
+
|
|
7
140
|
## [0.27.0] — 2026-10-15
|
|
8
141
|
|
|
9
142
|
**A blocked fleet reports itself. The approval count is in `w2m_status`, and it arrives as a note.**
|
package/ROADMAP.md
CHANGED
|
@@ -60,7 +60,7 @@ that produced it.
|
|
|
60
60
|
the document does not grant it and inventing it would be the worst possible default;
|
|
61
61
|
- a rule with no `matching_exempt_level` exempts nobody, rather than exempting everybody.
|
|
62
62
|
|
|
63
|
-
### v4.8.0 — memory: field-level acceptance (MMP P1/P2) **shipped in 0.9.9
|
|
63
|
+
### v4.8.0 — memory: field-level acceptance (MMP P1/P2) **shipped in 0.9.9 (block + lineage); the authority half completed in 0.29.0**
|
|
64
64
|
|
|
65
65
|
**Deliverable.** Extend the shipped memory layer: a CAT7 seven-field cognitive block, per-field
|
|
66
66
|
acceptance driven by §2's authority model, and lineage — each accepted claim records the content hash of
|
|
@@ -70,6 +70,17 @@ its parent, so a returned claim is identifiable as the receiver's own earlier th
|
|
|
70
70
|
claim's ancestor chain survives a round trip; a claim whose parent is missing is refused rather than
|
|
71
71
|
silently re-rooted.
|
|
72
72
|
|
|
73
|
+
**Correction, 0.29.0.** This entry previously read *"shipped in 0.9.9, suite verified on MacMini"*, and that
|
|
74
|
+
was **half true**. The block, the lineage, the absent-not-null refusal and the missing-parent refusal all
|
|
75
|
+
shipped and are all asserted. But **"per-field acceptance driven by §2's authority model" was never built**:
|
|
76
|
+
measured, `src/agent/blocks.mjs` did not import `authority.mjs` at all, and the only chooser that shipped
|
|
77
|
+
refused `confidence` by name and accepted everything else. The connection now exists in
|
|
78
|
+
`src/agent/field-authority.mjs`, with the three inherited fields deliberately left undeclared because no
|
|
79
|
+
chooser is ever asked about them.
|
|
80
|
+
|
|
81
|
+
The lesson is recorded rather than tidied away: **a roadmap sentence is a claim, and this one was checkable
|
|
82
|
+
in one grep.** It stood for several releases because nothing tested the sentence itself.
|
|
83
|
+
|
|
73
84
|
### v4.9.0 — approval queue and scheduling
|
|
74
85
|
|
|
75
86
|
**Deliverable.** A cross-session pending-approval index (§4.2's "supervisor reviews every agent's pending
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.0",
|
|
4
4
|
"description": "DSH: Windows2MacOS — drive the same project on online Windows and macOS machines from one DeepSeek account, across networks and regions. P2P hole punching is the default path (relay + shared server as rendezvous, ledger and fallback), with a bundled STUN responder, Tailscale/public-VPS/tunnel shapes, Rabbit relay + per-machine Localside + eight DSH tools (replicate / split), nine aggregate verdicts and signed requests.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -113,13 +113,13 @@
|
|
|
113
113
|
}
|
|
114
114
|
},
|
|
115
115
|
"scripts": {
|
|
116
|
-
"test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/layer-model.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/chatops-fleet-join.test.mjs",
|
|
116
|
+
"test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/layer-model.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/chatops-fleet-join.test.mjs",
|
|
117
117
|
"test:e2e": "node --test --test-force-exit test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/p2p-agent.test.mjs",
|
|
118
118
|
"test:relay": "node --test --test-force-exit test/relay.test.mjs",
|
|
119
119
|
"test:agent": "node --test --test-force-exit test/agent.test.mjs test/memory.test.mjs",
|
|
120
120
|
"test:tools": "node --test --test-force-exit test/tools.test.mjs",
|
|
121
121
|
"test:crossnetwork": "node --test --test-force-exit test/crossnetwork.test.mjs",
|
|
122
|
-
"test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/layer-model.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-agent.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/p2p-crypto.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/chatops-fleet-join.test.mjs",
|
|
122
|
+
"test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/layer-model.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-agent.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/p2p-crypto.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/chatops-fleet-join.test.mjs",
|
|
123
123
|
"pack": "node scripts/pack.mjs",
|
|
124
124
|
"verify:join": "node scripts/verify-join.mjs",
|
|
125
125
|
"lint": "eslint .",
|
|
@@ -145,7 +145,9 @@
|
|
|
145
145
|
"test:fleet-state": "node --test --test-force-exit test/fleet-state.test.mjs",
|
|
146
146
|
"test:crypto-blake2s": "node --test --test-force-exit test/crypto-blake2s.test.mjs",
|
|
147
147
|
"test:installed-version": "node --test --test-force-exit test/installed-version.test.mjs",
|
|
148
|
-
"test:permission": "node --test --test-force-exit test/permission.test.mjs"
|
|
148
|
+
"test:permission": "node --test --test-force-exit test/permission.test.mjs",
|
|
149
|
+
"test:packet": "node --test --test-force-exit test/machine-test-packet.test.mjs",
|
|
150
|
+
"test:field-authority": "node --test --test-force-exit test/field-authority.test.mjs"
|
|
149
151
|
},
|
|
150
152
|
"keywords": [
|
|
151
153
|
"dsh-bundle",
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Field-level acceptance driven by the authority model — MMP P1/P2's missing half.
|
|
3
|
+
*
|
|
4
|
+
* ## What was missing, and how it was found
|
|
5
|
+
*
|
|
6
|
+
* `ROADMAP.md` describes v4.8.0 as *"a CAT7 seven-field cognitive block, **per-field acceptance driven by §2's
|
|
7
|
+
* authority model**, and lineage"*, and marks it **shipped in 0.9.9**. Reading the tree to check that claim:
|
|
8
|
+
* the block, the lineage, the absent-not-null refusal and the missing-parent refusal are all real and all
|
|
9
|
+
* asserted. But **`blocks.mjs` does not import `authority.mjs` at all**, and `defaultChoose` — the only
|
|
10
|
+
* chooser that ships — refuses `confidence` by name and accepts everything else.
|
|
11
|
+
*
|
|
12
|
+
* So "driven by the authority model" was a description of an intention, and the one field rule that existed
|
|
13
|
+
* was hard-coded. This module is the connection that was claimed and not built.
|
|
14
|
+
*
|
|
15
|
+
* ## The rule that makes it safe
|
|
16
|
+
*
|
|
17
|
+
* **A field with no declared rule is REFUSED.** Not accepted, and not escalated. This is the same decision
|
|
18
|
+
* `authority.mjs` already states for exemptions — *"the tempting default is 'absent means unconstrained', and
|
|
19
|
+
* it is wrong in the one direction that matters"* — and `permission.mjs` states for capabilities: an omission
|
|
20
|
+
* must never become a grant. Three modules, one rule, because the failure mode is identical in all three: a
|
|
21
|
+
* table that grants what it has not heard of grants whatever is added next.
|
|
22
|
+
*
|
|
23
|
+
* ## Ordering, which is the part that is easy to get silently wrong
|
|
24
|
+
*
|
|
25
|
+
* Three refusals can apply to one field, and they are checked in this order:
|
|
26
|
+
*
|
|
27
|
+
* 1. **not a CAT7 field** — refused before any rule is consulted, because a rule cannot make a field exist;
|
|
28
|
+
* 2. **no declared rule** — refused, because an undeclared field is one nobody decided about;
|
|
29
|
+
* 3. **the authority verdict** — `allow` accepts, anything else refuses with the model's own reason.
|
|
30
|
+
*
|
|
31
|
+
* Step 3 never *grants* on a non-allow verdict. A `needs_approval` on a field is a refusal here rather than a
|
|
32
|
+
* queue: an accepted block is a stored fact, and storing a field as though a supervisor had approved it when
|
|
33
|
+
* nobody has is worse than omitting it.
|
|
34
|
+
*/
|
|
35
|
+
import { CAT7_FIELDS } from './blocks.mjs';
|
|
36
|
+
import { AUTHORITY_VERDICT, evaluate } from './authority.mjs';
|
|
37
|
+
|
|
38
|
+
/** Why a field was not accepted, as codes rather than prose. */
|
|
39
|
+
export const FIELD_REFUSAL = Object.freeze({
|
|
40
|
+
NOT_A_FIELD: 'FIELD_NOT_A_FIELD',
|
|
41
|
+
UNDECLARED: 'FIELD_UNDECLARED',
|
|
42
|
+
BELOW_AUTHORITY: 'FIELD_BELOW_AUTHORITY',
|
|
43
|
+
MALFORMED_RULE: 'FIELD_MALFORMED_RULE',
|
|
44
|
+
ALWAYS_REFUSED: 'FIELD_ALWAYS_REFUSED',
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* What each field costs to accept.
|
|
49
|
+
*
|
|
50
|
+
* Only the four **receiver-choosable** fields can be governed this way, because `acceptBlock` copies the
|
|
51
|
+
* three inherited ones (`origin`, `observed_at`, `lineage`) without consulting a chooser at all — a receiver
|
|
52
|
+
* that could set them could assert a provenance it does not have. The two choosable fields that do not appear
|
|
53
|
+
* here are governed on purpose and the code says which reason applies to each.
|
|
54
|
+
*
|
|
55
|
+
* The levels follow `AUTHORITY_BANDS`: `ordinary` may say what was observed and what it means; `captain` may
|
|
56
|
+
* assert what supports it. A field is a claim about how far the assertion reaches, so its cost is its reach.
|
|
57
|
+
*/
|
|
58
|
+
export const FIELD_LEVELS = Object.freeze({
|
|
59
|
+
subject: 0,
|
|
60
|
+
body: 0,
|
|
61
|
+
evidence: 50,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Fields a receiver is refused whatever its authority, and why.
|
|
66
|
+
*
|
|
67
|
+
* `confidence` is **not** in {@link FIELD_LEVELS} at any level. A level can be raised, and if this field were
|
|
68
|
+
* merely expensive then a system administrator would be *permitted by this model* to adopt the sender's own
|
|
69
|
+
* certainty about the sender's own assertion — the one transfer of belief `blocks.mjs` calls a hard invariant
|
|
70
|
+
* rather than a default. This table is what makes that invariant reachable from here, so the refusal is
|
|
71
|
+
* reported with the same words `acceptBlock` uses instead of a second, drifting explanation.
|
|
72
|
+
*/
|
|
73
|
+
export const NEVER_ACCEPTED = Object.freeze({
|
|
74
|
+
confidence: 'a receiver does not adopt the sender\'s certainty (invariant, not a default)',
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The authority rule for one field, or `null` when the field has no declared rule.
|
|
79
|
+
*
|
|
80
|
+
* Returned in the shape `authority.evaluate` accepts, so the two models cannot drift into different notions
|
|
81
|
+
* of what a rule is.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} field
|
|
84
|
+
* @returns {{action: string, minLevel: number, matchingExemptLevel: null}|null}
|
|
85
|
+
*/
|
|
86
|
+
export function ruleForField(field) {
|
|
87
|
+
if (!Object.prototype.hasOwnProperty.call(FIELD_LEVELS, field)) return null;
|
|
88
|
+
return { action: `accept_field:${field}`, minLevel: FIELD_LEVELS[field], matchingExemptLevel: null };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Decide whether an actor may accept one field into a stored block.
|
|
93
|
+
*
|
|
94
|
+
* @param {string} field
|
|
95
|
+
* @param {{level: number, inScope?: boolean|null, id?: string|null}} actor
|
|
96
|
+
* @returns {{accepted: boolean, field: string|null, code: string, verdict: string|null,
|
|
97
|
+
* required: number|null, reason: string}}
|
|
98
|
+
*/
|
|
99
|
+
export function acceptField(field, actor) {
|
|
100
|
+
if (typeof field !== 'string' || !CAT7_FIELDS.includes(field)) {
|
|
101
|
+
// Before any rule: a rule cannot make a field exist, so consulting one here would let a typo in a rule
|
|
102
|
+
// table conjure a field that `verifyBlock` would then reject at the other end of the wire.
|
|
103
|
+
return {
|
|
104
|
+
accepted: false,
|
|
105
|
+
field: typeof field === 'string' ? field : null,
|
|
106
|
+
code: FIELD_REFUSAL.NOT_A_FIELD,
|
|
107
|
+
verdict: null,
|
|
108
|
+
required: null,
|
|
109
|
+
reason: `${JSON.stringify(field)} is not one of the seven CAT7 fields`,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (Object.prototype.hasOwnProperty.call(NEVER_ACCEPTED, field)) {
|
|
114
|
+
// Checked before the table, so raising a level cannot reach it. The message says it is an invariant, not a
|
|
115
|
+
// threshold, because a reader who thinks it is a threshold will try to raise it.
|
|
116
|
+
return {
|
|
117
|
+
accepted: false,
|
|
118
|
+
field,
|
|
119
|
+
code: FIELD_REFUSAL.ALWAYS_REFUSED,
|
|
120
|
+
verdict: AUTHORITY_VERDICT.DENY,
|
|
121
|
+
required: null,
|
|
122
|
+
reason: NEVER_ACCEPTED[field],
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const rule = ruleForField(field);
|
|
127
|
+
if (rule === null) {
|
|
128
|
+
// The load-bearing refusal. Accepting an undeclared field would make this table grant whatever is added
|
|
129
|
+
// next; escalating it would make it requestable. Both turn an omission into a permission.
|
|
130
|
+
return {
|
|
131
|
+
accepted: false,
|
|
132
|
+
field,
|
|
133
|
+
code: FIELD_REFUSAL.UNDECLARED,
|
|
134
|
+
verdict: null,
|
|
135
|
+
required: null,
|
|
136
|
+
reason: `no authority rule declares what ${field} costs, so nothing may accept it; a field the table has not heard of is refused rather than granted`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const decision = evaluate(rule, actor);
|
|
141
|
+
if (decision.verdict === AUTHORITY_VERDICT.ALLOW) {
|
|
142
|
+
return {
|
|
143
|
+
accepted: true,
|
|
144
|
+
field,
|
|
145
|
+
code: AUTHORITY_VERDICT.ALLOW,
|
|
146
|
+
verdict: decision.verdict,
|
|
147
|
+
required: rule.minLevel,
|
|
148
|
+
reason: decision.reason,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
accepted: false,
|
|
153
|
+
field,
|
|
154
|
+
code: decision.reason.startsWith('malformed rule:') ? FIELD_REFUSAL.MALFORMED_RULE : FIELD_REFUSAL.BELOW_AUTHORITY,
|
|
155
|
+
verdict: decision.verdict,
|
|
156
|
+
required: rule.minLevel,
|
|
157
|
+
reason: decision.reason,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* A `choose` function for `acceptBlock`, built from the authority model.
|
|
163
|
+
*
|
|
164
|
+
* Shaped to drop straight into `acceptBlock(block, { choose })`, which is the only reason it exists as a
|
|
165
|
+
* separate function rather than being inlined: the block layer must not need to know about authority, and the
|
|
166
|
+
* authority layer must not need to know about blocks.
|
|
167
|
+
*
|
|
168
|
+
* @param {{level: number, inScope?: boolean|null, id?: string|null}} actor
|
|
169
|
+
* @param {{onDecision?: (result: object) => void}} [options] `onDecision` sees every field's ruling, so a
|
|
170
|
+
* caller can record refusals without re-deriving them.
|
|
171
|
+
* @returns {(field: string, value: unknown, block: object) => {accept: boolean, why: string}}
|
|
172
|
+
*/
|
|
173
|
+
export function authorityChooser(actor, options = {}) {
|
|
174
|
+
const onDecision = typeof options.onDecision === 'function' ? options.onDecision : null;
|
|
175
|
+
return (field) => {
|
|
176
|
+
const result = acceptField(field, actor);
|
|
177
|
+
if (onDecision !== null) onDecision(result);
|
|
178
|
+
return { accept: result.accepted, why: result.reason };
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Accept a whole block with the authority chooser, and report both halves.
|
|
184
|
+
*
|
|
185
|
+
* @param {object} block
|
|
186
|
+
* @param {{level: number, inScope?: boolean|null, id?: string|null}} actor
|
|
187
|
+
* @param {{lineage?: string[]|null, accept?: Function}} [options] `accept` is `acceptBlock`, injected so this
|
|
188
|
+
* module does not import it in a cycle.
|
|
189
|
+
* @returns {{accepted: object|null, refused: Array<object>, decisions: Array<object>}}
|
|
190
|
+
*/
|
|
191
|
+
export function acceptWithAuthority(block, actor, options = {}) {
|
|
192
|
+
const accept = options.accept;
|
|
193
|
+
if (typeof accept !== 'function') {
|
|
194
|
+
return { accepted: null, refused: [{ field: null, why: 'no acceptBlock implementation was supplied' }], decisions: [] };
|
|
195
|
+
}
|
|
196
|
+
const decisions = [];
|
|
197
|
+
const result = accept(block, {
|
|
198
|
+
choose: authorityChooser(actor, { onDecision: (d) => decisions.push(d) }),
|
|
199
|
+
...(Object.prototype.hasOwnProperty.call(options, 'lineage') ? { lineage: options.lineage } : {}),
|
|
200
|
+
});
|
|
201
|
+
return { accepted: result.accepted, refused: result.refused, decisions };
|
|
202
|
+
}
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -193,7 +193,7 @@ const MAX_STAGES = 16;
|
|
|
193
193
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
194
194
|
* overrides it at runtime for tests and packagers.
|
|
195
195
|
*/
|
|
196
|
-
const PLUGIN_VERSION = '0.
|
|
196
|
+
const PLUGIN_VERSION = '0.29.0';
|
|
197
197
|
|
|
198
198
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
199
199
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|