instar 1.3.614 → 1.3.616
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/mandates.js +30 -9
- package/dist/commands/server.d.ts.map +1 -1
- package/dist/commands/server.js +110 -1
- package/dist/commands/server.js.map +1 -1
- package/dist/config/ConfigDefaults.d.ts.map +1 -1
- package/dist/config/ConfigDefaults.js +27 -0
- package/dist/config/ConfigDefaults.js.map +1 -1
- package/dist/coordination/AccountFollowMeMandateDelivery.d.ts +64 -0
- package/dist/coordination/AccountFollowMeMandateDelivery.d.ts.map +1 -0
- package/dist/coordination/AccountFollowMeMandateDelivery.js +92 -0
- package/dist/coordination/AccountFollowMeMandateDelivery.js.map +1 -0
- package/dist/coordination/DeliveredMandateStore.d.ts +67 -0
- package/dist/coordination/DeliveredMandateStore.d.ts.map +1 -0
- package/dist/coordination/DeliveredMandateStore.js +80 -0
- package/dist/coordination/DeliveredMandateStore.js.map +1 -0
- package/dist/core/AccountFollowMeRevocationStore.d.ts +34 -0
- package/dist/core/AccountFollowMeRevocationStore.d.ts.map +1 -0
- package/dist/core/AccountFollowMeRevocationStore.js +76 -0
- package/dist/core/AccountFollowMeRevocationStore.js.map +1 -0
- package/dist/core/AccountFollowMeSpendSlice.d.ts +197 -0
- package/dist/core/AccountFollowMeSpendSlice.d.ts.map +1 -0
- package/dist/core/AccountFollowMeSpendSlice.js +196 -0
- package/dist/core/AccountFollowMeSpendSlice.js.map +1 -0
- package/dist/core/AnthropicSubscriptionRouter.d.ts +28 -0
- package/dist/core/AnthropicSubscriptionRouter.d.ts.map +1 -1
- package/dist/core/AnthropicSubscriptionRouter.js +17 -0
- package/dist/core/AnthropicSubscriptionRouter.js.map +1 -1
- package/dist/core/MeshRpc.d.ts +37 -1
- package/dist/core/MeshRpc.d.ts.map +1 -1
- package/dist/core/MeshRpc.js +30 -0
- package/dist/core/MeshRpc.js.map +1 -1
- package/dist/core/PostUpdateMigrator.d.ts +9 -0
- package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
- package/dist/core/PostUpdateMigrator.js +30 -0
- package/dist/core/PostUpdateMigrator.js.map +1 -1
- package/dist/core/accountFollowMeCooperativeWipe.d.ts +66 -0
- package/dist/core/accountFollowMeCooperativeWipe.d.ts.map +1 -0
- package/dist/core/accountFollowMeCooperativeWipe.js +179 -0
- package/dist/core/accountFollowMeCooperativeWipe.js.map +1 -0
- package/dist/server/AgentServer.d.ts +28 -0
- package/dist/server/AgentServer.d.ts.map +1 -1
- package/dist/server/AgentServer.js +81 -1
- package/dist/server/AgentServer.js.map +1 -1
- package/dist/server/routes.d.ts +37 -0
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +135 -4
- package/dist/server/routes.js.map +1 -1
- package/package.json +1 -1
- package/src/data/builtin-manifest.json +64 -64
- package/upgrades/1.3.615.md +35 -0
- package/upgrades/1.3.616.md +34 -0
- package/upgrades/side-effects/ws52-account-follow-me-r7a-spend-slice.md +70 -0
- package/upgrades/side-effects/ws52-account-follow-me-revocation-wiring.md +104 -0
- package/upgrades/side-effects/ws52-one-dashboard-mandate-delivery.md +74 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Side-Effects Review — WS5.2 R7a: lease-sliced per-account spend ceiling
|
|
2
|
+
|
|
3
|
+
**Version / slug:** `ws52-account-follow-me-r7a-spend-slice`
|
|
4
|
+
**Date:** `2026-06-17`
|
|
5
|
+
**Author:** `Echo (instar-dev agent)`
|
|
6
|
+
**Second-pass reviewer:** `pending (HIGHEST-risk: cross-machine money/quota ceiling under partition + a new operator-mandate-gated mesh verb)`
|
|
7
|
+
|
|
8
|
+
## Summary of the change
|
|
9
|
+
|
|
10
|
+
WS5.2 R7a. A per-account spend ceiling, lease-sliced, owned by the fenced single-writer (the `FencedLease` holder), so N machines sharing one operator account can never collectively over-spend its quota — bounded by sum-of-leases, never N×ceiling, even under partition and across lease-holder failover. New module `src/core/AccountFollowMeSpendSlice.ts` (pure/injectable): `SliceIssuer` (holder-side — refuses `not-lease-holder`, stamps the current lease epoch, delegates the sum-of-leases ceiling to the existing durable `AccountFollowMeGrantLedger`); `SliceRenewalControl` (requester-side rate-cap + exponential backoff + per-(account,machine) coalescing + P19 breaker → fail-closed-to-own-account on a slow/partitioned holder); `decideAccountUse` (pure selection-time consultation — borrowed account used ONLY for a live, current-epoch, unexpired, remaining>0 slice; every uncertainty → own account). A new operator-mandate-gated `slice-renew` mesh verb in `MeshRpc.ts` (its own deny-by-default `checkCommandRBAC` case via an injected `authorizeSliceRenew` seam). `AnthropicSubscriptionRouter` gains optional `consultSlice`/`onSliceConsult` hooks invoked only on the subscription-pool selection path (no-op when unwired). Config knobs under `multiMachine.accountFollowMe.spendSlice`. Dark behind `multiMachine.accountFollowMe`; default behavior byte-identical when the hook is unwired/off.
|
|
11
|
+
|
|
12
|
+
## Decision-point inventory
|
|
13
|
+
|
|
14
|
+
- `slice-renew` mesh verb RBAC — **add** — deny-by-default; only the operator-mandate-authorized requester (via `authorizeSliceRenew`) may request a slice; absent seam → deny.
|
|
15
|
+
- `SliceIssuer.issueForRenew` — **add** — only the live `FencedLease` holder issues; ceiling enforced by the durable ledger; epoch-stamped.
|
|
16
|
+
- `decideAccountUse` — **add** — selection-time: borrowed vs own account; fail-closed-to-own on every uncertainty.
|
|
17
|
+
- `SliceRenewalControl` — **add** — the control-plane bound (rate-cap/coalesce/breaker) → O(per-account-cap) renewal RPCs.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## 1. Over-block
|
|
22
|
+
|
|
23
|
+
The conservative direction is intentional: when a slice is unknown/stale/expired/exhausted, or the holder is slow/partitioned, a machine "over-blocks" by falling back to its OWN account rather than using the borrowed one. This is the REQUIRED safe direction (never overspend a borrowed money/quota credential). It cannot wrongly deny the machine's own account (fallback is always available). No legitimate over-block of a healthy borrowed slice (a live current-epoch slice with budget is used).
|
|
24
|
+
|
|
25
|
+
## 2. Under-block
|
|
26
|
+
|
|
27
|
+
The ceiling is enforced at slice ISSUANCE (the ledger refuses `outstanding + amount > ceiling`) and at selection (a machine without budget falls back). It does NOT meter per-call token spend WITHIN a slice — a slice is a pre-allocated sub-budget; intra-slice accounting is the slice's granularity (by design — the spec's lease-slice model). A compromised holder could in principle over-issue, but the holder is the fenced single-writer (the same trust root as who-is-awake) and the ledger still caps the durable sum; a non-holder cannot issue at all.
|
|
28
|
+
|
|
29
|
+
## 3. Level-of-abstraction fit
|
|
30
|
+
|
|
31
|
+
Correct layers: issuance authority on the FencedLease holder (the existing single-writer), durable accounting in the existing GrantLedger (reused, not rebuilt), the renewal control-plane as a pure injectable state machine, the consultation as a pure function the router calls. The mesh verb sits in MeshRpc with the other commands. No logic duplicated; the ledger's sum-of-leases + epoch-fencing is reused as-is.
|
|
32
|
+
|
|
33
|
+
## 4. Signal vs authority compliance
|
|
34
|
+
|
|
35
|
+
The authority here (issue a spend slice) is held by the fenced single-writer + gated by the operator mandate (deny-by-default) — appropriate, not a brittle heuristic. The consultation (`decideAccountUse`) is a deterministic pure function (field checks on a slice), fail-closed. The breaker is a signal (consecutive transport failures → open) that only ever makes a machine MORE conservative (fall back to own account). No brittle blocking authority over user actions. Reference `docs/signal-vs-authority.md`: operator-mandate + fenced-single-writer authority, deny-by-default, is correctly-placed.
|
|
36
|
+
|
|
37
|
+
## 5. Interactions
|
|
38
|
+
|
|
39
|
+
Reuses `AccountFollowMeGrantLedger` (PR1) for the durable sum-of-leases bound + epoch-fenced consume — failover re-derivation is "for free" because `SliceIssuer` holds no in-memory slice state (reads the durable store live; a new holder over the same store sees the committed set). The `not-lease-holder` guard prevents a stale ex-holder from issuing during a handoff. The breaker distinguishes grant REFUSALS (`would-exceed-ceiling` — the holder answered; back off but don't trip) from transport FAILURES (trip the breaker) — so a healthy-but-full account doesn't open the breaker. The router hook runs only on the subscription path (never sdk-credit), so it can't perturb SDK routing.
|
|
40
|
+
|
|
41
|
+
## 6. External surfaces
|
|
42
|
+
|
|
43
|
+
One new mesh verb (`slice-renew`), deny-by-default, dark behind the flag. New config block (`spendSlice`, additive, defaulted). No new HTTP route. When `multiMachine.accountFollowMe` is off (or the router hook unwired — the default), behavior is byte-identical: the consult hook is never invoked. Other agents see the new verb only if they're mandated peers in a follow-me pool.
|
|
44
|
+
|
|
45
|
+
## 7. Multi-machine posture (Cross-Machine Coherence)
|
|
46
|
+
|
|
47
|
+
This IS a multi-machine coherence mechanism by construction. The single source of truth is the fenced-lease holder + the durable grant-ledger; slices are epoch-stamped so a failover voids stale-epoch slices and the new holder re-derives the outstanding set from the durable store before issuing — the sum-of-leases bound holds across the handoff with no double-allocation. Under partition, a VM that can't reach the holder fails closed to its own account (bounded, never unbounded overshoot). The renewal control plane is O(per-account-cap), not O(N), so it can't storm the holder. A single-machine agent is a no-op (no peers, the flag is off).
|
|
48
|
+
|
|
49
|
+
## 8. Rollback cost
|
|
50
|
+
|
|
51
|
+
Low. Entirely dark behind `multiMachine.accountFollowMe` + the unwired router hook → no live effect by default. New module + additive config + one mesh verb. Revert is a single-commit back-out; no migration, no persisted-schema change (the slice records live in the existing grant-ledger store, which already shipped in PR1).
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## Scope note (honest, not a hidden deferral)
|
|
56
|
+
|
|
57
|
+
R7a delivers the spend-ceiling MECHANISM end-to-end: the fenced-single-writer slice issuer (ledger-backed sum-of-leases ceiling + epoch-fenced failover re-derivation), the requester-side renewal control plane (rate-cap + coalescing + P19 breaker → O(per-account-cap)), the operator-mandate-gated `slice-renew` mesh verb, the pure `decideAccountUse` consultation (fail-closed-to-own on every uncertainty), AND the router hook that invokes it. The router currently SURFACES the decision to observability (`onSliceConsult`); the pool's account-selection actually CHOOSING own-vs-borrowed based on that decision is the enforcement integration that belongs to — and requires — the borrowed-account-in-pool concept, which is not yet live (enrollment adds accounts as normal local accounts; nothing is marked `isBorrowedAccount` yet). So the consultation is inert today by construction (every account → `own`), meaning there is NO reachable over-spend path that this layer leaves unguarded. This is the correct decomposition for a dark mechanism, not a deferral of reachable behavior; the enforcement wiring lands with the borrowed-account selection layer.
|
|
58
|
+
|
|
59
|
+
## Second-pass review
|
|
60
|
+
|
|
61
|
+
**Concur with the review** (security substance) — the independent reviewer verified all 7 points directly:
|
|
62
|
+
1. **Sum-of-leases bound holds** — `SliceIssuer.issueForRenew` delegates to the ledger's `issue()` (re-reads outstanding from the durable store, refuses `outstanding + amount > ceiling`); `SliceIssuer` holds ZERO in-memory accounting state; the `GrantStore` is synchronous so no interleave past the ceiling (structural invariant: keep the store synchronous). `outstandingFor` conservatively over-counts (safe direction).
|
|
63
|
+
2. **Failover / no double-allocation** — `holdsLease()` blocks a stale ex-holder; the new holder re-derives from the same durable store; epoch-stamped slices are void on stale epoch. Tested.
|
|
64
|
+
3. **Fail-closed-to-own on every uncertainty** — `decideAccountUse` returns `own` for flag-off / not-borrowed / no-slice / stale-epoch / expired / `!(remaining>0)` (0, negative, NaN). `borrowed` only via a live current-epoch unexpired positive-remaining slice.
|
|
65
|
+
4. **slice-renew RBAC deny-by-default** — own `checkCommandRBAC` case; `authorizeSliceRenew?.(...) ?? false` → absent seam OR false → 403; not the any-peer read class.
|
|
66
|
+
5. **Control plane O(per-account-cap)** — coalescing + rate-cap + exponential backoff + P19 breaker on transport `failed` only; grant `refused` (would-exceed-ceiling) backs off but does NOT trip the breaker.
|
|
67
|
+
6. **Dark by default / byte-identical when off** — `consultSlice` no-ops when unwired; subscription-path only, never sdk-credit. (Observe-only in this layer — see Scope note above.)
|
|
68
|
+
7. **No fail-open** — no catch/`||true`/default-allow; every positive outcome follows explicit guards.
|
|
69
|
+
|
|
70
|
+
The reviewer's one CONCERN was the dark-gate lint golden-map line shift from the new `spendSlice` config block — **FIXED**: the 7 shifted entries updated to the attributor's values (924/949/978/1147/1269/1314/1339); `lint-dev-agent-dark-gate.test.ts` now 24/24 green. tsc EXIT=0; the 3 R7a suites 59 passed.
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Side-Effects Review — WS5.2 Account Follow-Me, R12 revocation data-plane WIRING
|
|
2
|
+
|
|
3
|
+
**Version / slug:** `ws52-account-follow-me-revocation-wiring`
|
|
4
|
+
**Date:** `2026-06-17`
|
|
5
|
+
**Author:** Echo (autonomous)
|
|
6
|
+
**Second-pass reviewer:** pending (high-risk: credentials, destructive local wipe, revocation honesty)
|
|
7
|
+
**Spec:** `docs/specs/ws52-account-follow-me-security.md` — R12 (revocation), OQ6 (per-server model), gap 9 (offline give-up deadline)
|
|
8
|
+
**Status:** wiring built + tested (46 tests across unit/wiring/Tier-2; tsc clean). This PR makes the already-merged PURE executor (`AccountFollowMeRevocation`) FUNCTIONAL by constructing it in the server with real deps, firing it from `/mandate/:id/revoke`, scheduling its deadline sweep, and adding the config default + migration.
|
|
9
|
+
|
|
10
|
+
## Summary of the change
|
|
11
|
+
|
|
12
|
+
The pure executor (`src/core/AccountFollowMeRevocation.ts`) was merged but nothing called it. This PR is the server-shell wiring, implemented per the spec's PER-SERVER model (OQ6): the operator revokes the `account-follow-me` mandate on the TARGET machine's OWN dashboard, so the target runs its OWN local revocation — LOCAL, never a cross-machine wipe-instruction.
|
|
13
|
+
|
|
14
|
+
Files added (logic + tests, dark behind `multiMachine.accountFollowMe`):
|
|
15
|
+
- `src/core/accountFollowMeCooperativeWipe.ts` — `buildCooperativeWipe(deps)` builds the REAL synchronous three-step local wipe (framework logout against the account's `CLAUDE_CONFIG_DIR` + delete the per-account config-home dir via SafeFsExecutor + `SubscriptionPool.remove`), fail-closed per step. Side-effecting primitives (logout, slot delete) are injected seams so it is unit-testable without spawning a CLI.
|
|
16
|
+
- `src/core/AccountFollowMeRevocationStore.ts` — `DurablePendingWipeStore`, a crash-safe JSON ledger under the state dir (SafeFsExecutor atomic write) implementing the executor's `PendingWipeStore` seam. NOT in-memory.
|
|
17
|
+
- `tests/unit/account-followme-revocation-wiring.test.ts` (10) — cooperative-wipe + durable store + composed end-to-end.
|
|
18
|
+
- `tests/unit/account-followme-revocation-server-wiring.test.ts` (17) — source-touchpoint integrity + migration unit.
|
|
19
|
+
- `tests/integration/account-followme-revocation-route.test.ts` (4) — Tier-2 route trigger + dark no-op.
|
|
20
|
+
|
|
21
|
+
Files modified (shared/sensitive — flagged for careful review):
|
|
22
|
+
- `src/commands/server.ts` — construct `AccountFollowMeRevocation` with real deps; schedule the deadline sweep timer (unref'd); thread into `AgentServer`.
|
|
23
|
+
- `src/server/routes.ts` — RouteContext member; `/mandate/:id/revoke` detects an `account-follow-me` authority and fires `revoke(...)` with `cooperative-online` posture; surfaces the honest outcome on the response.
|
|
24
|
+
- `src/server/AgentServer.ts` — options member + RouteContext threading.
|
|
25
|
+
- `src/config/ConfigDefaults.ts` — `multiMachine.accountFollowMe.revocationReconnectDeadlineMs` default (6h).
|
|
26
|
+
- `src/core/PostUpdateMigrator.ts` — `migrateConfigAccountFollowMeRevocationDeadline` (existence-checked, idempotent) + dispatch.
|
|
27
|
+
|
|
28
|
+
## Decision-point inventory
|
|
29
|
+
|
|
30
|
+
- `/mandate/:id/revoke` route — **modified** — after the existing PIN-gated control-plane revoke succeeds, if the revoked mandate carried an `account-follow-me` authority, fire the LOCAL data-plane wipe. Non-account-follow-me mandates are byte-for-byte unaffected.
|
|
31
|
+
- `buildCooperativeWipe(...)` — **add** — the real wipe; deny-by-default for "done" (every step fail-closed to false on throw).
|
|
32
|
+
- deadline sweep timer (server.ts) — **add** — drives `sweepDeadlines()` on a 5-min cadence; strict no-op while dark.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## 1. Over-block
|
|
37
|
+
|
|
38
|
+
Not a block/allow gate. The closest surface is the cooperative-wipe fail-closed path: a partial/throwing wipe is reported `revocation-pending` (never `removed`), which can later escalate to `revocation-failed` ("rotate at provider") for a wipe that partially succeeded. SAFE direction — the worst outcome is the operator is told to rotate when they technically needn't have. We never claim `removed` we can't fully confirm.
|
|
39
|
+
|
|
40
|
+
## 2. Under-block
|
|
41
|
+
|
|
42
|
+
The per-server trigger only runs the LOCAL wipe (`cooperative-online` posture) — by design (OQ6). A de-paired/offline holder is reached on the cross-machine path, which the per-server model does not use; this is the honest scope. The remaining real-world gap is operator inaction after a `revocation-failed` item — outside any code's reach, which is why the item is HIGH priority.
|
|
43
|
+
|
|
44
|
+
## 3. Level-of-abstraction fit
|
|
45
|
+
|
|
46
|
+
Correct layer. The route is the trigger point (mirrors how PR2's scan route / EnrollmentWizard were wired); the wipe + durable store are injectable-deps modules (mirrors the executor's own style). The executor owns NO authority — the control-plane revoke stays with the PIN-gated MandateGate; this wiring consumes that decision and runs the side effects. `SubscriptionPool.remove` and the framework logout are reused, not re-implemented.
|
|
47
|
+
|
|
48
|
+
## 4. Signal vs authority compliance
|
|
49
|
+
|
|
50
|
+
**Required reference:** [docs/signal-vs-authority.md](../../docs/signal-vs-authority.md)
|
|
51
|
+
|
|
52
|
+
- [x] No NEW block/allow authority. The wiring runs the data-plane effect of a decision the existing PIN-gated MandateGate already made; the only gate honored is the executor's own `enabled()` dark-flag (no-op when off). The route's existing `checkMandatePin` authority is unchanged.
|
|
53
|
+
|
|
54
|
+
Every uncertain path FAILS CLOSED toward "rotate at provider", never toward a false "removed". The trigger is wrapped so a data-plane error can never break the control-plane revoke that already succeeded.
|
|
55
|
+
|
|
56
|
+
## 5. Interactions
|
|
57
|
+
|
|
58
|
+
- **Shadowing:** none — first consumer of the executor.
|
|
59
|
+
- **Double-fire:** the durable store is keyed `${accountId}::${targetMachineId}`; a re-revoke or reconnect+sweep race cannot double-escalate the same pair (sweep removes on escalation; success removes on `removed`). Re-running `revoke` for the same pair upserts, never duplicates.
|
|
60
|
+
- **Races:** reconnect vs sweep — whichever runs first wins, both orderings honest (covered by the executor's merged tests). The new durable store preserves the in-memory upsert/remove semantics.
|
|
61
|
+
- **Feedback loops:** none — the sweep only removes records + emits attention items.
|
|
62
|
+
|
|
63
|
+
## 6. External surfaces
|
|
64
|
+
|
|
65
|
+
- **Other agents / mesh:** none — per the per-server model this trigger has NO mesh egress. It runs the framework logout + slot delete on the LOCAL target only.
|
|
66
|
+
- **Persistent state:** a new durable pending-wipe ledger (`account-follow-me-revocation-pending.json` under the state dir). Records are small and credential-free (account id, machine id, mandate id, provider name, operator email, nickname, two timestamps). PII note: operator email + machine nickname land at-rest, same posture as the §6.1a meta projection (email "never a secret"); no credential field exists in the shape. **Destructive local effect:** the wipe deletes the per-account `CLAUDE_CONFIG_DIR` (via SafeFsExecutor — audited, source-tree-guarded) and the keychain credential entry. This is the intended revocation effect and runs ONLY for a revoked account-follow-me mandate while the feature is enabled.
|
|
67
|
+
- **Operator surface (Mobile-Complete):** the control-plane revoke is the existing PIN-gated Mandates-tab control (already phone-complete). The new operator-facing output is the HIGH `RevocationFailedAttention` item via the existing attention-queue path. No new operator UI primitive; no API-only operator action.
|
|
68
|
+
- **External systems:** the honest end-state points the operator at the provider; it calls no provider API.
|
|
69
|
+
|
|
70
|
+
## 6b. Operator-surface quality
|
|
71
|
+
|
|
72
|
+
No operator-surface markup file (`dashboard/*.js`, `*.html`, approval/grant/secret-drop form) is touched by THIS PR. The dashboard `revocation-pending` / `revocation-failed` render is a tracked follow-on increment and will carry its own 6b review. Not applicable to this commit.
|
|
73
|
+
|
|
74
|
+
## 7. Multi-machine posture
|
|
75
|
+
|
|
76
|
+
Per-server: each machine revokes its OWN local account when its operator revokes the mandate on its OWN dashboard. Single-machine / flag-off is a strict no-op — the route trigger does nothing for a non-follow-me mandate and the sweep returns []. No cross-machine transport is introduced (and none was required — the per-server model is cleanly local). See "Could NOT wire" below for what the per-server model deliberately does NOT cover.
|
|
77
|
+
|
|
78
|
+
## 8. Rollback cost
|
|
79
|
+
|
|
80
|
+
Low. The feature is dark behind `multiMachine.accountFollowMe` (live-on-dev / dark-fleet). Disabling the flag makes `revoke()` a no-op and the sweep inert immediately (read live, no restart). The durable ledger is a single small JSON file; deleting it loses only pending-wipe bookkeeping (honest end-state then defaults to provider-rotation). The route trigger is additive and wrapped — reverting it restores the prior revoke behavior exactly. No schema migration beyond an existence-checked config default.
|
|
81
|
+
|
|
82
|
+
## What could NOT be fully wired (honest)
|
|
83
|
+
|
|
84
|
+
- **Offline / de-paired / hostile posture from the route trigger.** In the per-server model the operator revokes on the TARGET's own dashboard, so the target is online & cooperative by construction; the route always passes `cooperative-online`. The executor's `offline` / `revoked` branches (durable pending, provider-rotation instruction) are fully built and unit-tested but are reached by the CROSS-MACHINE revoke path, which does not exist (and is not part of the per-server model). They remain dormant-but-correct — a future cross-machine revoke increment would supply the computed posture. This is the clean-local boundary the prompt called for: I wired what IS local and did not stub the cross-machine path.
|
|
85
|
+
- **`onTargetReconnect` trigger.** The reconnect-fires-pending-wipe hook is built + tested in the executor but has no caller in the per-server model (a reconnecting machine revokes its own local account on its own dashboard, not via a peer's pending record). Left unwired deliberately — it belongs to the cross-machine increment.
|
|
86
|
+
- **Dashboard `revocation-pending` / `revocation-failed` render** — tracked follow-on (6b above).
|
|
87
|
+
|
|
88
|
+
## Second-pass review
|
|
89
|
+
|
|
90
|
+
**Independent reviewer (2026-06-17) — found ONE BLOCKING issue; FIXED + re-reviewed → CONCUR.**
|
|
91
|
+
|
|
92
|
+
The reviewer confirmed: `removed` is reachable only after a fully-successful local wipe; the trigger fires only for an `account-follow-me` mandate and a wipe error never breaks the already-succeeded control-plane revoke; dark = strict no-op (`enabled()` read live, not cached); the durable store holds no token material; the sweep timer is gated + unref'd + try/caught; the migration is idempotent + existence-checked.
|
|
93
|
+
|
|
94
|
+
**BLOCKING finding (now fixed):** `defaultDeleteSlot` did a `recursive,force` rm of the operator-provided `configHome` with NO guard — and `~/.claude` (the operator's PRIMARY/shared login) is a legitimate `configHome` value, so an `account-follow-me` revoke targeting the default account would have recursively deleted the operator's main login directory (worst case: `$HOME`/root). **Fix:** added an exported `isProtectedConfigHome()` guard that refuses (→ `slotDeleted:false` → durable pending, never a false `removed`) any deletion target resolving to a filesystem root, `$HOME` itself, an ANCESTOR of `$HOME`, or a framework DEFAULT config home (`~/.claude`/`.codex`/`.gemini`/`.pi`/`.config`), or an empty/whitespace path (which would otherwise `path.resolve('')`→cwd). The guard runs BEFORE any `safeRmSync`, is case-folded (macOS/Windows case-insensitive volumes: `~/.CLAUDE` ≡ `~/.claude`), and traversal-safe (`~/.claude/../.claude`, trailing slashes collapse to the canonical protected path). A re-review of the fix found NO bypass across an adversarial spelling battery and CONCURRED. A genuine per-account slot (`~/.claude-adriana`, `~/.instar/accounts/<id>`, tmpdir paths) is NOT matched and deletes normally. Covered by 3 new both-sides tests in `account-followme-revocation-wiring.test.ts`.
|
|
95
|
+
|
|
96
|
+
## Evidence pointers
|
|
97
|
+
|
|
98
|
+
- `npx tsc --noEmit` clean.
|
|
99
|
+
- 46 tests green: `account-followme-revocation-wiring` (10), `account-followme-revocation-server-wiring` (17), `account-followme-revocation-route` (4 — Tier-2 + dark no-op), plus the merged executor's 15. The existing `ws52-account-follow-me-wiring` (12) + `mandate-routes` (15) still pass (no regression).
|
|
100
|
+
- Tier-2 live-pipeline evidence: revoking an account-follow-me mandate over the real route returns `accountFollowMeRevocation.state === 'removed'` AND `pool.get('acct-x') === null` (the real data-plane effect); a non-follow-me revoke carries no revocation payload and leaves the account intact; flag-off returns `reason: 'feature-disabled'` with the account untouched.
|
|
101
|
+
|
|
102
|
+
## CI-green follow-up (same PR)
|
|
103
|
+
|
|
104
|
+
The `cooperativeWipe` fail-closed catches are tagged `@silent-fallback-ok` (each surfaces its failure through the function's return value → the executor keeps a durable pending, never a false `removed`), keeping the `no-silent-fallbacks` ratchet at baseline 476. The `revocationReconnectDeadlineMs` ConfigDefaults insertion shifted the `lint-dev-agent-dark-gate` hand-authored golden map by +4 lines for every `enabled:` entry below it (7 entries); the map was updated by hand to match. No behavior change — comment tags + a test line-number map.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Side-Effects Review — WS5.2 one-dashboard cross-machine mandate delivery
|
|
2
|
+
|
|
3
|
+
**Version / slug:** `ws52-one-dashboard-mandate-delivery`
|
|
4
|
+
**Date:** `2026-06-17`
|
|
5
|
+
**Author:** `Echo (instar-dev agent)`
|
|
6
|
+
**Second-pass reviewer:** `concurred (HIGH-stakes: cross-machine authorization delivery)`
|
|
7
|
+
|
|
8
|
+
## Summary of the change
|
|
9
|
+
|
|
10
|
+
Operator feedback (topic 13481): "there should only be ONE dashboard for the agent, not one per machine." Today an account-follow-me enrollment on a TARGET machine needs a PIN-gated mandate ISSUED on that target's OWN dashboard — the per-machine friction the operator rejects. This change lets the operator's SINGLE dashboard issue + deliver the mandate to the target, verified there via the already-approved ws52 R4a bridge. New `POST /mandate/issue-for-machine` (PIN-gated, same auth as `/mandate/issue`): issues the mandate locally, then for a REMOTE target packages it (`packageMandateForDelivery`, signed with this operator machine's Ed25519 identity key) and dispatches it over a new `account-follow-me-mandate-deliver` mesh verb. The target's handler authenticates the mesh `sender` (existing Ed25519 envelope verification → the registered operator-machine identity), resolves that sender's REGISTERED key, R4a-verifies the mandate's issuance signature against it (`acceptMandateDelivery` → `acceptDeliveredMandate`, with `expectedIssuer === authenticated sender`), checks exact bounds (account-follow-me / this machine / re-mint), and only then persists it to a new `DeliveredMandateStore`. The enroll-start route (`POST /subscription-pool/follow-me/enroll/start`) now, when the LOCAL gate denies, ALSO consults the delivered mandate — re-verifying the R4a signature at point-of-use and re-checking bounds — both paths fail-closed (403). Files: `AccountFollowMeMandateDelivery.ts` + `DeliveredMandateStore.ts` (new), `MeshRpc.ts`, `routes.ts`, `AgentServer.ts`, `commands/server.ts`, `dashboard/mandates.js`. Dark behind `multiMachine.accountFollowMe`.
|
|
11
|
+
|
|
12
|
+
## Decision-point inventory
|
|
13
|
+
|
|
14
|
+
- `account-follow-me-mandate-deliver` mesh verb handler / `acceptMandateDelivery` — **add** — the cross-machine authorization-acceptance decision: a delivered mandate is trusted ONLY via R4a verification against the authenticated sender's registered key + exact bounds. Deny-by-default.
|
|
15
|
+
- `POST /mandate/issue-for-machine` — **add** — PIN-gated issuance + remote delivery (agent cannot self-issue).
|
|
16
|
+
- enroll-start delivered-mandate consultation — **modify** — an ADDITIONAL fail-closed authorization source alongside the unchanged local gate.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## 1. Over-block
|
|
21
|
+
Conservative by design: any uncertainty (no registered operator key for the sender, signature fails, bounds mismatch, target≠this machine, feature off) denies. The operator's own PIN-gated issuance is unchanged, so the operator is never wrongly blocked from authorizing; a delivery that can't be verified simply isn't honored (the operator retries / the honest 502 surfaces). No legitimate authorized enrollment is rejected.
|
|
22
|
+
|
|
23
|
+
## 2. Under-block
|
|
24
|
+
The delivered mandate authorizes ONLY its exact bounds (account-follow-me, specific accountId, this targetMachineId, re-mint). It cannot be replayed for another account/machine. It does not expand what a mandate authorizes — it only changes WHERE the operator can issue it from (one dashboard) and HOW it reaches the target (signed mesh delivery). The downstream S7 email-gate + the enrollment flow are unchanged.
|
|
25
|
+
|
|
26
|
+
## 3. Level-of-abstraction fit
|
|
27
|
+
Correct: issuance stays in the PIN-gated mandate route; delivery rides the existing authenticated `meshRpcDispatcher` (same pipe as remote-close); acceptance uses the already-approved ws52 R4a bridge (`acceptDeliveredMandate`); the core `MandateGate` authorship model is UNTOUCHED (delivered mandates use the R4a path, scoped to the account-follow-me enroll-start consumer — not a change to how every mandate is trusted). This is the minimal-blast-radius placement.
|
|
28
|
+
|
|
29
|
+
## 4. Signal vs authority compliance
|
|
30
|
+
The authority is the operator's PIN-gated mandate (unchanged) + the R4a cryptographic binding to the registered operator machine. The agent cannot self-issue (bearer token insufficient — PIN required). The mesh verb's real gate is the signature re-verification, not a coarse role check. Deterministic + fail-closed; reference `docs/signal-vs-authority.md`.
|
|
31
|
+
|
|
32
|
+
## 5. Interactions
|
|
33
|
+
The enroll-start consultation is ADDITIVE — the existing local-mandate path is byte-unchanged; the delivered path is reached only when the local gate denies, and both deny-by-default. `verifyDeliveredMandate` re-verifies at point-of-use (never trusts a stored flag). No change to the core MandateGate, so no other mandate consumer is affected. The delivery RPC failure surfaces as an honest 502 (retryable), never a silent stuck issuance.
|
|
34
|
+
|
|
35
|
+
## 6. External surfaces
|
|
36
|
+
One new PIN-gated route + one new mesh verb (deny-by-default, signature-gated) + a dashboard change routing account-follow-me issuance through the new route. All dark behind `multiMachine.accountFollowMe` (503/refuse when off). The mesh verb is only honored from an authenticated registered peer whose R4a signature verifies — no new attack surface for an unauthenticated party.
|
|
37
|
+
|
|
38
|
+
## 7. Multi-machine posture (Cross-Machine Coherence)
|
|
39
|
+
This change EXISTS to fix a cross-machine coherence defect: the per-machine dashboard/mandate seam. It is replicated-by-delivery: the operator's single machine is the issuance authority; the signed mandate is delivered to + verified on the target; the target holds it locally (in DeliveredMandateStore) for its own enroll-start. Trust is grounded in the registered MachineIdentity store (Know Your Principal — the authenticated mesh sender, never a payload name). Single-machine agents never deliver (local target path unchanged) — no-op.
|
|
40
|
+
|
|
41
|
+
## 8. Rollback cost
|
|
42
|
+
Low. Dark by default; new files + additive route/verb; the core MandateGate is untouched so reverting cannot destabilize existing mandate behavior. Single-commit back-out; no migration (DeliveredMandateStore is new + only populated when the feature is live).
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## 6b. Operator-Surface Quality (dashboard change)
|
|
47
|
+
|
|
48
|
+
The change touches `dashboard/mandates.js` (an operator surface), so per the Operator-Surface Quality standard:
|
|
49
|
+
|
|
50
|
+
1. **Right action surfaced prominently / clear next step:** The change makes the EXISTING mandate issue form work for a remote target — when the operator approves an `account-follow-me` authority, it routes through `/mandate/issue-for-machine` and shows a plain-language outcome ("delivered to <machine>" / "issued locally" / a retryable "couldn't reach <machine>" on a 502). The operator's action (approve with PIN) is unchanged and primary; the delivery is automatic. This REMOVES a confusing dead-end (the old "open that machine's dashboard to approve" note) — the exact operator-surface defect this change fixes.
|
|
51
|
+
2. **No internals as primary content:** The operator sees machine NICKNAMES and a plain outcome sentence, not fingerprints/mesh internals (those remain in the support/reference line only). The cross-machine delivery + R4a verification happen invisibly.
|
|
52
|
+
3. **Destructive actions de-emphasized:** This surface issues an authorization (not a destructive action); revoke remains the separate, clearly-labeled control. Nothing destructive is added or made prominent.
|
|
53
|
+
4. **Plain language at phone width:** The new notices are short plain sentences ("Approved — delivered to Mac Mini"), not JSON or jargon, and reuse the existing mobile-responsive mandate-card layout. No new wide/desktop-only element.
|
|
54
|
+
|
|
55
|
+
(Note: a dedicated pre-filled follow-me picker reading the deep-link `?account=&target=&mechanism=` params is a nice-to-have not built here; the functional path works via the existing form. Tracked as a polish follow-up <!-- tracked: topic-13481 -->.)
|
|
56
|
+
|
|
57
|
+
## Agent Proposes, Operator Approves
|
|
58
|
+
|
|
59
|
+
The operator approves a SERVER-AUTHORED request built from STRUCTURED DATA — never agent-supplied free-text:
|
|
60
|
+
- The mandate's authority is a structured `CoordinationMandate` (bounds: `accountId`, `targetMachineId`, `mechanism:'re-mint'`, `action:'account-follow-me'`, `expiresAt`) constructed server-side by `MandateStore.issue()` from typed fields. The operator approves it with their dashboard PIN (`checkMandatePin`) — the agent's bearer token cannot issue/widen it (requester ≠ authorizer preserved).
|
|
61
|
+
- The cross-machine delivery carries that SAME canonical structured mandate; the R4a signature is over the server-canonicalized bytes (`canonicalMandate`), and the target re-derives the bounds from the structured mandate (`readFollowMeBounds`) — it never parses or trusts agent/operator free-text. A name in the payload is never authority (Know Your Principal); only the structured bounds + the R4a signature against the registered operator key are.
|
|
62
|
+
- The dashboard surfaces the structured authority (account + target machine nickname + the fixed action slug) for approval; it does not let the agent inject free-text that becomes the approved authority. The operator approves a typed, server-authored grant, full stop.
|
|
63
|
+
|
|
64
|
+
## Second-pass review
|
|
65
|
+
|
|
66
|
+
**Concur with the review.** Independent audit verified all 8 points against the actual source (tsc EXIT=0; 47/47 tests):
|
|
67
|
+
1. **Trust anchor = authenticated sender, never payload** — handler resolves `operatorPem = meshIdMgr.getSigningPublicKeyPem(sender)` (same registered-identity source `verifyEnvelope` uses); `acceptMandateDelivery` passes `expectedOperatorMachineFingerprint: sender`; `CrossMachineMandate` rejects `issuerFingerprint !== expectedIssuer`. No payload field is ever the anchor.
|
|
68
|
+
2. **R4a load-bearing + fail-closed** — no operator key → refuse; sig fail → refuse; issuer≠sender → refuse; `store.put` strictly after all checks. Unverified mandate never persisted/honored.
|
|
69
|
+
3. **Exact-bounds, no replay** — double-gated (accept-time + enroll-start point-of-use): accountId + targetMachineId===this-machine + mechanism=re-mint. A/X cannot be replayed for B/Y.
|
|
70
|
+
4. **enroll-start additive + fail-closed** — local path unchanged; delivered path reached only on local-deny; `verifyDeliveredMandate` re-verifies R4a (not a stored flag); no match → 403.
|
|
71
|
+
5. **issue-for-machine PIN-gated** — `checkMandatePin` same as `/mandate/issue`; bearer alone insufficient; local-target unchanged.
|
|
72
|
+
6. **Mesh RBAC deny-by-default** — own case; `authorizeMandateDeliver ?? false`; real authority = signature re-verify (rogue registered peer still can't deliver an unsigned mandate).
|
|
73
|
+
7. **Dark by default** — route 503, verb refused, accept `feature-disabled` when off; single-machine no-op.
|
|
74
|
+
8. **No fail-open** — terminal `accepted:true` only after all refusals; the only `@silent-fallback-ok` catches fail toward denial (`ok:false` / `[]`).
|