instar 1.3.493 → 1.3.495
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/dashboard/index.html +6 -0
- package/dashboard/mandates.js +147 -3
- package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
- package/dist/core/PostUpdateMigrator.js +18 -0
- package/dist/core/PostUpdateMigrator.js.map +1 -1
- package/dist/messaging/slack/SocketModeClient.d.ts +10 -0
- package/dist/messaging/slack/SocketModeClient.d.ts.map +1 -1
- package/dist/messaging/slack/SocketModeClient.js +77 -49
- package/dist/messaging/slack/SocketModeClient.js.map +1 -1
- package/dist/scaffold/templates.d.ts.map +1 -1
- package/dist/scaffold/templates.js +1 -0
- package/dist/scaffold/templates.js.map +1 -1
- package/dist/server/CapabilityIndex.d.ts.map +1 -1
- package/dist/server/CapabilityIndex.js +2 -1
- package/dist/server/CapabilityIndex.js.map +1 -1
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +25 -0
- package/dist/server/routes.js.map +1 -1
- package/package.json +1 -1
- package/src/data/builtin-manifest.json +63 -63
- package/src/scaffold/templates.ts +1 -0
- package/upgrades/1.3.494.md +27 -0
- package/upgrades/1.3.495.md +29 -0
- package/upgrades/side-effects/mandate-grant-form.md +79 -0
- package/upgrades/side-effects/socketmode-ws-leak-fix.md +74 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# Side-Effects Review — Mandates-tab user floor-action grant form (#1080)
|
|
2
|
+
|
|
3
|
+
**Version / slug:** `mandate-grant-form`
|
|
4
|
+
**Date:** `2026-06-12`
|
|
5
|
+
**Author:** `Instar Agent (echo)`
|
|
6
|
+
**Second-pass reviewer:** `reviewer subagent (PIN-gated authority surface — see appended response)`
|
|
7
|
+
|
|
8
|
+
## Summary of the change
|
|
9
|
+
|
|
10
|
+
Adds the missing operator surface for the existing PIN-gated `POST /mandate/:id/grants` route: a phone-first grant form on every active mandate card in the dashboard Mandates tab (`dashboard/mandates.js` renderers + controller, CSS in `dashboard/index.html`), plus a new read-only `GET /permissions/users` route (`src/server/routes.ts`) feeding the person picker, `CapabilityIndex` entries, the CLAUDE.md template bullet (`src/scaffold/templates.ts`), and a Migration Parity patch (`src/core/PostUpdateMigrator.ts`) inserting that bullet into existing agents. Born from the 2026-06-12 Mobile-Complete Operator Actions lesson (scenario 8/8 of the Slack live test was laptop-bound). The change adds NO new authority and NO new decision logic — the server-side gate, PIN check, signing, clamping, and audit are all pre-existing and untouched.
|
|
11
|
+
|
|
12
|
+
## Decision-point inventory
|
|
13
|
+
|
|
14
|
+
- `POST /mandate/:id/grants` (PIN gate + MandateStore.addGrants validation) — **pass-through** — the form is a client of the existing gate; its checks are unchanged.
|
|
15
|
+
- `GET /permissions/users` (new route) — **add, but not a decision point** — read-only projection of `users.json` (slackUserId/name/orgRole only); gates nothing, filters nothing inbound, holds no authority.
|
|
16
|
+
- Client-side validation in `wireGrantButtons` (PIN present, grantee present) — **add** — UX-layer pre-flight only; the server re-validates everything. Refusing to POST without a PIN is convenience, not authority (the server would 403 identically).
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## 1. Over-block
|
|
21
|
+
|
|
22
|
+
**What legitimate inputs does this change reject that it shouldn't?**
|
|
23
|
+
|
|
24
|
+
The form refuses to submit without a grantee or PIN (both server-required — no legitimate request is lost). The person picker constrains the grantee to registered users; an operator legitimately wanting to grant an UNREGISTERED Slack id would be over-blocked by a dropdown-only design — mitigated: when the registry is empty the field degrades to free text, and the API accepts any id directly. Residual: with a non-empty registry the dropdown offers no "type someone else" escape; accepted for now (a grant to an unregistered principal can't be resolved by the permission gate anyway — `SlackPrincipalResolver` won't produce a registered principal for them, so such a grant would be inert; the dropdown reflects the set for which a grant is meaningful).
|
|
25
|
+
|
|
26
|
+
## 2. Under-block
|
|
27
|
+
|
|
28
|
+
**What failure modes does this still miss?**
|
|
29
|
+
|
|
30
|
+
- The form cannot stop an operator from granting the WRONG person a floor action — by design; the PIN holder's judgment is the authority. The plain-language confirmation + the per-card grant list are the mitigations.
|
|
31
|
+
- `GET /permissions/users` lists ALL registered Slack users regardless of role; it does not advise "this person already holds this authority by role" (a redundant grant is valid and harmless — the gate checks role first).
|
|
32
|
+
- The duration presets cap at 24h; longer grants require re-granting (deliberate friction, matches the spec's time-boxed intent).
|
|
33
|
+
|
|
34
|
+
## 3. Level-of-abstraction fit
|
|
35
|
+
|
|
36
|
+
**Is this at the right layer?**
|
|
37
|
+
|
|
38
|
+
Yes. The gap was purely presentational — the authority/validation/signing layer (`MandateStore.addGrants`, `checkMandatePin`) is correct and untouched. The expiry clamp is duplicated client-side ONLY to convert a server rejection into a success-with-shorter-window (better operator experience); the server remains the enforcing layer. The person picker's data comes from a server route rather than embedding registry reads in the dashboard — consistent with every other tab.
|
|
39
|
+
|
|
40
|
+
## 4. Signal vs authority compliance
|
|
41
|
+
|
|
42
|
+
**Does this hold blocking authority with brittle logic?**
|
|
43
|
+
|
|
44
|
+
No blocking authority anywhere in the change. The form's pre-flight checks mirror server requirements 1:1 and cannot diverge into wrongly-blocking territory without the server having the same requirement. The floor-action dropdown is the one brittle mirror (a hand-copied enum) — made non-brittle by the dedicated drift test pinning the dashboard list against the `FLOOR_ACTIONS` source enum. Reference reviewed: `docs/signal-vs-authority.md`.
|
|
45
|
+
|
|
46
|
+
## 5. Interactions
|
|
47
|
+
|
|
48
|
+
**Does it shadow another check, get shadowed, double-fire, race with adjacent cleanup?**
|
|
49
|
+
|
|
50
|
+
- `renderMandates` gained an optional second parameter (default `[]`) — all existing callers and the 24 pre-existing tab tests pass unchanged.
|
|
51
|
+
- `wireGrantButtons()` follows the exact `wireRevokeButtons()` pattern (re-wired on every refresh; `onclick` assignment is idempotent).
|
|
52
|
+
- The new `/permissions/users` fetch joins the refresh `Promise.all`; its failure is caught per-call and degrades the picker to free text — a registry outage can no longer take down the tab (tested).
|
|
53
|
+
- The 30s auto-refresh re-renders the list, which RESETS in-progress form input (pre-existing behavior for the revoke row's PIN field too). Mitigation considered and deferred as pre-existing scope: the grant form lives in a collapsed `<details>`, so accidental loss requires the refresh to land mid-typing; the revoke row has shipped with this behavior since the tab existed. <!-- tracked: JKHeadley/instar#1080 -->
|
|
54
|
+
|
|
55
|
+
## 6. External surfaces
|
|
56
|
+
|
|
57
|
+
**Anything visible to other agents/users/systems? Timing/state dependencies?**
|
|
58
|
+
|
|
59
|
+
- New Bearer-gated route `GET /permissions/users` exposes slackUserId + name + orgRole of registered users — strictly less than `users.json` already holds, no channel identifiers/preferences/permissions leak (tested). Visible to any Bearer holder (same trust domain as the full mandate list).
|
|
60
|
+
- CLAUDE.md template + migration change agent guidance fleet-wide on update (the behavioral half of the fix). Migration is idempotent, anchored, with append fallback (tested all three paths).
|
|
61
|
+
- No config, no message formats, no timing dependencies beyond the existing dashboard refresh cycle.
|
|
62
|
+
|
|
63
|
+
## 7. Rollback cost
|
|
64
|
+
|
|
65
|
+
**If this turns out wrong in production, what's the back-out?**
|
|
66
|
+
|
|
67
|
+
Revert the PR, release a patch. The dashboard is stateless; the route is read-only; grants created through the form are ordinary signed mandate grants (rollback does not orphan them — they expire or die with their mandate). The CLAUDE.md migration leaves one extra guidance bullet in updated agents' files; it references the Mandates tab generically and stays harmless even if the form were reverted (the tab still exists). Cheap rollback.
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## Second-pass review
|
|
72
|
+
|
|
73
|
+
**Reviewer:** independent reviewer subagent (id adc039b9f0dd4b8dc), 2026-06-12
|
|
74
|
+
**Verdict (verbatim):** "Concur with the review." — after verifying: the auth-middleware allowlist does NOT skip `/permissions` (Bearer applies; e2e pins the 401); PIN never in module state and cleared on success + refusal paths; every new render interpolation goes through `esc()` (attribute positions included); the expiry clamp compares ms epochs on both sides and equality passes the server's strict check; the grant form is gated on the same `state === 'active'` condition as the revoke row; the migration guard phrase is contained in its own inserted bullet (idempotent).
|
|
75
|
+
|
|
76
|
+
Observations (all non-blocking), and disposition:
|
|
77
|
+
1. The floor-action drift pin was one-directional (subset, not equality) — a stale dashboard-only extra after an enum removal would mint inert-but-recorded grants. **Disposition: fixed — the test now asserts set-equality in both directions.**
|
|
78
|
+
2. A THROWN fetch (network failure) skipped the PIN clear and failed silently (matching the pre-existing issue/revoke pattern). **Disposition: fixed beyond the pre-existing pattern — PIN clear moved to `finally` (clears on every path) and a catch surfaces the failure as a persistent error note; new test pins both.**
|
|
79
|
+
3. `MandateStore.addGrants` refuses revoked but not EXPIRED mandates — protection on expired ones is by clamp-inheritance (the grant's effective expiry can never exceed the mandate's, so a grant signed into an expired mandate is born dead), not an explicit check; pre-existing behavior, not introduced here. **Disposition: noted here for the record; the UI additionally hides the form on expired mandates.**
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Side-Effects Review — SocketModeClient websocket leak fix (#1076)
|
|
2
|
+
|
|
3
|
+
**Version / slug:** `socketmode-ws-leak-fix`
|
|
4
|
+
**Date:** `2026-06-12`
|
|
5
|
+
**Author:** `Instar Agent (echo)`
|
|
6
|
+
**Second-pass reviewer:** `reviewer subagent (connection-recovery path — see appended response)`
|
|
7
|
+
|
|
8
|
+
## Summary of the change
|
|
9
|
+
|
|
10
|
+
Fixes JKHeadley/instar#1076: `SocketModeClient` leaked one live websocket per deliberate `reconnect()` because the "temporarily clear `started`" save/restore was synchronous while websocket close events fire on a later tick — the stale close handler orphaned the replacement socket and double-reconnected. Two mechanisms replace it, both confined to `src/messaging/slack/SocketModeClient.ts`: (1) socket event handlers are closure-bound to their own socket and ignore events when that socket is no longer `this.ws`; (2) a connection `epoch` is bumped on every deliberate teardown (`disconnect()`, `reconnect()`, `_forceReconnect()`, `_handleDisconnect()`, all funneled through a new `_teardownSocket()`), and every in-flight async path (awaited `apps.connections.open`, sleeping `_backoffReconnect`, delayed `too_many_websockets` retry, post-failure MAX_BACKOFF retry) captures the epoch at start and stands down if superseded. Tests: new behavioral file `tests/unit/slack-socket-leak.test.ts` (proven failing first), pattern updates in `slack-socket-reconnect.test.ts` and `slack-socket-heartbeat.test.ts`. No decision-point (gate/sentinel/block-allow) surface is touched; this is transport-lifecycle correctness.
|
|
11
|
+
|
|
12
|
+
## Decision-point inventory
|
|
13
|
+
|
|
14
|
+
No decision points touched. The change gates no information flow, blocks no actions, filters no messages, and constrains no agent behavior — it manages the lifecycle of a transport connection. The nearest decision-shaped logic is "should this stale event trigger a reconnect?", which is connection bookkeeping, not agent-behavior authority.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## 1. Over-block
|
|
19
|
+
|
|
20
|
+
**What legitimate inputs does this change reject that it shouldn't?**
|
|
21
|
+
|
|
22
|
+
The new guards drop two classes of events: (a) events from a socket that is no longer tracked, (b) scheduled reconnects whose epoch was superseded. Class (a) risk: a `message` event from a just-replaced socket carrying a real Slack envelope is now ignored instead of processed. This is correct, not over-block: processing it would ack on a dead/stale socket (the un-acked event is redelivered by Slack on the live connection — Slack's documented Socket Mode behavior, and the adapter already dedupes redelivered events). Class (b) risk: a superseded backoff stands down — by construction another path has already opened (or is opening) the connection, so dropping the duplicate dial is the fix itself. No legitimate input is rejected.
|
|
23
|
+
|
|
24
|
+
## 2. Under-block
|
|
25
|
+
|
|
26
|
+
**What failure modes does this still miss?**
|
|
27
|
+
|
|
28
|
+
- A socket that dies without EVER emitting close (silent TCP death) is unchanged — that's the existing heartbeat/liveness-probe path, untouched and still covering it.
|
|
29
|
+
- The SleepWake detector's spurious ~10s-gap wakes still trigger frequent (now harmless) redials and tunnel restarts — explicitly out of scope, tracked in JKHeadley/instar#1077. <!-- tracked: JKHeadley/instar#1077 -->
|
|
30
|
+
- If Slack's `apps.connections.open` itself returns errors indefinitely, behavior is the existing backoff loop (unchanged semantics, now epoch-guarded).
|
|
31
|
+
|
|
32
|
+
## 3. Level-of-abstraction fit
|
|
33
|
+
|
|
34
|
+
**Is this at the right layer?**
|
|
35
|
+
|
|
36
|
+
Yes. The leak is intrinsic to the socket-lifecycle bookkeeping inside `SocketModeClient`; neither `SlackAdapter.reconnect()` (a one-line delegate) nor the SleepWake handler in `server.ts` can fix it — they cannot see which socket an event belongs to. Rate-limiting wake events at the server layer (#1077) is complementary, not a substitute: any caller may legitimately call `reconnect()` at any frequency and the client must stay leak-free.
|
|
37
|
+
|
|
38
|
+
## 4. Signal vs authority compliance
|
|
39
|
+
|
|
40
|
+
**Does this hold blocking authority with brittle logic?**
|
|
41
|
+
|
|
42
|
+
Not applicable in the gate sense — the change is not a detector or an authority over agent behavior (see decision-point inventory). Reference reviewed: `docs/signal-vs-authority.md`. The identity/epoch guards are deterministic bookkeeping over objects the module itself owns, the category where plain code is the correct tool.
|
|
43
|
+
|
|
44
|
+
## 5. Interactions
|
|
45
|
+
|
|
46
|
+
**Does it shadow another check, get shadowed, double-fire, race with adjacent cleanup?**
|
|
47
|
+
|
|
48
|
+
- The previous behavior DOUBLE-FIRED `handlers.onDisconnected` for Slack-initiated disconnect envelopes (once from `_handleDisconnect`, once from the late close handler) — visible as duplicated `[slack] Disconnected:` log lines in the incident. The identity guard removes the duplicate; `SlackAdapter`'s `onDisconnected` only stamps `_lastDisconnectedAt` and logs. Honest coverage note (raised by the second-pass reviewer): client-initiated teardowns (`reconnect()`, `_forceReconnect()`) now fire ZERO `onDisconnected` where the old code accidentally fired one via the late close — so `_lastDisconnectedAt` is no longer stamped on those cycles and `_recoverMissedMessages` won't trigger for them. Accepted: the old stamp landed at teardown time (never covering the actual outage window, so the "recovery" it triggered re-read a window with nothing in it), the dominant disconnect paths (natural close, Slack envelopes) still stamp, and Slack redelivers un-acked envelopes on the new connection.
|
|
49
|
+
- `_startHeartbeat()` already self-clears before starting; with the open-handler guard a stale socket can no longer restart the heartbeat for an orphan. The heartbeat's `_forceReconnect` now funnels through `_teardownSocket` like every other teardown — one code path, no divergent cleanup to race.
|
|
50
|
+
- `connect()` on an already-connected client now tears down a pre-existing socket via the `_openConnection` invariant check instead of silently orphaning it (defensive; no current caller does this).
|
|
51
|
+
|
|
52
|
+
## 6. External surfaces
|
|
53
|
+
|
|
54
|
+
**Anything visible to other agents/users/systems? Timing/state dependencies?**
|
|
55
|
+
|
|
56
|
+
No API, config, message-format, or scaffold/template surface changes; no migration needed (pure `src/` behavior fix shipped by version update). Externally visible effect on Slack's side: the app stops accumulating phantom Socket Mode connections and stops hammering `apps.connections.open` — strictly closer to intended behavior. The fix inherently depends on event ordering (that's its subject); the test harness models the real ordering (close events fired manually on a later tick) rather than assuming benign timing.
|
|
57
|
+
|
|
58
|
+
## 7. Rollback cost
|
|
59
|
+
|
|
60
|
+
**If this turns out wrong in production, what's the back-out?**
|
|
61
|
+
|
|
62
|
+
Revert the single source file (+ tests) and release a patch — no data, state, config, or migration coupling. Worst plausible failure mode of the fix itself would be an ignored close event on a socket that IS current (impossible by construction: the guard compares against `this.ws`, and only `_teardownSocket`/the close handler itself clear it), with the existing heartbeat as the independent backstop that force-reconnects any non-OPEN tracked socket within 30s. Cheap, clean rollback.
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Second-pass review
|
|
67
|
+
|
|
68
|
+
**Reviewer:** independent reviewer subagent (id a494a8c1260cacf70), 2026-06-12
|
|
69
|
+
**Verdict (verbatim):** "Concur with the review." — after walking every requested interleaving class (dual dials at the same epoch resolve via the `_openConnection` invariant teardown; the `reconnecting` flag cannot stick true since `_backoffReconnect` resets it unconditionally after the sleep and before the epoch check; the natural-close-while-sleeper-in-flight case is safe precisely because natural closes don't bump the epoch).
|
|
70
|
+
|
|
71
|
+
Observations (all non-blocking), and disposition:
|
|
72
|
+
1. Section 5's onDisconnected claim was correct as scoped but incomplete — client-initiated teardowns now fire zero `onDisconnected`, reducing `_recoverMissedMessages` trigger coverage on those cycles. **Disposition: section 5 reworded honestly (above); behavior accepted for the reasons stated there.**
|
|
73
|
+
2. Theoretical permanent-death strand via a defensive double-`connect()` with a failing API. **Disposition: hardened — `connect()` now tears down any pre-existing socket first (see `connect()` comment).**
|
|
74
|
+
3. The epoch deliberately not bumping on natural closes is load-bearing for liveness. **Disposition: documented with a comment in the close handler so a future "bump everywhere for symmetry" refactor can't introduce the permanent-death path.**
|