instar 1.3.93 → 1.3.95

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.
Files changed (39) hide show
  1. package/dist/commands/machine.d.ts.map +1 -1
  2. package/dist/commands/machine.js +23 -1
  3. package/dist/commands/machine.js.map +1 -1
  4. package/dist/commands/server.d.ts.map +1 -1
  5. package/dist/commands/server.js +16 -0
  6. package/dist/commands/server.js.map +1 -1
  7. package/dist/core/MachineIdentity.d.ts +2 -0
  8. package/dist/core/MachineIdentity.d.ts.map +1 -1
  9. package/dist/core/MachineIdentity.js +4 -0
  10. package/dist/core/MachineIdentity.js.map +1 -1
  11. package/dist/core/MeshUrlAdvertiser.d.ts +50 -0
  12. package/dist/core/MeshUrlAdvertiser.d.ts.map +1 -0
  13. package/dist/core/MeshUrlAdvertiser.js +64 -0
  14. package/dist/core/MeshUrlAdvertiser.js.map +1 -0
  15. package/dist/core/PairingSessionStore.d.ts +12 -0
  16. package/dist/core/PairingSessionStore.d.ts.map +1 -0
  17. package/dist/core/PairingSessionStore.js +54 -0
  18. package/dist/core/PairingSessionStore.js.map +1 -0
  19. package/dist/monitoring/AttributionResolver.d.ts +18 -0
  20. package/dist/monitoring/AttributionResolver.d.ts.map +1 -1
  21. package/dist/monitoring/AttributionResolver.js +18 -0
  22. package/dist/monitoring/AttributionResolver.js.map +1 -1
  23. package/dist/monitoring/BurnDetector.d.ts.map +1 -1
  24. package/dist/monitoring/BurnDetector.js +17 -1
  25. package/dist/monitoring/BurnDetector.js.map +1 -1
  26. package/dist/monitoring/TokenLedger.d.ts +26 -0
  27. package/dist/monitoring/TokenLedger.d.ts.map +1 -1
  28. package/dist/monitoring/TokenLedger.js +121 -4
  29. package/dist/monitoring/TokenLedger.js.map +1 -1
  30. package/dist/server/machineRoutes.d.ts.map +1 -1
  31. package/dist/server/machineRoutes.js +64 -9
  32. package/dist/server/machineRoutes.js.map +1 -1
  33. package/package.json +1 -1
  34. package/src/data/builtin-manifest.json +3 -3
  35. package/upgrades/1.3.94.md +38 -0
  36. package/upgrades/1.3.95.md +39 -0
  37. package/upgrades/side-effects/burn-attribution-wiring.md +103 -0
  38. package/upgrades/side-effects/mesh-url-advertisement.md +66 -0
  39. package/upgrades/side-effects/noninteractive-pool-join.md +85 -0
