instar 1.3.494 → 1.3.496

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.
@@ -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,59 @@
1
+ # Side-Effects Review — WS4.2 per-machine empty-state strip (dashboard sessions view)
2
+
3
+ **Spec:** docs/specs/MULTI-MACHINE-SEAMLESSNESS-SPEC.md (converged 2026-06-12, 3 iterations; approved)
4
+ **Change:** dashboard/index.html (+ tests). The sessions view renders an explicit state
5
+ row for every pool machine that has no session tiles: "online — no active sessions"
6
+ when its heartbeat is live, "not reachable — last seen <t>" when not. Data: the
7
+ existing `GET /pool` machines array, fetched on the existing 15s pool poll cadence.
8
+ Closes audit finding F7 (2026-06-12 live incident: idle Mini rendered as nothing and
9
+ read as a regression).
10
+
11
+ ## 1. Over-block
12
+ No issue identified — the change blocks nothing. It is a read-only presentation
13
+ addition; no input is rejected anywhere.
14
+
15
+ ## 2. Under-block
16
+ No issue identified — there is no blocking surface. Honesty boundary worth naming:
17
+ `online:false` cannot distinguish "deliberately shut down" from "network-unreachable",
18
+ so the row says "not reachable — last seen <t>" (data-honest) rather than claiming to
19
+ know which. The spec's three-state wording maps onto the two states the data supports;
20
+ this is recorded as the deliberate build decision the converged spec's round-3
21
+ adversarial pass classified as adequately constrained.
22
+
23
+ ## 3. Level-of-abstraction fit
24
+ Right layer. The dashboard already consumes `/pool` (Machines tab) and
25
+ `/sessions?scope=pool` (tiles); the strip is pure client-side composition of data both
26
+ endpoints already serve. No new route, no server change. A server-side "empty
27
+ machines" field would duplicate client-known state at a worse layer.
28
+
29
+ ## 4. Signal vs authority compliance
30
+ Compliant by vacuity: the change introduces NO decision point — it gates no flow,
31
+ filters no message, constrains no behavior. (Phase-1 principle check recorded the
32
+ same conclusion in the build transcript.)
33
+
34
+ ## 5. Interactions
35
+ - The strip renders AFTER session tiles and clears its own rows per render — no
36
+ interference with tile add/remove reconciliation (verified by test: no duplicate
37
+ accumulation).
38
+ - The zero-sessions early-return branch now also renders the strip; the existing
39
+ #emptyState banner still shows alongside it (intended: "no sessions" + per-machine
40
+ states are complementary, not contradictory).
41
+ - A machine WITH tiles gets no row (its tiles' machine badge already names it) —
42
+ prevents double-labeling.
43
+ - The extra `GET /pool` per 15s poll tick matches the Machines tab's existing call
44
+ pattern and is auth'd identically (apiFetch). Failure is swallowed best-effort: the
45
+ strip simply stays absent until the next tick — degraded view, never an error loop.
46
+
47
+ ## 6. External surfaces
48
+ None beyond the operator's own PIN-gated dashboard. No new data is exposed: nickname,
49
+ online, lastSeen are already rendered on the Machines tab. Machine-provided strings
50
+ are escaped before DOM injection (tested). Single-machine agents: `poolMachinesView`
51
+ is populated only when the pool is enabled with 2+ machines — strict no-op, tested.
52
+
53
+ ## 7. Rollback cost
54
+ Trivial: revert the dashboard/index.html hunk (static asset; no data, no migration,
55
+ no state). Ships with the next release; rollback is a one-commit revert and re-release.
56
+
57
+ ## Second-pass review
58
+ Not required — no block/allow decisions, no session lifecycle, no
59
+ gate/sentinel/watchdog surface. (Phase-5 trigger list consulted; none match.)