@twinsearth/w2m-dsh-plugin 0.9.7 → 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.
@@ -1,4 +1,4 @@
1
- /**
1
+ /**
2
2
  * The Localside agent: everything between "Rabbit hands us an offer" and
3
3
  * "Rabbit has our envelope".
4
4
  *
@@ -48,7 +48,7 @@ import {
48
48
  resolveProjectCwd,
49
49
  } from './git.mjs';
50
50
  import { createSpool } from './spool.mjs';
51
- import { appendMemory, memoryRecord } from './memory.mjs';
51
+ import { MEMORY_FILE, appendMemory, memoryRecord } from './memory.mjs';
52
52
  import { matchAllowedCommand, parseAllowedCommands } from './allowed-commands.mjs';
53
53
  import { CRYPTO_REASON, DEVICE_KEY_FILE, DeviceKey, PeerKeyStore } from './crypto.mjs';
54
54
  import { DEFAULT_P2P_MODE, P2P_DEFAULTS, P2PNode, normalizeP2PMode, normalizeP2PPort } from './p2p-node.mjs';
@@ -1094,7 +1094,7 @@ export function createAgent(options) {
1094
1094
  // Loaded here, at construction, rather than lazily at `start()`: the key file is also what a
1095
1095
  // *dispatcher* publishes in an offer, and "which key will this machine present" must not depend on
1096
1096
  // whether a particular code path happened to have run yet. `loadOrCreate` is the documented rule
1097
- // ("generated the first time a machine pairs"), and a failure is never fatal — see `deviceKeyError`.
1097
+ // ("generated the first time a machine pairs"), and a failure is never fatal - see `deviceKeyError`.
1098
1098
  const deviceKeyOutcome = loadDeviceKey(options.deviceKey ?? null, stateDir, identity.machine_id, identityPath);
1099
1099
  /** @type {object|null} This machine's static keypair, or null when there is none. */
1100
1100
  const deviceKey = deviceKeyOutcome.key;
