@twinsearth/w2m-dsh-plugin 0.9.8 → 0.10.2
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 +203 -0
- package/ROADMAP.md +2 -2
- package/package.json +9 -4
- package/src/agent/approval-index.mjs +300 -0
- package/src/agent/blocks.mjs +426 -0
- package/src/agent/health.mjs +330 -0
- package/src/agent/storage.mjs +380 -0
- package/src/plugin/tools.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,209 @@ 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.10.2] — 2026-10-15
|
|
8
|
+
|
|
9
|
+
**Tiered storage — temporary, compressed, permanent, and the two kinds of knowledge that persist forever.**
|
|
10
|
+
|
|
11
|
+
### Why tiers rather than one directory
|
|
12
|
+
|
|
13
|
+
The Linux host reached 100% filesystem on 2026-10-14 from a single uncapped writer at 14.7 GB/day, and the
|
|
14
|
+
recovery cost real time. The lesson was not "delete more": it was that **data with different lifetimes was
|
|
15
|
+
stored in one place under one policy**, so the only available policy was "never delete" until it became
|
|
16
|
+
"delete under pressure" — which is the worst possible moment to be deciding what matters.
|
|
17
|
+
|
|
18
|
+
So a file's tier is written down when it is stored, and the tier alone decides what may happen to it:
|
|
19
|
+
|
|
20
|
+
| Tier | Lifetime | May it be deleted? |
|
|
21
|
+
|---|---|---|
|
|
22
|
+
| `temp` | a stated TTL | yes, automatically, once expired |
|
|
23
|
+
| `compressed` | until the archive is unwanted | only whole, never partially |
|
|
24
|
+
| `permanent` | forever | **no — refused, not warned about** |
|
|
25
|
+
| `skill` | forever | **no** |
|
|
26
|
+
| `law` | forever | **no — amended instead** |
|
|
27
|
+
|
|
28
|
+
### Why `permanent` refuses rather than warns
|
|
29
|
+
|
|
30
|
+
A warning is a thing a caller ignores, and the caller under pressure is exactly who this exists for.
|
|
31
|
+
`memory.jsonl` is the **only** durable copy of what a command printed — the relay keeps `stdout_sha256` and
|
|
32
|
+
never the text — so deleting it deletes the fleet's ability to answer what it already tried, and nothing
|
|
33
|
+
else in the system notices. `device.key` is the identity peers know a machine by; losing it makes every
|
|
34
|
+
peer holding the old one refuse that machine with no visible cause. A forced removal remains possible,
|
|
35
|
+
because a rule with no escape hatch gets bypassed rather than followed.
|
|
36
|
+
|
|
37
|
+
`law` entries carry an **amendment chain** naming the hash each amendment supersedes, because "what does
|
|
38
|
+
the rule say now" and "what did it used to say" are both questions a reader has, and only the second
|
|
39
|
+
explains why something was done the old way.
|
|
40
|
+
|
|
41
|
+
### A test that asserted nothing, found while writing it
|
|
42
|
+
|
|
43
|
+
The compressed-removal case ended with `existsSync(join(..., 'nope'))` — a path that never exists, so the
|
|
44
|
+
assertion could never be false. It now checks the real property: removal leaves no orphan on disk, because
|
|
45
|
+
an index that forgets a file still on disk is how a store grows without anything being able to name what
|
|
46
|
+
is in it.
|
|
47
|
+
|
|
48
|
+
**24 tests, 0 failures.**
|
|
49
|
+
|
|
50
|
+
## [0.10.1] — 2026-10-15
|
|
51
|
+
|
|
52
|
+
**A service-health agent: measure, classify, recommend — never act.**
|
|
53
|
+
|
|
54
|
+
### Why it only advises
|
|
55
|
+
|
|
56
|
+
A component that both decides a server is unhealthy *and* changes that server is one bug away from
|
|
57
|
+
deleting the thing it was protecting. The 2026-10-14 disk incident is the concrete example: the recovery
|
|
58
|
+
was `truncate`, **not** `rm`, because rsyslog still held the file descriptor open, so removing the file
|
|
59
|
+
would have freed nothing while appearing to succeed. A cleaner that had decided on its own to delete "the
|
|
60
|
+
big log file" would have looked successful and changed nothing.
|
|
61
|
+
|
|
62
|
+
### Where the thresholds come from — measurement, not taste
|
|
63
|
+
|
|
64
|
+
- **Disk** — critical at 90% used *or* under 1 GB free; warn at 80%. The host reached 100% with 268 MB
|
|
65
|
+
free, which is the state this is for. The absolute floor matters independently: a huge filesystem can be
|
|
66
|
+
"only 85% used" and still have less headroom than a task needs.
|
|
67
|
+
- **Log growth** — 14.7 GB/day is the measured rate that caused the incident. Any single file over 1 GB is
|
|
68
|
+
reported **by name**, because "the disk is filling" is not actionable and "this file is filling it" is.
|
|
69
|
+
- **Load** — compared against CPU count, never an absolute number, because the consequence worth naming is
|
|
70
|
+
that a loaded machine starts expiring tasks at the ~50 s lease ceiling.
|
|
71
|
+
- **Memory** — set where a host starts swapping, not where it looks comfortable.
|
|
72
|
+
|
|
73
|
+
### Two defects the suite found in its own work
|
|
74
|
+
|
|
75
|
+
- The critical load multiple was `loadPerCpuWarn * 2`, which **reads as a rule and is a coincidence** —
|
|
76
|
+
equal to 3.0 only while the warning threshold is 1.5, so changing one would silently move the other. Its
|
|
77
|
+
own test asserted the documented 1.5 / 3.0 pair and failed. It is now a named constant.
|
|
78
|
+
- A test asserted `critical` for 4.0 load over 2 CPUs. 4 / 2 is 2.0 per CPU, between warn and critical.
|
|
79
|
+
**The implementation was right and the assertion was not.**
|
|
80
|
+
|
|
81
|
+
### Permanent storage is a classification, not a copy
|
|
82
|
+
|
|
83
|
+
`DATA_CLASSES` marks `memory.jsonl`, `device.json`, `device.key` and `fleet-memory.md` as
|
|
84
|
+
`IRREPLACEABLE`. It copies nothing: deciding where data may live is a policy this module has no standing
|
|
85
|
+
to make, and the honest half is saying which data cannot be regenerated.
|
|
86
|
+
|
|
87
|
+
### A limitation measured rather than assumed
|
|
88
|
+
|
|
89
|
+
`df` exists on Linux and macOS and not on Windows, so a Windows sample carries `disks: []`. The important
|
|
90
|
+
part is that it is **empty** rather than filled with a healthy-looking default — an unmeasured disk must
|
|
91
|
+
never read as free space. A test asserts the empty case on whichever host runs the suite.
|
|
92
|
+
|
|
93
|
+
**20 tests, 0 failures.**
|
|
94
|
+
|
|
95
|
+
## [0.10.0] — 2026-10-15
|
|
96
|
+
|
|
97
|
+
**The cross-session approval index, and a syncer that refuses to wedge a repository.**
|
|
98
|
+
|
|
99
|
+
### The approval index (roadmap v4.9.0)
|
|
100
|
+
|
|
101
|
+
`approval-broker.mjs` answers approvals **per session**: a request belongs to one IM window and the
|
|
102
|
+
operator answers it there. That is right for the person in that conversation and wrong for a supervisor,
|
|
103
|
+
who by definition is not in it — the design's own words are that a supervisor reviews all agents' pending
|
|
104
|
+
approvals *from one queue*. Without this, a request raised in a session nobody is watching sits until its
|
|
105
|
+
deadline delegates it.
|
|
106
|
+
|
|
107
|
+
The index presents pending work across sessions, filtered by what the viewer may act on, and verifies
|
|
108
|
+
authority at the moment of decision. It decides nothing itself: `decide()` returns a verdict and the caller
|
|
109
|
+
settles the broker, because the broker already owns settlement and a second settler would be a second owner
|
|
110
|
+
of the same promise.
|
|
111
|
+
|
|
112
|
+
Two choices worth stating. The subject's authority level is recorded **at raise time**, because it is a
|
|
113
|
+
fact about who asked and must not be re-derived from a caller who might be the approver — an index that let
|
|
114
|
+
the approver supply it would let the approver lower it. And `listFor` returns work the viewer **cannot**
|
|
115
|
+
answer, with the reason, rather than filtering it out: a filtered list makes the fleet look idle at the
|
|
116
|
+
moment it is most blocked.
|
|
117
|
+
|
|
118
|
+
### `scripts/fleet-sync.mjs` — broadcast, sync, merge, with a decision
|
|
119
|
+
|
|
120
|
+
Three defects were found by running it, two of them dangerous in production:
|
|
121
|
+
|
|
122
|
+
- **A failed rebase wedged the repository.** No committer identity meant `git rebase` stopped part-way,
|
|
123
|
+
leaving a detached HEAD and `.git/rebase-merge` for a person to resolve. An automatic tool that can
|
|
124
|
+
wedge a repository is worse than one that does nothing. The identity is now checked before starting, and
|
|
125
|
+
a failure **aborts** the rebase to restore the exact previous state.
|
|
126
|
+
- **A failed `rev-parse` was read as a ref name.** `git rev-parse <unknown>` **echoes the argument** to
|
|
127
|
+
stdout and then exits non-zero, so a failed lookup produced `{ok:false, out:'origin/main'}` — a plausible
|
|
128
|
+
ref name attached to a status saying it does not exist. `inspect` read `out` alone and reported a
|
|
129
|
+
repository with **no remote at all** as `ahead` with `ahead/behind: NaN/0`. Every ref lookup now uses
|
|
130
|
+
`--verify`, and an unparseable `rev-list` is `unreadable`/stop rather than falling through to a default.
|
|
131
|
+
- **The published/unpublished safety test was dead code.** `published = local_only < ahead` can never be
|
|
132
|
+
true: `ahead` counts the commits absent from the remote-tracking branch and `--not --remotes` selects
|
|
133
|
+
exactly those commits. The real question is narrower — would this rebase rewrite a commit some remote ref
|
|
134
|
+
already reaches — and it is now answered by testing each local commit for reachability from the remotes.
|
|
135
|
+
|
|
136
|
+
A finding that changed a test rather than the code: **on a single upstream, a "both sides published"
|
|
137
|
+
divergence cannot exist.** If a local commit is reachable from `origin/main` then HEAD is its ancestor, so
|
|
138
|
+
`behind` is 0 and there is no divergence. Three fixtures failed before that became clear. The reachable
|
|
139
|
+
version of the hazard is a local commit some *other* remote ref already has — a branch pushed as a feature
|
|
140
|
+
and then rewritten — and that is what the test now builds.
|
|
141
|
+
|
|
142
|
+
**30 tests across the two, 0 failures**, against real repositories and a real bare remote: a mocked git
|
|
143
|
+
would have been a test of the mock.
|
|
144
|
+
|
|
145
|
+
## [0.9.9] — 2026-10-15
|
|
146
|
+
|
|
147
|
+
**Cognitive memory blocks (roadmap v4.8.0): field-level acceptance and lineage — and the first release whose suite was run on the fleet machine rather than on the development host.**
|
|
148
|
+
|
|
149
|
+
### `src/agent/blocks.mjs`
|
|
150
|
+
|
|
151
|
+
The memory layer shipped in 0.9.6/0.9.7 accepts a peer's claim **whole or not at all**, which is a blunt
|
|
152
|
+
instrument with two costs the roadmap records as P1 and P2. A block is now seven fixed fields so the
|
|
153
|
+
decision can be made per field:
|
|
154
|
+
|
|
155
|
+
| Field | May a receiver choose it? |
|
|
156
|
+
|---|---|
|
|
157
|
+
| `subject`, `body`, `evidence` | yes |
|
|
158
|
+
| `confidence` | **no — refused whatever the chooser says** |
|
|
159
|
+
| `origin`, `observed_at`, `lineage` | **no — inherited, and setting them would be forging provenance** |
|
|
160
|
+
|
|
161
|
+
Lineage is by **content hash**, not by id, because an id is assigned by whoever creates the block and can
|
|
162
|
+
be copied. A block whose parent cannot be produced is **refused rather than re-rooted**: re-rooting turns
|
|
163
|
+
an unverifiable history into no history, which reads as a fresh, independent assertion, and that is how a
|
|
164
|
+
fabricated claim acquires a respectable past.
|
|
165
|
+
|
|
166
|
+
### Three defects the suite found in its own module
|
|
167
|
+
|
|
168
|
+
- **`confidence` was only a default, not an invariant.** Supplying a custom `choose` *replaced* the
|
|
169
|
+
default rather than refining it, so the file's central conservative property — a receiver does not adopt
|
|
170
|
+
the sender's certainty about the sender's own assertion — was silently optional. "Silently optional" is
|
|
171
|
+
how a safety default stops being one, so the refusal is now a hard invariant that no chooser can open.
|
|
172
|
+
- **A cycle fixture that asserted nothing.** The first cycle test mutated `lineage` after hashing and keyed
|
|
173
|
+
the map by the recomputed digest, leaving the references pointing at hashes the map did not hold — the
|
|
174
|
+
walker reported `missing` and never met the cycle it was supposed to test. `linkFixtures` now makes the
|
|
175
|
+
reference and the key the same string by construction.
|
|
176
|
+
- **A test that measured the clock.** `makeBlock` stamps the current time when no `observed_at` is given
|
|
177
|
+
(correctly — that is what the field means), so two blocks built a millisecond apart differ. The failure
|
|
178
|
+
looked like a hashing defect and was a 1 ms difference in a field the test never mentioned.
|
|
179
|
+
|
|
180
|
+
### Verified on MacMini, not only here
|
|
181
|
+
|
|
182
|
+
This is the first release whose suite was executed on the fleet machine:
|
|
183
|
+
|
|
184
|
+
```
|
|
185
|
+
MacMini (MacMinideMac-mini.local)
|
|
186
|
+
HEAD : 642242446d3eb0ed1928f128d088b727284a6d43
|
|
187
|
+
expected : 642242446d3eb0ed1928f128d088b727284a6d43 MATCH
|
|
188
|
+
suiteOk : true
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
`test/blocks.test.mjs` — **28 tests, 0 failures**, adversarial by design: most cases assert a refusal
|
|
192
|
+
rather than a happy path, because what makes a fleet memory worse than no memory is losing the ability to
|
|
193
|
+
tell a fact from an echo. `eslint` 0 errors.
|
|
194
|
+
|
|
195
|
+
### Notes on how this got here, because the tooling cost more than the module
|
|
196
|
+
|
|
197
|
+
`run-relay.mjs` targets `root@<relay>` and runs **bash on the relay host**; `relay-run.mjs` runs a
|
|
198
|
+
**JavaScript file on a fleet machine**. Both are called "relay" and only one of them is the fleet — a
|
|
199
|
+
clone believed to be on MacMini was on the relay for a round. Also: `Set-Content -Encoding utf8` writes a
|
|
200
|
+
**BOM**, and a shebang behind a BOM is not a shebang. And a 43 KB script cannot travel as an argv element
|
|
201
|
+
under Windows' 32 KB command-line limit, which is why this work reaches MacMini as a **branch** it fetches
|
|
202
|
+
rather than as an inline command.
|
|
203
|
+
|
|
204
|
+
### Environment, measured
|
|
205
|
+
|
|
206
|
+
MacMini has `git` 2.54.0, `curl`, `python3`, 70 GiB free, and **working** HTTPS to npm, the GitHub API,
|
|
207
|
+
release assets and codeload — including `git ls-remote`, which an earlier session recorded as failing.
|
|
208
|
+
It has no `npm`, `pnpm` or `brew`, so `node --test` directly is the suite runner.
|
|
209
|
+
|
|
7
210
|
## [0.9.8] — 2026-10-14
|
|
8
211
|
|
|
9
212
|
**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.**
|
package/ROADMAP.md
CHANGED
|
@@ -46,7 +46,7 @@ it into P1. So this is not a foundation poured in advance of a use — the use i
|
|
|
46
46
|
Each row states its **deliverable** and its **falsifying test** — the observation that would show it is
|
|
47
47
|
not working. A row without the second is a feature list, not a plan.
|
|
48
48
|
|
|
49
|
-
### v4.7.0 — authority model
|
|
49
|
+
### v4.7.0 — authority model **shipped in 0.9.8**
|
|
50
50
|
|
|
51
51
|
**Deliverable.** `src/agent/authority.mjs`: an integer `authority_level` on every actor, the four bands
|
|
52
52
|
(0–49 ordinary, 50–69 captain, 70–89 org admin, 90+ system admin), a rule evaluator carrying
|
|
@@ -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)
|
|
63
|
+
### v4.8.0 — memory: field-level acceptance (MMP P1/P2) **shipped in 0.9.9, suite verified on MacMini**
|
|
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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.2",
|
|
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",
|
|
@@ -112,13 +112,13 @@
|
|
|
112
112
|
}
|
|
113
113
|
},
|
|
114
114
|
"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/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/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.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
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",
|
|
117
117
|
"test:relay": "node --test --test-force-exit test/relay.test.mjs",
|
|
118
118
|
"test:agent": "node --test --test-force-exit test/agent.test.mjs test/memory.test.mjs",
|
|
119
119
|
"test:tools": "node --test --test-force-exit test/tools.test.mjs",
|
|
120
120
|
"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/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/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.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
122
|
"pack": "node scripts/pack.mjs",
|
|
123
123
|
"verify:join": "node scripts/verify-join.mjs",
|
|
124
124
|
"lint": "eslint .",
|
|
@@ -128,7 +128,12 @@
|
|
|
128
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",
|
|
129
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",
|
|
130
130
|
"test:memory": "node --test --test-force-exit test/memory.test.mjs",
|
|
131
|
-
"test:authority": "node --test --test-force-exit test/authority.test.mjs"
|
|
131
|
+
"test:authority": "node --test --test-force-exit test/authority.test.mjs",
|
|
132
|
+
"test:blocks": "node --test --test-force-exit test/blocks.test.mjs",
|
|
133
|
+
"test:approval-index": "node --test --test-force-exit test/approval-index.test.mjs",
|
|
134
|
+
"test:fleet-sync": "node --test --test-force-exit test/fleet-sync.test.mjs",
|
|
135
|
+
"test:health": "node --test --test-force-exit test/health.test.mjs",
|
|
136
|
+
"test:storage": "node --test --test-force-exit test/storage.test.mjs"
|
|
132
137
|
},
|
|
133
138
|
"keywords": [
|
|
134
139
|
"dsh-bundle",
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cross-session pending-approval index (roadmap v4.9.0).
|
|
3
|
+
*
|
|
4
|
+
* ## The problem
|
|
5
|
+
*
|
|
6
|
+
* `approval-broker.mjs` answers approvals **per session**: a request belongs to one IM window, and the
|
|
7
|
+
* operator answers it there. That is right for the person who is *in* that conversation and wrong for a
|
|
8
|
+
* supervisor, who by definition is not — the design's own words are that a supervisor reviews all agents'
|
|
9
|
+
* pending approvals *from one queue*. Without this file, a request raised in a session nobody is watching
|
|
10
|
+
* sits until its deadline delegates it, and the fact that it was waiting is recorded only inside the
|
|
11
|
+
* session that could not answer it.
|
|
12
|
+
*
|
|
13
|
+
* ## What the index does and, more importantly, does not do
|
|
14
|
+
*
|
|
15
|
+
* It **presents** pending work across sessions, filtered by what the viewer has the authority to act on,
|
|
16
|
+
* and it **verifies** authority at the moment of decision. It does not decide anything itself: `decide()`
|
|
17
|
+
* returns a verdict and the caller settles the broker. That split exists because the broker already owns
|
|
18
|
+
* settlement — the timer, the abort listener and the delegate all settle one record — and a second settler
|
|
19
|
+
* would be a second owner of the same promise.
|
|
20
|
+
*
|
|
21
|
+
* ## Why the filter is authority-based rather than a flag
|
|
22
|
+
*
|
|
23
|
+
* Every actor above the ordinary band can in principle approve *something*; what differs is what. So
|
|
24
|
+
* `listFor` answers "what may this actor act on" rather than "is this actor a supervisor", and the answer
|
|
25
|
+
* is per request. An index that showed everything to everyone above a threshold would make the threshold
|
|
26
|
+
* the whole policy, and the authority model would be decoration.
|
|
27
|
+
*
|
|
28
|
+
* ## The three refusals, each for a different reason
|
|
29
|
+
*
|
|
30
|
+
* * **Self-approval** is refused: an actor that can approve its own request has no supervisor.
|
|
31
|
+
* * **Peer approval** is refused: two captains approving each other is a ring, not a chain, and the
|
|
32
|
+
* chain's whole purpose is that a decision can be escalated past the level that wanted it.
|
|
33
|
+
* * **A vanished request** is refused rather than ignored, because "already answered" and "never
|
|
34
|
+
* existed" look identical from a stale queue row and only one means someone should fix their view.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { AUTHORITY_VERDICT, isAuthorityLevel, mayApprove, requiredApproverLevel } from './authority.mjs';
|
|
38
|
+
|
|
39
|
+
/** Why the index refused to let an actor act. Codes, so a caller can branch without matching prose. */
|
|
40
|
+
export const INDEX_REFUSAL = Object.freeze({
|
|
41
|
+
UNKNOWN_REQUEST: 'APPROVAL_UNKNOWN_REQUEST',
|
|
42
|
+
ALREADY_SETTLED: 'APPROVAL_ALREADY_SETTLED',
|
|
43
|
+
SELF_APPROVAL: 'APPROVAL_SELF_APPROVAL',
|
|
44
|
+
INSUFFICIENT_AUTHORITY: 'APPROVAL_INSUFFICIENT_AUTHORITY',
|
|
45
|
+
NO_SUBJECT_LEVEL: 'APPROVAL_NO_SUBJECT_LEVEL',
|
|
46
|
+
BAD_ACTOR: 'APPROVAL_BAD_ACTOR',
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* How long a settled request stays visible to a supervisor.
|
|
51
|
+
*
|
|
52
|
+
* A queue that empties the instant a decision is made cannot answer "did I already deal with that", which
|
|
53
|
+
* is the question a supervisor returning to a busy queue actually has. Kept short deliberately: this is a
|
|
54
|
+
* receipt, not a history, and the audit trail is the place for history.
|
|
55
|
+
*/
|
|
56
|
+
export const SETTLED_RETENTION_MS = 120_000;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Create an index over one or more sessions' pending approvals.
|
|
60
|
+
*
|
|
61
|
+
* @param {object} [options]
|
|
62
|
+
* @param {() => number} [options.now] Injected clock, so a test can advance time without waiting.
|
|
63
|
+
* @param {(event: string, detail?: object) => void} [options.audit] Called on every state change.
|
|
64
|
+
* @param {number} [options.settledRetentionMs]
|
|
65
|
+
*/
|
|
66
|
+
export function createApprovalIndex(options = {}) {
|
|
67
|
+
const now = options.now ?? (() => Date.now());
|
|
68
|
+
const audit = options.audit ?? (() => {});
|
|
69
|
+
const settledRetentionMs = options.settledRetentionMs ?? SETTLED_RETENTION_MS;
|
|
70
|
+
|
|
71
|
+
/** approvalId -> entry. The entry is the index's own record; the broker owns settlement. */
|
|
72
|
+
const entries = new Map();
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Register a pending approval.
|
|
76
|
+
*
|
|
77
|
+
* Called by whatever raised it — the bridge, in practice — so that the request becomes visible outside
|
|
78
|
+
* the session that owns it. The subject's authority level is recorded **here**, at raise time, because
|
|
79
|
+
* it is a fact about who asked and must not be re-derived later from a caller who might be the approver.
|
|
80
|
+
* An index that let the approver supply the subject's level would let the approver lower it.
|
|
81
|
+
*
|
|
82
|
+
* @param {{approvalId: string, sessionKey?: string|null, subject: {id?: string|null, level: number},
|
|
83
|
+
* action: string, toolName?: string|null, reason?: string|null, openedAt?: number,
|
|
84
|
+
* deadlineAt?: number|null}} entry
|
|
85
|
+
* @returns {{ok: boolean, reason_code?: string, detail?: string}}
|
|
86
|
+
*/
|
|
87
|
+
function register(entry) {
|
|
88
|
+
const approvalId = entry?.approvalId;
|
|
89
|
+
if (typeof approvalId !== 'string' || approvalId === '') {
|
|
90
|
+
return { ok: false, reason_code: INDEX_REFUSAL.UNKNOWN_REQUEST, detail: 'register needs an approvalId' };
|
|
91
|
+
}
|
|
92
|
+
if (!isAuthorityLevel(entry?.subject?.level)) {
|
|
93
|
+
// Refused rather than defaulted. A request whose subject has no level cannot be placed relative to
|
|
94
|
+
// any approver, and guessing a level here would be inventing the one fact the decision rests on.
|
|
95
|
+
return { ok: false, reason_code: INDEX_REFUSAL.NO_SUBJECT_LEVEL, detail: `subject level must be an integer 0..100, got ${JSON.stringify(entry?.subject?.level ?? null)}` };
|
|
96
|
+
}
|
|
97
|
+
if (typeof entry.action !== 'string' || entry.action.trim() === '') {
|
|
98
|
+
return { ok: false, reason_code: INDEX_REFUSAL.UNKNOWN_REQUEST, detail: 'register needs a non-empty action' };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const at = Number.isFinite(entry.openedAt) ? entry.openedAt : now();
|
|
102
|
+
const record = {
|
|
103
|
+
approvalId,
|
|
104
|
+
sessionKey: entry.sessionKey ?? null,
|
|
105
|
+
subjectId: entry.subject.id ?? null,
|
|
106
|
+
subjectLevel: entry.subject.level,
|
|
107
|
+
action: entry.action,
|
|
108
|
+
toolName: entry.toolName ?? null,
|
|
109
|
+
reason: entry.reason ?? null,
|
|
110
|
+
openedAt: at,
|
|
111
|
+
deadlineAt: Number.isFinite(entry.deadlineAt) ? entry.deadlineAt : null,
|
|
112
|
+
settled: null,
|
|
113
|
+
settledAt: null,
|
|
114
|
+
};
|
|
115
|
+
entries.set(approvalId, record);
|
|
116
|
+
audit('approval-index/registered', { approval_id: approvalId, session: record.sessionKey, subject_level: record.subjectLevel, action: record.action });
|
|
117
|
+
return { ok: true };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Retention is applied on read, so no timer is needed and a paused process cannot accumulate work. */
|
|
121
|
+
function reap() {
|
|
122
|
+
const cutoff = now() - settledRetentionMs;
|
|
123
|
+
for (const [id, record] of entries) {
|
|
124
|
+
if (record.settled !== null && record.settledAt !== null && record.settledAt < cutoff) entries.delete(id);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Can this actor act on this request? A pure question: it settles nothing.
|
|
130
|
+
*
|
|
131
|
+
* Split from `decide` so a caller can render a queue with reasons and only then act, without every
|
|
132
|
+
* render having a side effect.
|
|
133
|
+
*
|
|
134
|
+
* @param {object} record
|
|
135
|
+
* @param {{id?: string|null, level: number}} actor
|
|
136
|
+
* @returns {{allowed: boolean, reason_code: string|null, detail: string|null, required: number|null}}
|
|
137
|
+
*/
|
|
138
|
+
function mayAct(record, actor) {
|
|
139
|
+
if (!isAuthorityLevel(actor?.level)) {
|
|
140
|
+
return { allowed: false, reason_code: INDEX_REFUSAL.BAD_ACTOR, detail: `actor level must be an integer 0..100, got ${JSON.stringify(actor?.level ?? null)}`, required: null };
|
|
141
|
+
}
|
|
142
|
+
if (record.settled !== null) {
|
|
143
|
+
return { allowed: false, reason_code: INDEX_REFUSAL.ALREADY_SETTLED, detail: `already settled as "${record.settled}"`, required: null };
|
|
144
|
+
}
|
|
145
|
+
const peer = mayApprove(
|
|
146
|
+
{ id: actor.id ?? null, level: actor.level },
|
|
147
|
+
{ id: record.subjectId, level: record.subjectLevel },
|
|
148
|
+
);
|
|
149
|
+
if (peer.ok) return { allowed: true, reason_code: null, detail: null, required: peer.required };
|
|
150
|
+
// `mayApprove` already distinguishes self-approval from an insufficient level, and the distinction
|
|
151
|
+
// matters to a reader: one is outranking, the other is identity. Mapped rather than re-derived, so
|
|
152
|
+
// the two modules cannot drift into disagreeing about which happened.
|
|
153
|
+
const selfRefused = typeof peer.reason === 'string' && peer.reason.includes('may not approve its own');
|
|
154
|
+
return {
|
|
155
|
+
allowed: false,
|
|
156
|
+
reason_code: selfRefused ? INDEX_REFUSAL.SELF_APPROVAL : INDEX_REFUSAL.INSUFFICIENT_AUTHORITY,
|
|
157
|
+
detail: peer.reason,
|
|
158
|
+
required: peer.required,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Record that a request was settled, by whoever settled it.
|
|
164
|
+
*
|
|
165
|
+
* Separate from `decide` because not every settlement is a decision: the broker's timer delegates, the
|
|
166
|
+
* host cancels on abort, and a disposal settles everything. A queue that only learned about operator
|
|
167
|
+
* decisions would keep showing requests that are long gone.
|
|
168
|
+
*/
|
|
169
|
+
function settle(approvalId, outcome) {
|
|
170
|
+
const record = entries.get(approvalId);
|
|
171
|
+
if (record === undefined) return { ok: false, reason_code: INDEX_REFUSAL.UNKNOWN_REQUEST };
|
|
172
|
+
if (record.settled !== null) return { ok: false, reason_code: INDEX_REFUSAL.ALREADY_SETTLED, detail: `already settled as "${record.settled}"` };
|
|
173
|
+
record.settled = String(outcome);
|
|
174
|
+
record.settledAt = now();
|
|
175
|
+
audit('approval-index/settled', { approval_id: approvalId, outcome: record.settled });
|
|
176
|
+
return { ok: true };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Decide a request as this actor, if the actor may.
|
|
181
|
+
*
|
|
182
|
+
* Returns the verdict and marks the entry settled so the queue stops showing it. The caller is
|
|
183
|
+
* responsible for actually settling the broker's promise — this function deliberately cannot, because
|
|
184
|
+
* the broker owns the resolver and a second settler would race the timer.
|
|
185
|
+
*
|
|
186
|
+
* @param {string} approvalId
|
|
187
|
+
* @param {{id?: string|null, level: number}} actor
|
|
188
|
+
* @param {string} outcome
|
|
189
|
+
* @returns {{ok: boolean, reason_code: string|null, detail: string|null, approvalId: string}}
|
|
190
|
+
*/
|
|
191
|
+
function decide(approvalId, actor, outcome) {
|
|
192
|
+
const record = entries.get(approvalId);
|
|
193
|
+
if (record === undefined) {
|
|
194
|
+
return { ok: false, reason_code: INDEX_REFUSAL.UNKNOWN_REQUEST, detail: `no pending approval ${approvalId}`, approvalId };
|
|
195
|
+
}
|
|
196
|
+
const check = mayAct(record, actor);
|
|
197
|
+
if (!check.allowed) return { ok: false, reason_code: check.reason_code, detail: check.detail, approvalId };
|
|
198
|
+
|
|
199
|
+
record.settled = String(outcome);
|
|
200
|
+
record.settledAt = now();
|
|
201
|
+
record.decidedBy = actor.id ?? null;
|
|
202
|
+
audit('approval-index/decided', { approval_id: approvalId, by: actor.id ?? null, level: actor.level, outcome: record.settled });
|
|
203
|
+
return { ok: true, reason_code: null, detail: null, approvalId };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* What this actor may act on, oldest first.
|
|
208
|
+
*
|
|
209
|
+
* Oldest first because a queue's job is to surface what has been waiting longest — the request closest to
|
|
210
|
+
* its deadline is the one whose loss costs most, and a newest-first view would hide exactly that.
|
|
211
|
+
*
|
|
212
|
+
* Each row carries its own verdict rather than being filtered out, so a supervisor can see that work
|
|
213
|
+
* exists above their authority and is not theirs to answer. A filtered list would make the fleet look
|
|
214
|
+
* idle at the moment it is most blocked.
|
|
215
|
+
*/
|
|
216
|
+
function listFor(actor, { includeSettled = false, nowMs = null } = {}) {
|
|
217
|
+
reap();
|
|
218
|
+
const at = Number.isFinite(nowMs) ? nowMs : now();
|
|
219
|
+
const rows = [];
|
|
220
|
+
for (const record of entries.values()) {
|
|
221
|
+
if (!includeSettled && record.settled !== null) continue;
|
|
222
|
+
const check = record.settled !== null
|
|
223
|
+
? { allowed: false, reason_code: INDEX_REFUSAL.ALREADY_SETTLED, detail: `settled as "${record.settled}"`, required: null }
|
|
224
|
+
: mayAct(record, actor);
|
|
225
|
+
rows.push({
|
|
226
|
+
approval_id: record.approvalId,
|
|
227
|
+
session: record.sessionKey,
|
|
228
|
+
action: record.action,
|
|
229
|
+
tool: record.toolName,
|
|
230
|
+
reason: record.reason,
|
|
231
|
+
subject: { id: record.subjectId, level: record.subjectLevel },
|
|
232
|
+
opened_at: new Date(record.openedAt).toISOString(),
|
|
233
|
+
waiting_ms: Math.max(0, at - record.openedAt),
|
|
234
|
+
deadline_at: record.deadlineAt === null ? null : new Date(record.deadlineAt).toISOString(),
|
|
235
|
+
overdue: record.deadlineAt !== null && record.deadlineAt <= at && record.settled === null,
|
|
236
|
+
settled: record.settled,
|
|
237
|
+
may_act: check.allowed,
|
|
238
|
+
required_level: check.required,
|
|
239
|
+
why_not: check.allowed ? null : { reason_code: check.reason_code, detail: check.detail },
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
rows.sort((a, b) => Date.parse(a.opened_at) - Date.parse(b.opened_at));
|
|
243
|
+
return rows;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The shape of the queue, without the rows.
|
|
248
|
+
*
|
|
249
|
+
* The design asks for one queue across sessions; a supervisor's first question is usually "how much is
|
|
250
|
+
* waiting and where", and a count grouped by session answers it without reading every row.
|
|
251
|
+
*/
|
|
252
|
+
function summary(actor) {
|
|
253
|
+
const rows = listFor(actor);
|
|
254
|
+
const bySession = new Map();
|
|
255
|
+
let actionable = 0;
|
|
256
|
+
let overdue = 0;
|
|
257
|
+
for (const row of rows) {
|
|
258
|
+
const key = row.session ?? '(no session)';
|
|
259
|
+
bySession.set(key, (bySession.get(key) ?? 0) + 1);
|
|
260
|
+
if (row.may_act) actionable += 1;
|
|
261
|
+
if (row.overdue) overdue += 1;
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
pending: rows.length,
|
|
265
|
+
actionable,
|
|
266
|
+
overdue,
|
|
267
|
+
by_session: Object.fromEntries([...bySession.entries()].sort(([a], [b]) => String(a).localeCompare(String(b)))),
|
|
268
|
+
viewer: { id: actor?.id ?? null, level: actor?.level ?? null },
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Every pending approval, unfiltered. For diagnostics and for a caller that has already authorised. */
|
|
273
|
+
function all({ includeSettled = false } = {}) {
|
|
274
|
+
reap();
|
|
275
|
+
return [...entries.values()].filter((r) => includeSettled || r.settled === null);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
register,
|
|
280
|
+
settle,
|
|
281
|
+
decide,
|
|
282
|
+
mayAct: (approvalId, actor) => {
|
|
283
|
+
const record = entries.get(approvalId);
|
|
284
|
+
if (record === undefined) return { allowed: false, reason_code: INDEX_REFUSAL.UNKNOWN_REQUEST, detail: `no pending approval ${approvalId}`, required: null };
|
|
285
|
+
return mayAct(record, actor);
|
|
286
|
+
},
|
|
287
|
+
listFor,
|
|
288
|
+
summary,
|
|
289
|
+
all,
|
|
290
|
+
get size() {
|
|
291
|
+
reap();
|
|
292
|
+
return [...entries.values()].filter((r) => r.settled === null).length;
|
|
293
|
+
},
|
|
294
|
+
/** Exposed for diagnostics only. */
|
|
295
|
+
_internals: { entries },
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Re-exported so a caller of this module does not have to reach into `authority.mjs` for one name. */
|
|
300
|
+
export { AUTHORITY_VERDICT, requiredApproverLevel };
|