@@ -0,0 +1,103 @@
1
+ # Side-Effects Review — wire AttributionResolver into ingest + exempt the pre-attribution sentinel
2
+
3
+ ## What changed
4
+
5
+ Completes **Phase 2** of `docs/specs/token-burn-detection-and-self-heal.md`, which
6
+ was specced but never wired. Three source files + their tests:
7
+
8
+ 1. **`src/monitoring/AttributionResolver.ts`** — exports a new
9
+ `PRE_ATTRIBUTION_KEY = 'unknown::pre-attribution'` constant: the sentinel
10
+ meaning "the resolver has never run on this event" (the column default and
11
+ the historical hardcoded ingest value). `resolveAttribution()` itself is
12
+ unchanged and never returns this sentinel.
13
+
14
+ 2. **`src/monitoring/TokenLedger.ts`**
15
+ - `ingestLine()` now calls `resolveAttribution({sessionId, projectPath: cwd,
16
+ prompt: null, model})` instead of hardcoding `'unknown::pre-attribution'`.
17
+ The Claude-CLI JSONL assistant line carries no user prompt, so this is
18
+ cwd/job/hook/session-level attribution; prompt-shape attribution remains a
19
+ separate follow-up (needs user+assistant line correlation at ingest).
20
+ - New `ledger_meta` key/value table + `getMeta`/`setMeta` helpers.
21
+ - New `backfillAttributionOnce()` — one-shot, idempotent, marker-guarded
22
+ (`attribution-backfill-v1`) re-resolution of legacy sentinel rows, run
23
+ non-fatally from the constructor after `prepareStatements()`.
24
+
25
+ 3. **`src/monitoring/BurnDetector.ts`** — `tick()` exempts the
26
+ `PRE_ATTRIBUTION_KEY` sentinel from the **absolute-share** trigger only. A
27
+ bucket at 100% under the sentinel is a coverage gap, not a burn. The
28
+ genuinely-residual `unknown::<sessionId>` key (resolver ran, no match) is
29
+ NOT exempt and still alerts (the spec's "alert on unattributable spend").
30
+ Baseline-divergence is unaffected.
31
+
32
+ ## Why
33
+
34
+ The recurring `unknown::pre-attribution consumed 100.0% of 24h spend
35
+ (threshold 25%)` alert was a **fleet-wide false positive**. Because the
36
+ resolver was never wired, every JSONL-sourced event defaulted to the single
37
+ sentinel bucket, whose share was always 100%, so the absolute-share trigger
38
+ fired every hour forever — on every agent with burn-detection enabled (default)
39
+ and any daily spend. Real spend on the reporting agent was ~1.5M tokens/24h
40
+ (the bleed this system targets was ~3B/day).
41
+
42
+ ## Behavioral side effects
43
+
44
+ - **BurnDetector no longer emits absolute-share on the sentinel.** Intended.
45
+ This is the false-positive fix. Real burns are unaffected: (a) any resolved
46
+ key (component/job/hook/`unknown::<sid>`) still trips at >25%; (b) the
47
+ sentinel only exists transiently until backfill clears it; (c) a real burner
48
+ sharing the 24h window with sentinel rows still trips (the sentinel is
49
+ skipped, not counted against the burner's share — verified by test
50
+ "does not let the sentinel mask a real burner sharing the window").
51
+ - **Existing attribution distribution changes shape.** Instead of one 100%
52
+ bucket, spend now splits per origin (mostly `unknown::<sessionId>` for the
53
+ dominant Claude-CLI path, plus `user-job:*` / `user-hook:*`). The
54
+ `/tokens/*` summary/sessions/by-project routes are unchanged in schema; only
55
+ the `byAttributionKey` grouping is now meaningful. No route signature changed.
56
+ - **One-time backfill UPDATE on first boot after upgrade.** On the reporting
57
+ agent that's ~384k rows across a few hundred distinct (session, project,
58
+ model) triples — resolved once per triple inside a single transaction, so a
59
+ handful of bulk UPDATEs, not 384k. Marker-guarded → runs exactly once per DB.
60
+ Wrapped in try/catch in the constructor: a backfill failure logs and is
61
+ swallowed (worst case: sentinel rows stay unattributed and thus exempt from
62
+ burn alerts — i.e. degrades to the old-but-safe behavior, never a crash).
63
+
64
+ ## Migration parity
65
+
66
+ No `PostUpdateMigrator` change required. The backfill self-heals on the
67
+ constructor's next run, which happens on every server start. Existing agents
68
+ get it on their next restart onto a version carrying this change. The new
69
+ `ledger_meta` table is created idempotently in the SCHEMA list alongside the
70
+ existing tables.
71
+
72
+ ## Blast radius / isolation
73
+
74
+ - Scope is the three monitoring files. **No HTTP route, server-boot, or
75
+ CapabilityIndex change** → no agent-awareness/CLAUDE.md-template update owed.
76
+ - Codex token sessions live in a separate table (`codex_token_sessions`) and
77
+ are untouched — burn detection reads only `token_events`.
78
+ - Read-only-observability invariant of the TokenLedger is preserved: the only
79
+ new write is to the ledger's own SQLite DB (the backfill UPDATE + the meta
80
+ marker). No source JSONL file is touched.
81
+
82
+ ## Rollback
83
+
84
+ Revert the three `src/monitoring/*` files. Backfilled `attribution_key` values
85
+ are harmless if the revert lands (they're just better labels); the
86
+ `ledger_meta` table and its marker row are inert without the new code. No data
87
+ loss, no schema-incompatible state. (Per-phase revert matches the umbrella
88
+ spec's rollback model.)
89
+
90
+ ## Tests
91
+
92
+ - New `tests/unit/burn-attribution-wiring.test.ts` (11 tests): resolver never
93
+ returns the sentinel; ingest now resolves a real key; two sessions split into
94
+ two keys; job-cwd → `user-job:*`; backfill converts legacy sentinel rows on
95
+ reopen + is idempotent; BurnDetector regression — sentinel at 100% emits
96
+ nothing, residual `unknown::<sid>` at 100% still emits, sentinel doesn't mask
97
+ a co-occurring real burner.
98
+ - Updated `tests/unit/token-ledger.test.ts` (#512 schema-order migration test):
99
+ now asserts the migrated legacy row is backfilled to `unknown::sess-old` and
100
+ the sentinel is gone, instead of asserting the sentinel persists.
101
+ - Full sweep green: `vitest run` over burn-detection-phase-1..6 + token-ledger +
102
+ TokenLedger-codex + TokenLedgerPoller-codex + burn-attribution-wiring =
103
+ **149 passed, 0 failed**. `tsc --noEmit` clean.
@@ -0,0 +1,66 @@
1
+ # Side-effects review — Mesh URL advertisement (lastKnownUrl population)
2
+
3
+ ## What changed
4
+
5
+ `updateMachineUrl()` (the only writer of `registry.machines[id].lastKnownUrl`)
6
+ had **zero callers** on main. The Multi-Machine Session Pool routes every
7
+ cross-machine RPC — `deliverMessage`, session transfer, lease ops — to a peer by
8
+ reading that peer's `lastKnownUrl`, and filters peers with `!!e.lastKnownUrl`.
9
+ With the field never written, every peer was filtered out and **no session
10
+ could ever be delivered or transferred across machines**. The feature was inert
11
+ across real machines despite green 3-tier tests (which inject mock peer URLs).
12
+
13
+ Found 2026-05-29 bringing up a second real machine (the mini) for the
14
+ session-pool real-hardware proof.
15
+
16
+ This change adds:
17
+ - `src/core/MeshUrlAdvertiser.ts` — `resolveAdvertisedMeshUrl(tunnel, resolvedUrl?)`
18
+ (named → `https://<hostname>`, quick → the runtime-resolved start URL, none →
19
+ null) + `advertiseSelfMeshUrl(recorder, selfId, url)` (idempotent, tolerant of
20
+ a missing self entry).
21
+ - `src/commands/server.ts` — calls the advertiser after `tunnel.start()` on both
22
+ the boot path and the sleep/wake restart path (a quick tunnel gets a new URL
23
+ after a wake, so the previously-advertised URL goes stale).
24
+ - `src/commands/machine.ts` (`joinMesh`) — the joiner records the URL it
25
+ connected through as the awake peer's `lastKnownUrl`, so a standby can route
26
+ back to the awake machine immediately without waiting for a registry sync.
27
+
28
+ ## Blast radius
29
+
30
+ - **Dark-feature-only effect.** The populated `lastKnownUrl` is consumed solely
31
+ by the session-pool mesh code, which is gated behind `multiMachine.sessionPool`
32
+ (default `enabled:false`, `stage:'dark'`). On a single-machine agent there are
33
+ no peers, so nothing changes. Populating the field is otherwise inert.
34
+ - **No new config, no new route, no schema change** → no migration needed.
35
+ Existing agents pick up the behavior at boot on the next release. No
36
+ `PostUpdateMigrator` entry required (the code runs at startup unconditionally
37
+ when multi-machine is enabled and a tunnel URL exists).
38
+ - **Idempotent + fail-safe.** `advertiseSelfMeshUrl` writes only when the URL
39
+ changed and swallows the "self entry not present yet" race (returns false).
40
+ A tunnel-disabled machine advertises nothing (correct — it is genuinely
41
+ unreachable cross-machine; peers skip it rather than route to a dead URL).
42
+ - **No secret exposure.** A tunnel URL is already public (the dashboard link).
43
+ Recording it in the registry adds no new exposure.
44
+
45
+ ## What this does NOT close (scoped, decision-gated follow-up)
46
+
47
+ The **awake machine learning a git-uncredentialed standby's URL** still needs one
48
+ of: (a) the standby having git push creds for the shared agent repo (so its
49
+ advertised entry syncs via the existing git-backed RegistrySyncDebouncer), or
50
+ (b) a signed mesh `announce-url` RPC so a standby can publish its URL to the
51
+ awake machine over authenticated MeshRpc without git. (b) also requires the
52
+ awake side to persist the joiner's identity, which today only happens via the
53
+ **interactive SAS pairing** confirmation — so closing this fully touches the
54
+ pairing security model. That is a genuine design fork (creds vs. new RPC +
55
+ non-interactive registration), not a silent deferral, and is raised explicitly
56
+ rather than hand-resolved on a production identity.
57
+
58
+ ## Tests
59
+
60
+ - `tests/unit/mesh-url-advertiser.test.ts` — resolver (named/quick/disabled/none)
61
+ + advertiser against a real `MachineIdentityManager` (writes, idempotent,
62
+ updates on change, null no-op, missing-entry tolerance).
63
+ - `tests/unit/mesh-url-advertisement-wiring.test.ts` — wiring-integrity: asserts
64
+ the advertiser is invoked on the boot + sleep/wake tunnel paths and the joiner
65
+ records the connect URL. This is the tier that was missing — it would have
66
+ caught the original "zero callers" gap.
@@ -0,0 +1,85 @@
1
+ # Side-effects review — Non-interactive, code-authenticated pool join (trust model A)
2
+
3
+ ## What changed & why
4
+
5
+ Bringing up a second real machine for the Multi-Machine Session Pool surfaced
6
+ that the awake machine never learned about a machine that joined headlessly: the
7
+ existing `pair`/`join` flow is **interactive by design** — `/api/pair` was
8
+ "signal-only" and real registration completed only via a human confirming
9
+ matching SAS visual symbols on both screens. An active-active session pool can't
10
+ require a human to eyeball symbols per machine, so (operator decision: "Proceed
11
+ with A") pairing becomes **code-authenticated and non-interactive**: the
12
+ time-limited, single-use pairing code (carried over the TLS tunnel) is the
13
+ shared secret that authorizes the join; Ed25519-signed mesh RPCs handle trust
14
+ thereafter.
15
+
16
+ Root cause of the gap: `instar pair` created the `PairingSession` into an
17
+ **unused** `_pairingSession` variable and discarded it, so the running server
18
+ had no code to validate against.
19
+
20
+ Changes:
21
+ - **`src/core/PairingSessionStore.ts`** (new): persists the validation-relevant
22
+ fields of the active `PairingSession` to `.instar/machine/pairing-session.json`
23
+ (0600), and loads it. The ephemeral X25519 private key is deliberately NOT
24
+ persisted (it is a `KeyObject`, not JSON-serializable, and unused for code auth).
25
+ - **`src/commands/machine.ts`** (`pairMachine`): persists the session via
26
+ `PairingSessionStore` (10-min window) instead of discarding it; (`joinMesh`):
27
+ sends the joiner's own advertised URL in the pair body when it already has one.
28
+ - **`src/server/machineRoutes.ts`** (`/api/pair`): validates the submitted code
29
+ against the persisted session (`validatePairingCode` — single-use,
30
+ attempt-capped, TTL'd), and on success registers the joiner as **standby**,
31
+ stores its public keys (`storeRemoteIdentity`, so MeshRpc can verify it),
32
+ records its advertised URL, and burns the code. Rejections are audited.
33
+ - **`src/core/MachineIdentity.ts`**: adds a `baseDir` getter so the route can
34
+ co-locate its `PairingSessionStore` with the registry.
35
+
36
+ ## Security analysis
37
+
38
+ - **The code IS the auth.** It is a `WORD-WORD-NNNN` secret, single-use
39
+ (`consumed`), attempt-capped (default 3 — failed attempts persist across
40
+ requests so the cap actually throttles brute force), and TTL'd (10 min as set
41
+ by `instar pair`). It travels over the Cloudflare TLS tunnel. Brute force over
42
+ a large code space within 3 attempts is infeasible.
43
+ - **A joiner can only ever become `standby`.** The role is hard-coded in the
44
+ handler; a join request can never claim the awake role or move the lease.
45
+ - **`/api/pair` remains unauthenticated** (it is the bootstrap, before the joiner
46
+ is known) — but it is now *gated*: with no active session it rejects (403)
47
+ rather than the old behavior of accepting any request. Malformed identities are
48
+ rejected (400) before anything is persisted.
49
+ - **What we trade away vs. SAS:** the human visual-symbol MITM check. Mitigated
50
+ by code-over-TLS + short TTL + single-use + attempt cap. This is the operator's
51
+ explicit trust-model choice for automated pool formation.
52
+
53
+ ## Blast radius
54
+
55
+ - **Dark-feature-adjacent.** This is the multi-machine pairing path; a
56
+ single-machine agent never calls it. No behavior change unless an operator runs
57
+ `instar pair` + `instar join`.
58
+ - **No new config / schema.** `/api/pair` is server code → existing agents get
59
+ the new handler on update; no `PostUpdateMigrator` entry needed. The
60
+ `pairing-session.json` file is created on demand by `instar pair`.
61
+ - **Backward-compatible join:** `joinMesh` still registers the awake machine from
62
+ the response and records its URL (the #531 fix); the only response change is
63
+ `status: 'paired'` instead of `'pending'`, which `joinMesh` does not branch on.
64
+
65
+ ## Not closed here (scoped follow-up)
66
+
67
+ For a **quick-tunnel** standby, its reachable URL is only known after its server
68
+ starts (post-join), so it cannot be sent in the pair body. The awake machine
69
+ therefore still needs a way to learn a quick-tunnel standby's URL — either the
70
+ standby uses a named tunnel (deterministic URL, exchanged at pair time), or a
71
+ dedicated authenticated URL-announce path is added (the standby is now registered
72
+ with verifiable keys, so it can sign such a call). Raised explicitly; the
73
+ heartbeat path is awake→standby (lease arbitration), so it is not a clean carrier.
74
+
75
+ ## Tests
76
+
77
+ - `tests/unit/pairing-session-store.test.ts` — `PairingSessionStore` roundtrip,
78
+ null-on-absent, no-private-key-persisted, 0600 perms, overwrite, malformed-file
79
+ tolerance.
80
+ - `tests/integration/pool-noninteractive-pairing.test.ts` — valid code registers
81
+ joiner as standby + records URL + stores keys + consumes code; wrong code 403 +
82
+ attempt increment; brute-force lockout; no-session 403; consumed 403; malformed
83
+ identity 400.
84
+ - `tests/integration/machine-routes.test.ts` — updated the legacy signal-only
85
+ assertion to the new session-gated contract.