@@ -1103,7 +1103,7 @@ export function createAgent(options) {
1103
1103
  /**
1104
1104
  * A key adopted from the pre-v0.6.2 location (`--state/device.key`). Reported once at startup,
1105
1105
  * because a machine whose key moved is a machine whose public key a peer may have learned under the
1106
- * old file — silence here would make "my peers stopped talking to me" have no visible cause.
1106
+ * old file - silence here would make "my peers stopped talking to me" have no visible cause.
1107
1107
  */
1108
1108
  const deviceKeyMigratedFrom = deviceKeyOutcome.migratedFrom ?? null;
1109
1109
  /**
@@ -1120,7 +1120,7 @@ export function createAgent(options) {
1120
1120
  * What each task's direct channel actually was, for the result envelope.
1121
1121
  *
1122
1122
  * Keyed by `task_id`, alongside the channel map rather than on the channel itself, because the
1123
- * envelope is built *after* `planDirectDelivery` may have released the channel — and
1123
+ * envelope is built *after* `planDirectDelivery` may have released the channel - and
1124
1124
  * `p2p.encrypted` has to describe the channel that carried this attempt, not whichever channel
1125
1125
  * happens to be live when the envelope is written.
1126
1126
  *
@@ -1166,7 +1166,7 @@ export function createAgent(options) {
1166
1166
  const p2pPeers = new Map();
1167
1167
 
1168
1168
  /**
1169
- * One reverse-dial attempt per `task_id` — the executor's own half of a two-way punch.
1169
+ * One reverse-dial attempt per `task_id` - the executor's own half of a two-way punch.
1170
1170
  *
1171
1171
  * A record exists for every offer this agent takes on, including the ones it decides *not* to
1172
1172
  * dial for, and that is deliberate: "we did not try, because a channel was already live" and "we
@@ -1444,8 +1444,8 @@ export function createAgent(options) {
1444
1444
  * happened rather than what was possible. `reason` is the named code from that same channel, so
1445
1445
  * `p2p.encrypted: false` always arrives with a *why*.
1446
1446
  *
1447
- * It is called from both places a channel becomes a task's channel — the offer that arrived on one
1448
- * and the dial that opened one — and the most recent call wins, which is right: a task's result
1447
+ * It is called from both places a channel becomes a task's channel - the offer that arrived on one
1448
+ * and the dial that opened one - and the most recent call wins, which is right: a task's result
1449
1449
  * travels over the channel that is live *now*.
1450
1450
  *
1451
1451
  * @param {string} taskId
@@ -1479,7 +1479,7 @@ export function createAgent(options) {
1479
1479
  *
1480
1480
  * Without this, a task executed entirely over the relay reports `encrypted: false` with no
1481
1481
  * explanation, and a reader cannot tell "this machine has no crypto" from "no direct channel was the
1482
- * answer for this task" — entirely different operational facts. The reason is the *same* string
1482
+ * answer for this task" - entirely different operational facts. The reason is the *same* string
1483
1483
  * `offerTransportOf` puts in `p2p.reason`, deliberately: one sentence, decided in one place.
1484
1484
  *
1485
1485
  * It never overwrites a channel's own record: once a channel carried something for this task, which
@@ -2118,7 +2118,7 @@ export function createAgent(options) {
2118
2118
  * Signing headers for one request, or nothing at all.
2119
2119
  *
2120
2120
  * The signed `path` is the **API-relative** path plus its query
2121
- * (`/v1/stream?machine_id=…&seq=5`), never the URL as sent.
2121
+ * (`/v1/stream?machine_id= - seq=5`), never the URL as sent.
2122
2122
  *
2123
2123
  * That looks backwards -- surely the signature should cover the bytes on the
2124
2124
  * wire? -- so here is the case that settles it. With `rabbitUrl =
@@ -2283,7 +2283,7 @@ export function createAgent(options) {
2283
2283
  * * a **pairing code** is the operator saying *"let this machine in"*, and it is the only way to
2284
2284
  * place a device in a fleet the device could not compute for itself;
2285
2285
  * * a **fleet key** is the account saying *"this machine is mine"*, and the relay derives the
2286
- * fleet from it — so it can only ever reach a fleet its holder is already entitled to.
2286
+ * fleet from it - so it can only ever reach a fleet its holder is already entitled to.
2287
2287
  *
2288
2288
  * Passing neither remains allowed: the relay answers `PAIRING_INVALID` and the caller reports it,
2289
2289
  * which is the pre-v0.8.1 behaviour for an unpaired machine.
@@ -2298,8 +2298,7 @@ export function createAgent(options) {
2298
2298
  /**
2299
2299
  * Send only the discriminator that was actually given.
2300
2300
  *
2301
- * `pairing_code: null` would be *present*, and a presence check reads a null as "provided" —
2302
- * the same trap that made `createILinkChannel` pass `{ fetch: undefined }` and throw on mount in
2301
+ * `pairing_code: null` would be *present*, and a presence check reads a null as "provided" - * the same trap that made `createILinkChannel` pass `{ fetch: undefined }` and throw on mount in
2303
2302
  * v0.7.0. An absent code must be an absent key, not a null one.
2304
2303
  */
2305
2304
  if (typeof pairingCode === 'string' && pairingCode !== '') body.pairing_code = pairingCode;
@@ -2419,7 +2418,7 @@ export function createAgent(options) {
2419
2418
  * states, and a bound nobody measures is a claim: this is the number that says
2420
2419
  * whether the result path really stayed inside it.
2421
2420
  * * `result_error` -- why the direct copy of *this result* did not land, when one was attempted
2422
- * and not acknowledged (`P2P_ACK_TIMEOUT: …`). Null otherwise; this is the
2421
+ * and not acknowledged (`P2P_ACK_TIMEOUT: - ). Null otherwise; this is the
2423
2422
  * `delivery.error` that `p2pDeliveryMark` has always carried and the envelope
2424
2423
  * never reported.
2425
2424
  *
@@ -2438,8 +2437,7 @@ export function createAgent(options) {
2438
2437
  *
2439
2438
  * Read from what the agent *recorded* when it held the channel, not from a live object: by the
2440
2439
  * time the envelope is built the channel may have been released, and `p2p.encrypted` has to
2441
- * describe the channel that carried this attempt. `null` means no channel was ever involved —
2442
- * a task executed entirely over the relay — which reports `encrypted: false` because that is the
2440
+ * describe the channel that carried this attempt. `null` means no channel was ever involved - * a task executed entirely over the relay - which reports `encrypted: false` because that is the
2443
2441
  * truth about this task's data plane, whatever this machine's keypair could have done.
2444
2442
  */
2445
2443
  const encryption = encryptionFor(offer?.task_id);
@@ -2653,17 +2651,25 @@ export function createAgent(options) {
2653
2651
  * The write happens **after** the envelope is complete, so the record it stores is the same one the
2654
2652
  * relay will judge -- not a second, independently assembled opinion about what happened.
2655
2653
  *
2656
- * `appendMemory` never throws and its result is deliberately not returned or checked: the task is
2657
- * the work and this is a note about it, so a full disk or an unwritable directory must not turn a
2658
- * successful command into a failed task. A memory that can fail work is worse than no memory, and
2659
- * the failure mode is silent here on purpose -- the envelope's own fields already say what happened.
2654
+ * `appendMemory` never throws and its result never changes the verdict: the task is the work and
2655
+ * this is a note about it, so a full disk or an unwritable directory must not turn a successful
2656
+ * command into a failed task. A memory that can fail work is worse than no memory.
2657
+ *
2658
+ * v0.9.8: its *outcome* is nonetheless reported, as `memory` on the envelope. Leaving it entirely
2659
+ * silent was the right call for the control flow and the wrong one for the reader -- a machine whose
2660
+ * memory is not being written is indistinguishable from one that has nothing to remember, which is
2661
+ * exactly the class of invisible failure this layer exists to end. One optional field, no verdict
2662
+ * moved: it is not a comparable field, so two machines cannot disagree over it.
2660
2663
  */
2661
- appendMemory(stateDir, memoryRecord({
2664
+ const memoryWrite = appendMemory(stateDir, memoryRecord({
2662
2665
  envelope,
2663
2666
  commandArgv,
2664
2667
  cwdRel,
2665
2668
  ms: (Date.parse(endedAt) - Date.parse(startedAt)) || 0,
2666
2669
  }));
2670
+ envelope.memory = memoryWrite.ok === true
2671
+ ? { written: true, file: MEMORY_FILE, rotated: memoryWrite.rotated === true }
2672
+ : { written: false, reason_code: memoryWrite.error };
2667
2673
 
2668
2674
  return buildEnvelope(envelope);
2669
2675
  }
@@ -3220,7 +3226,7 @@ export function createAgent(options) {
3220
3226
  }
3221
3227
 
3222
3228
  if (channelFor(offer?.task_id) === null) {
3223
- // No channel at all, so nothing about this task's data plane was ever sealed — and the reason
3229
+ // No channel at all, so nothing about this task's data plane was ever sealed - and the reason
3224
3230
  // recorded here is the one `offerTransportOf` already decided for `p2p.reason`.
3225
3231
  rememberNoChannel(offer?.task_id, offerTransportOf(offer, p2pStatusSnapshot()).reason);
3226
3232
  return p2pDeliveryMark();
@@ -3282,7 +3288,7 @@ export function createAgent(options) {
3282
3288
 
3283
3289
  const startedAt = process.hrtime.bigint();
3284
3290
  // `machine_id` travels with the notice because the dispatcher has to be able to say *which*
3285
- // machine finished without waiting for the relay's copy — the plugin names its direct-result
3291
+ // machine finished without waiting for the relay's copy - the plugin names its direct-result
3286
3292
  // inbox file after it. The frame still carries no envelope: `result_path` is decided by the
3287
3293
  // acknowledgement this frame is waiting for, and `envelope_sha256` covers `result_path`, so
3288
3294
  // sending the envelope first and stamping it afterwards would break the hash it is signed with.
@@ -3415,7 +3421,7 @@ export function createAgent(options) {
3415
3421
  code = null;
3416
3422
  }
3417
3423
  const error = new Error(`stream HTTP ${response.status} ${body.slice(0, 200)}`.trim());
3418
- // Preserve the relay's own code (SIGNATURE_REQUIRED, SIGNATURE_EXPIRED, …)
3424
+ // Preserve the relay's own code (SIGNATURE_REQUIRED, SIGNATURE_EXPIRED, -
3419
3425
  // so a signed link can be diagnosed without guessing.
3420
3426
  error.code = code ?? `HTTP_${response.status}`;
3421
3427
  state.lastRelayError = {
@@ -3568,7 +3574,7 @@ export function createAgent(options) {
3568
3574
  // peer tried to put on the wire is overwritten, so a relay frame can never claim the
3569
3575
  // direct path -- the only thing that sets `'p2p'` here is the channel having delivered it.
3570
3576
  const offer = { ...payload, p2p_transport: source.transport };
3571
- // v0.6.0: the block's `static_keys` is the *entire* key-distribution mechanism — a machine
3577
+ // v0.6.0: the block's `static_keys` is the *entire* key-distribution mechanism - a machine
3572
3578
  // learns the keys of exactly the peers it was asked to talk to, from the offer that asked it,
3573
3579
  // and never from a config file. Learned here, before the offer is queued, because the reverse
3574
3580
  // dial starts inside `handleOffer` and a key merged any later would miss the handshake this
@@ -4127,8 +4133,8 @@ export function createAgent(options) {
4127
4133
  /**
4128
4134
  * Load this machine's static keypair, or explain why there is none (v0.6.0).
4129
4135
  *
4130
- * Never throws. A machine whose key file is unreadable is still a working W2M machine — every task
4131
- * still travels over the relay — and `agent.state.p2p.encryption.error` names the file that could not
4136
+ * Never throws. A machine whose key file is unreadable is still a working W2M machine - every task
4137
+ * still travels over the relay - and `agent.state.p2p.encryption.error` names the file that could not
4132
4138
  * be read so an operator can fix it. Refusing to start an agent because a *security upgrade* could not
4133
4139
  * complete is exactly the fleet-losing failure `docs/P2P-CRYPTO.md` §4 forbids.
4134
4140
  *
@@ -4138,15 +4144,15 @@ export function createAgent(options) {
4138
4144
  * used to break it: it loaded from the `--state` directory, justified by a comment claiming that
4139
4145
  * `identity.mjs` puts `device.json` there "when the agent is run with `--state`". That claim is false.
4140
4146
  * `device.json` is fixed at `<DSH_HOME>/xclient/device.json` by the identity contract and `--state`
4141
- * does not move it — which is why an agent run as `--state ~/.dsh/xclient/localside-mac` had its
4147
+ * does not move it - which is why an agent run as `--state ~/.dsh/xclient/localside-mac` had its
4142
4148
  * identity one directory *above* its key.
4143
4149
  *
4144
4150
  * The measured consequence, on one machine running both halves of W2M: the plugin (which loads the key
4145
4151
  * correctly, from `dirname(device.json)`) and the agent each generated a *different* pair, and both
4146
- * announced to the one `/v1/peer/{machine_id}` slot — the relay's record alternated between the two
4152
+ * announced to the one `/v1/peer/{machine_id}` slot - the relay's record alternated between the two
4147
4153
  * nodes' ports every few seconds, and the `static_key` a dispatcher read was one node's or the other's.
4148
4154
  * A dispatcher that learns the wrong key cannot arm a handshake, so every direct channel came back
4149
- * `P2P_CRYPTO_PLAINTEXT_PEER` — reported as "the peer is an older build" when both ends were the same
4155
+ * `P2P_CRYPTO_PLAINTEXT_PEER` - reported as "the peer is an older build" when both ends were the same
4150
4156
  * build on the same host.
4151
4157
  *
4152
4158
  * MIGRATION. `DeviceKey.load` warns that "silently generating a replacement key would make every peer
@@ -4168,7 +4174,7 @@ function loadDeviceKey(injected, stateDir, machineId, deviceFile = undefined) {
4168
4174
  return { key: injected, error: null, migratedFrom: null };
4169
4175
  }
4170
4176
 
4171
- // Beside `device.json` — the documented rule. `identityPath` is the path the caller actually
4177
+ // Beside `device.json` - the documented rule. `identityPath` is the path the caller actually
4172
4178
  // loaded, never a fresh global resolution: resolving `DSH_HOME` here would make a test (or any
4173
4179
  // caller with an injected identity) read and *create* a key in the real user's home.
4174
4180
  let canonicalDir = null;
@@ -4179,7 +4185,7 @@ function loadDeviceKey(injected, stateDir, machineId, deviceFile = undefined) {
4179
4185
  /**
4180
4186
  * Candidates in order. `mayCreate` is the whole subtlety: only a location that is *the* documented
4181
4187
  * home may mint a key. A legacy directory is consulted to adopt a key that is already there, never
4182
- * to create one — `DeviceKey.load` warns that generating a replacement "would make every peer that
4188
+ * to create one - `DeviceKey.load` warns that generating a replacement "would make every peer that
4183
4189
  * knows the old one refuse this machine", so an upgrade must not silently change this machine's
4184
4190
  * public key.
4185
4191
  *
@@ -0,0 +1,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 };