instar 1.3.523 → 1.3.525
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/dist/commands/server.d.ts.map +1 -1
- package/dist/commands/server.js +37 -1
- package/dist/commands/server.js.map +1 -1
- package/dist/config/ConfigDefaults.d.ts.map +1 -1
- package/dist/config/ConfigDefaults.js +12 -0
- package/dist/config/ConfigDefaults.js.map +1 -1
- package/dist/core/ConflictStore.d.ts +150 -0
- package/dist/core/ConflictStore.d.ts.map +1 -0
- package/dist/core/ConflictStore.js +247 -0
- package/dist/core/ConflictStore.js.map +1 -0
- package/dist/core/CredentialIdentityOracle.d.ts +59 -0
- package/dist/core/CredentialIdentityOracle.d.ts.map +1 -0
- package/dist/core/CredentialIdentityOracle.js +89 -0
- package/dist/core/CredentialIdentityOracle.js.map +1 -0
- package/dist/core/CredentialLocationLedger.d.ts +187 -0
- package/dist/core/CredentialLocationLedger.d.ts.map +1 -0
- package/dist/core/CredentialLocationLedger.js +351 -0
- package/dist/core/CredentialLocationLedger.js.map +1 -0
- package/dist/core/CredentialWriteFunnel.d.ts +77 -0
- package/dist/core/CredentialWriteFunnel.d.ts.map +1 -0
- package/dist/core/CredentialWriteFunnel.js +128 -0
- package/dist/core/CredentialWriteFunnel.js.map +1 -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/ReplicatedStoreReader.d.ts +83 -0
- package/dist/core/ReplicatedStoreReader.d.ts.map +1 -0
- package/dist/core/ReplicatedStoreReader.js +100 -0
- package/dist/core/ReplicatedStoreReader.js.map +1 -0
- package/dist/core/ReplicationBudget.d.ts +183 -0
- package/dist/core/ReplicationBudget.d.ts.map +1 -0
- package/dist/core/ReplicationBudget.js +253 -0
- package/dist/core/ReplicationBudget.js.map +1 -0
- package/dist/core/RollbackUnmerge.d.ts +134 -0
- package/dist/core/RollbackUnmerge.d.ts.map +1 -0
- package/dist/core/RollbackUnmerge.js +286 -0
- package/dist/core/RollbackUnmerge.js.map +1 -0
- package/dist/core/UnionReader.d.ts +161 -0
- package/dist/core/UnionReader.d.ts.map +1 -0
- package/dist/core/UnionReader.js +0 -0
- package/dist/core/UnionReader.js.map +1 -0
- package/dist/core/devGatedFeatures.d.ts.map +1 -1
- package/dist/core/devGatedFeatures.js +5 -0
- package/dist/core/devGatedFeatures.js.map +1 -1
- package/dist/core/types.d.ts +35 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/scaffold/templates.d.ts.map +1 -1
- package/dist/scaffold/templates.js +6 -0
- package/dist/scaffold/templates.js.map +1 -1
- package/dist/server/AgentServer.d.ts +7 -0
- package/dist/server/AgentServer.d.ts.map +1 -1
- package/dist/server/AgentServer.js +3 -0
- package/dist/server/AgentServer.js.map +1 -1
- package/dist/server/routes.d.ts +10 -0
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +67 -0
- package/dist/server/routes.js.map +1 -1
- package/package.json +1 -1
- package/scripts/lint-no-direct-llm-http.js +6 -0
- package/src/data/builtin-manifest.json +64 -64
- package/src/scaffold/templates.ts +6 -0
- package/upgrades/1.3.524.md +31 -0
- package/upgrades/1.3.525.md +32 -0
- package/upgrades/side-effects/hlc-step4-5-union-rollback-bounds.md +148 -0
- package/upgrades/side-effects/live-credential-repointing-increment-a-foundation.md +75 -0
- package/upgrades/side-effects/live-credential-repointing-increment-a-funnel-primitive.md +66 -0
- package/upgrades/side-effects/live-credential-repointing-increment-a-ledger.md +71 -0
- package/upgrades/side-effects/live-credential-repointing-increment-a-oracle.md +68 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CredentialIdentityOracle — Step 3 of live credential re-pointing (spec §2.3 verify, §2.11).
|
|
3
|
+
*
|
|
4
|
+
* The ONLY oracle that reads credential REALITY: given a config-home slot, it reads the slot's
|
|
5
|
+
* current credential blob, takes its OAuth access token, and asks the Anthropic OAuth profile
|
|
6
|
+
* endpoint (`GET /api/oauth/profile`) which account that token belongs to. `claude auth status`
|
|
7
|
+
* is disqualified (E4a — it reads a metadata file, not the live credential, and lies during the
|
|
8
|
+
* keychain-first/config-second swap window); the config `oauthAccount` record is metadata, not
|
|
9
|
+
* truth. The profile endpoint is the truth-oracle the convergence experiments (E4b) adopted.
|
|
10
|
+
*
|
|
11
|
+
* It implements the `IdentityOracle` interface the `CredentialLocationLedger` (Step 2) seeds and
|
|
12
|
+
* recovers from. Separation of concerns: this oracle returns the RAW probed email (or an
|
|
13
|
+
* `unavailable` result); the LEDGER maps email → accountId through the pool and decides what to
|
|
14
|
+
* do on an ambiguous/unknown match. That keeps the pool-mapping policy in one place (§2.2).
|
|
15
|
+
*
|
|
16
|
+
* §2.11 classification CONTRACT — `email` set IFF the profile returned a non-empty string email;
|
|
17
|
+
* EVERY other outcome (no token / fetch error / non-2xx (incl. 401/403/429/5xx) / unparseable /
|
|
18
|
+
* missing-or-empty-or-nonstring email) → `unavailable` with a reason. NEVER a "mismatch": an
|
|
19
|
+
* unverifiable slot is quarantine-never-repair upstream, never a guess.
|
|
20
|
+
*
|
|
21
|
+
* Reuses `readClaudeOauth` (OAuthRefresher) for the per-slot blob read — no hand-rolled keychain
|
|
22
|
+
* access. The profile fetch lives here behind a bounded timeout; this file is allowlisted in
|
|
23
|
+
* `scripts/lint-no-direct-llm-http.js` for the SAME reason QuotaCollector is — an OAuth profile
|
|
24
|
+
* call is identity bookkeeping, not an LLM message call.
|
|
25
|
+
*
|
|
26
|
+
* NOTE (tracked to Step 4/5): when the slot's access token is EXPIRED, the spec's optimization is
|
|
27
|
+
* one refresh exchange (through the credential write funnel) before the profile call to avoid a
|
|
28
|
+
* spurious `unavailable`. That refresh WRITES a rotated token, so it must go through the Step-4
|
|
29
|
+
* write funnel — until that lands, an expired token classifies as `unavailable` (the safe
|
|
30
|
+
* direction: the slot is quarantined and re-probed, never guessed). The profile call here writes
|
|
31
|
+
* nothing.
|
|
32
|
+
*/
|
|
33
|
+
import { readClaudeOauth } from './OAuthRefresher.js';
|
|
34
|
+
const OAUTH_PROFILE_URL = 'https://api.anthropic.com/api/oauth/profile';
|
|
35
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
36
|
+
export class CredentialIdentityOracle {
|
|
37
|
+
store;
|
|
38
|
+
fetchImpl;
|
|
39
|
+
timeoutMs;
|
|
40
|
+
constructor(deps = {}) {
|
|
41
|
+
this.store = deps.store;
|
|
42
|
+
this.fetchImpl =
|
|
43
|
+
deps.fetchImpl ??
|
|
44
|
+
((url, init) => fetch(url, init));
|
|
45
|
+
this.timeoutMs = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
46
|
+
}
|
|
47
|
+
async resolveSlotTenant(slot) {
|
|
48
|
+
// 1. Read the slot's current credential blob → access token. (No write; reuses OAuthRefresher.)
|
|
49
|
+
const oauth = this.store ? readClaudeOauth(slot, this.store) : readClaudeOauth(slot);
|
|
50
|
+
const token = oauth?.accessToken;
|
|
51
|
+
if (!token || typeof token !== 'string') {
|
|
52
|
+
return { unavailable: true, reason: 'no access token in slot credential store' };
|
|
53
|
+
}
|
|
54
|
+
// 2. Probe the profile endpoint with the slot's token. Bounded; any failure → unavailable.
|
|
55
|
+
let resp;
|
|
56
|
+
try {
|
|
57
|
+
resp = await this.fetchImpl(OAUTH_PROFILE_URL, {
|
|
58
|
+
headers: {
|
|
59
|
+
Authorization: `Bearer ${token}`,
|
|
60
|
+
Accept: 'application/json',
|
|
61
|
+
'Content-Type': 'application/json',
|
|
62
|
+
'anthropic-beta': 'oauth-2025-04-20',
|
|
63
|
+
},
|
|
64
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
return { unavailable: true, reason: `profile fetch error: ${err?.message ?? 'unknown'}` };
|
|
69
|
+
}
|
|
70
|
+
if (!resp.ok) {
|
|
71
|
+
// 401/403/429/5xx all land here — never a mismatch, always unavailable (§2.11).
|
|
72
|
+
return { unavailable: true, reason: `profile endpoint returned ${resp.status}` };
|
|
73
|
+
}
|
|
74
|
+
// 3. Parse + extract the owning email.
|
|
75
|
+
let data;
|
|
76
|
+
try {
|
|
77
|
+
data = await resp.json();
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return { unavailable: true, reason: 'unparseable profile response' };
|
|
81
|
+
}
|
|
82
|
+
const email = data?.account?.email;
|
|
83
|
+
if (typeof email !== 'string' || email.length === 0) {
|
|
84
|
+
return { unavailable: true, reason: 'profile response carried no usable email' };
|
|
85
|
+
}
|
|
86
|
+
return { email };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
//# sourceMappingURL=CredentialIdentityOracle.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"CredentialIdentityOracle.js","sourceRoot":"","sources":["../../src/core/CredentialIdentityOracle.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,OAAO,EAAE,eAAe,EAAwB,MAAM,qBAAqB,CAAC;AAG5E,MAAM,iBAAiB,GAAG,6CAA6C,CAAC;AACxE,MAAM,kBAAkB,GAAG,MAAM,CAAC;AAiBlC,MAAM,OAAO,wBAAwB;IAClB,KAAK,CAAmB;IACxB,SAAS,CAAc;IACvB,SAAS,CAAS;IAEnC,YAAY,OAAqC,EAAE;QACjD,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC;QACxB,IAAI,CAAC,SAAS;YACZ,IAAI,CAAC,SAAS;gBACd,CAAC,CAAC,GAAG,EAAE,IAAI,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,IAAmB,CAAuC,CAAC,CAAC;QACzF,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC,SAAS,IAAI,kBAAkB,CAAC;IACxD,CAAC;IAED,KAAK,CAAC,iBAAiB,CAAC,IAAY;QAClC,gGAAgG;QAChG,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,eAAe,CAAC,IAAI,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,eAAe,CAAC,IAAI,CAAC,CAAC;QACrF,MAAM,KAAK,GAAG,KAAK,EAAE,WAAW,CAAC;QACjC,IAAI,CAAC,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;YACxC,OAAO,EAAE,WAAW,EAAE,IAAI,EAAE,MAAM,EAAE,0CAA0C,EAAE,CAAC;QACnF,CAAC;QAED,2FAA2F;QAC3F,IAAI,IAAmE,CAAC;QACxE,IAAI,CAAC;YACH,IAAI,GAAG,MAAM,IAAI,CAAC,SAAS,CAAC,iBAAiB,EAAE;gBAC7C,OAAO,EAAE;oBACP,aAAa,EAAE,UAAU,KAAK,EAAE;oBAChC,MAAM,EAAE,kBAAkB;oBAC1B,cAAc,EAAE,kBAAkB;oBAClC,gBAAgB,EAAE,kBAAkB;iBACrC;gBACD,MAAM,EAAE,WAAW,CAAC,OAAO,CAAC,IAAI,CAAC,SAAS,CAAC;aAC5C,CAAC,CAAC;QACL,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,OAAO,EAAE,WAAW,EAAE,IAAI,EAAE,MAAM,EAAE,wBAAyB,GAAa,EAAE,OAAO,IAAI,SAAS,EAAE,EAAE,CAAC;QACvG,CAAC;QAED,IAAI,CAAC,IAAI,CAAC,EAAE,EAAE,CAAC;YACb,gFAAgF;YAChF,OAAO,EAAE,WAAW,EAAE,IAAI,EAAE,MAAM,EAAE,6BAA6B,IAAI,CAAC,MAAM,EAAE,EAAE,CAAC;QACnF,CAAC;QAED,uCAAuC;QACvC,IAAI,IAAa,CAAC;QAClB,IAAI,CAAC;YACH,IAAI,GAAG,MAAM,IAAI,CAAC,IAAI,EAAE,CAAC;QAC3B,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,EAAE,WAAW,EAAE,IAAI,EAAE,MAAM,EAAE,8BAA8B,EAAE,CAAC;QACvE,CAAC;QACD,MAAM,KAAK,GAAI,IAA0C,EAAE,OAAO,EAAE,KAAK,CAAC;QAC1E,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACpD,OAAO,EAAE,WAAW,EAAE,IAAI,EAAE,MAAM,EAAE,0CAA0C,EAAE,CAAC;QACnF,CAAC;QACD,OAAO,EAAE,KAAK,EAAE,CAAC;IACnB,CAAC;CACF"}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CredentialLocationLedger — the bookkeeping core of live credential re-pointing.
|
|
3
|
+
*
|
|
4
|
+
* Spec: docs/specs/live-credential-repointing-rebalancer.md §2.2.
|
|
5
|
+
*
|
|
6
|
+
* A machine-local durable ledger (`<stateDir>/credential-locations.json`) that records,
|
|
7
|
+
* for each config-home SLOT, which pool account's credential currently lives there. It is
|
|
8
|
+
* the single source of truth for "which account is in which home" once live re-pointing is
|
|
9
|
+
* enabled — every consumer that today treats a pool account's enrollment `configHome` as its
|
|
10
|
+
* live location instead resolves through `slotOf(accountId)` / `tenantOf(slot)` here.
|
|
11
|
+
*
|
|
12
|
+
* This module is Step 2 of Increment A: the bookkeeping + durability + seeding/recovery core.
|
|
13
|
+
* It performs NO keychain writes itself — the staged swap executor (§2.3, Step 5) and the
|
|
14
|
+
* write funnel (§2.2, Step 4) are separate. The identity oracle it seeds/recovers from is the
|
|
15
|
+
* `IdentityOracle` interface (Step 3 implements it against the Anthropic OAuth profile endpoint,
|
|
16
|
+
* `/api/oauth/profile`, routed through the existing intelligence-provider chokepoint).
|
|
17
|
+
*
|
|
18
|
+
* Durability posture (the §2.2 contract — NOT SubscriptionPool's posture):
|
|
19
|
+
* - missing file → NEVER-SEEDED (mode 'ok', empty assignments). Reads return null →
|
|
20
|
+
* consumers fall back to today's enrollment-home behavior. This is
|
|
21
|
+
* normal, not a degradation.
|
|
22
|
+
* - corrupt/unparseable → UNKNOWN MODE (fail-closed for moves: every mutation refuses;
|
|
23
|
+
* fail-open-LOUD for reads: reads return null AND one HIGH attention
|
|
24
|
+
* item names the degradation). Recovery is a fresh `seedFromOracle()`.
|
|
25
|
+
* This is deliberately NOT a silent fresh-start (No Silent Degradation):
|
|
26
|
+
* a quiet reset here could place a session onto the wrong account.
|
|
27
|
+
*/
|
|
28
|
+
/** The on-disk schema version this module writes. Bumped only on a breaking shape change. */
|
|
29
|
+
export declare const CREDENTIAL_LEDGER_SCHEMA_VERSION = 1;
|
|
30
|
+
/** Which account's credential currently lives in a given config-home slot. */
|
|
31
|
+
export interface CredentialAssignment {
|
|
32
|
+
/** Config-home path acting as the slot, e.g. `~/.claude` or an enrollment home. */
|
|
33
|
+
slot: string;
|
|
34
|
+
/** Pool account id whose credential currently sits in this slot. */
|
|
35
|
+
accountId: string;
|
|
36
|
+
/** ISO — when this assignment was last recorded (a swap/seed into this slot). */
|
|
37
|
+
since: string;
|
|
38
|
+
/** ISO — last identity-oracle confirmation of this slot's tenant; null = never verified. */
|
|
39
|
+
lastVerifiedAt: string | null;
|
|
40
|
+
/** Excluded from balancing — oracle was unavailable, or a divergence was detected. */
|
|
41
|
+
quarantined: boolean;
|
|
42
|
+
}
|
|
43
|
+
/** One journal entry — in-flight swap phases + the last N completed (pruned at commit). */
|
|
44
|
+
export interface CredentialLedgerJournalEntry {
|
|
45
|
+
/** Monotonic sequence within the ledger (== the version at write time). */
|
|
46
|
+
seq: number;
|
|
47
|
+
/** The operation this entry belongs to. */
|
|
48
|
+
op: 'seed' | 'swap' | 'set-default' | 'quarantine' | 'unquarantine' | 'restore';
|
|
49
|
+
/** The phase of that operation (swap is multi-phase per §2.3; others are single-shot). */
|
|
50
|
+
phase: 'begin' | 'staged' | 'exchanged' | 'verified' | 'done' | 'aborted';
|
|
51
|
+
/** Slots this entry touched (ordered). */
|
|
52
|
+
slots: string[];
|
|
53
|
+
/** Free-text detail (never a credential — names/ids/reasons only). */
|
|
54
|
+
detail?: string;
|
|
55
|
+
/** ISO timestamp. */
|
|
56
|
+
at: string;
|
|
57
|
+
}
|
|
58
|
+
export interface CredentialLocationLedgerStore {
|
|
59
|
+
version: number;
|
|
60
|
+
assignments: CredentialAssignment[];
|
|
61
|
+
journal: CredentialLedgerJournalEntry[];
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Result of probing a slot's CURRENT credential blob for its owning account.
|
|
65
|
+
* Implemented in Step 3 against `GET /api/oauth/profile` (the Anthropic OAuth profile endpoint).
|
|
66
|
+
*
|
|
67
|
+
* CONTRACT (§2.11): `email` set === identity-confirmed; `unavailable:true` for EVERY other
|
|
68
|
+
* outcome (timeout/401/403/429/5xx/missing-or-empty-or-nonstring email/unparseable). The
|
|
69
|
+
* oracle NEVER reports a "mismatch" — an unverifiable slot is quarantine-never-repair, never
|
|
70
|
+
* a guess.
|
|
71
|
+
*/
|
|
72
|
+
export interface IdentityOracleResult {
|
|
73
|
+
email?: string;
|
|
74
|
+
unavailable?: boolean;
|
|
75
|
+
reason?: string;
|
|
76
|
+
}
|
|
77
|
+
export interface IdentityOracle {
|
|
78
|
+
/** Resolve which account email the credential blob currently in `slot` belongs to. */
|
|
79
|
+
resolveSlotTenant(slot: string): Promise<IdentityOracleResult>;
|
|
80
|
+
}
|
|
81
|
+
/** Narrow view of the subscription pool the ledger needs (satisfied by SubscriptionPool). */
|
|
82
|
+
export interface LedgerPoolAccount {
|
|
83
|
+
id: string;
|
|
84
|
+
email?: string;
|
|
85
|
+
configHome: string;
|
|
86
|
+
framework?: string;
|
|
87
|
+
}
|
|
88
|
+
export interface LedgerPoolView {
|
|
89
|
+
list(): LedgerPoolAccount[];
|
|
90
|
+
}
|
|
91
|
+
/** Attention-item shape (mirrors AgentWorktreeDetector.AttentionItemInput). */
|
|
92
|
+
export interface CredentialLedgerAttentionInput {
|
|
93
|
+
id: string;
|
|
94
|
+
title: string;
|
|
95
|
+
summary: string;
|
|
96
|
+
description?: string;
|
|
97
|
+
category: string;
|
|
98
|
+
priority: 'URGENT' | 'HIGH' | 'NORMAL' | 'LOW';
|
|
99
|
+
sourceContext?: string;
|
|
100
|
+
}
|
|
101
|
+
export interface CredentialLocationLedgerDeps {
|
|
102
|
+
/** Agent stateDir (e.g. `.instar`). The ledger lives at `<stateDir>/credential-locations.json`. */
|
|
103
|
+
stateDir: string;
|
|
104
|
+
/** The subscription pool — used to map a probed email → accountId during seed/recovery. */
|
|
105
|
+
pool: LedgerPoolView;
|
|
106
|
+
/** Identity oracle for seeding/recovery (Step 3 implementation). */
|
|
107
|
+
oracle: IdentityOracle;
|
|
108
|
+
/** Emit a HIGH attention item (typically telegramAdapter.createAttentionItem). */
|
|
109
|
+
emitAttention?: (item: CredentialLedgerAttentionInput) => void | Promise<void>;
|
|
110
|
+
/** Injectable clock for deterministic tests. */
|
|
111
|
+
now?: () => string;
|
|
112
|
+
}
|
|
113
|
+
/** Thrown when a mutation is attempted while the ledger is in UNKNOWN mode (fail-closed). */
|
|
114
|
+
export declare class CredentialLedgerUnknownModeError extends Error {
|
|
115
|
+
constructor(message: string);
|
|
116
|
+
}
|
|
117
|
+
/** Outcome of a seed/recovery probe for one slot (returned for caller observability + tests). */
|
|
118
|
+
export interface SeedSlotOutcome {
|
|
119
|
+
slot: string;
|
|
120
|
+
result: 'assigned' | 'unavailable' | 'ambiguous' | 'unknown-email';
|
|
121
|
+
accountId?: string;
|
|
122
|
+
email?: string;
|
|
123
|
+
reason?: string;
|
|
124
|
+
}
|
|
125
|
+
export declare class CredentialLocationLedger {
|
|
126
|
+
private readonly storePath;
|
|
127
|
+
private readonly pool;
|
|
128
|
+
private readonly oracle;
|
|
129
|
+
private readonly emitAttention?;
|
|
130
|
+
private readonly now;
|
|
131
|
+
private store;
|
|
132
|
+
/** UNKNOWN mode — set when the on-disk ledger was present but corrupt. Fail-closed for moves. */
|
|
133
|
+
private unknownMode;
|
|
134
|
+
/** Dedupe the UNKNOWN-mode attention item so a re-read storm raises it once. */
|
|
135
|
+
private unknownAttentionRaised;
|
|
136
|
+
/** In-memory index: accountId → slot (rebuilt on every mutation/load). */
|
|
137
|
+
private slotByAccount;
|
|
138
|
+
/** In-memory index: slot → accountId. */
|
|
139
|
+
private accountBySlot;
|
|
140
|
+
constructor(deps: CredentialLocationLedgerDeps);
|
|
141
|
+
private load;
|
|
142
|
+
private save;
|
|
143
|
+
private reindex;
|
|
144
|
+
/** Current slot holding `accountId`'s credential, or null (UNKNOWN mode / never-seeded). */
|
|
145
|
+
slotOf(accountId: string): string | null;
|
|
146
|
+
/** Account currently tenanting `slot`, or null (UNKNOWN mode / never-seeded). */
|
|
147
|
+
tenantOf(slot: string): string | null;
|
|
148
|
+
isUnknownMode(): boolean;
|
|
149
|
+
/** True when the ledger has at least one assignment (has been seeded). */
|
|
150
|
+
isSeeded(): boolean;
|
|
151
|
+
getAssignments(): readonly CredentialAssignment[];
|
|
152
|
+
getAssignment(slot: string): CredentialAssignment | null;
|
|
153
|
+
getJournal(): readonly CredentialLedgerJournalEntry[];
|
|
154
|
+
get version(): number;
|
|
155
|
+
private assertMutable;
|
|
156
|
+
/** Record (or re-point) `slot`'s tenant to `accountId`. Single-writer; bumps version. */
|
|
157
|
+
recordAssignment(slot: string, accountId: string, opts?: {
|
|
158
|
+
verifiedAt?: string | null;
|
|
159
|
+
op?: CredentialLedgerJournalEntry['op'];
|
|
160
|
+
}): CredentialAssignment;
|
|
161
|
+
/** Mark a slot quarantined (excluded from balancing) — oracle unavailable / divergence. */
|
|
162
|
+
quarantineSlot(slot: string, reason: string): void;
|
|
163
|
+
/** Lift a quarantine after a clean re-probe. */
|
|
164
|
+
unquarantineSlot(slot: string): void;
|
|
165
|
+
/** Stamp a slot's lastVerifiedAt after an identity-oracle confirmation. */
|
|
166
|
+
markVerified(slot: string, at?: string): void;
|
|
167
|
+
/** Append a journal entry for an in-flight swap phase (used by the executor in Step 5). */
|
|
168
|
+
appendJournal(entry: Omit<CredentialLedgerJournalEntry, 'seq' | 'at'> & {
|
|
169
|
+
at?: string;
|
|
170
|
+
}): CredentialLedgerJournalEntry;
|
|
171
|
+
private bumpAndJournal;
|
|
172
|
+
/** Keep all non-terminal (in-flight) entries + the last MAX_COMPLETED_JOURNAL terminal ones. */
|
|
173
|
+
private pruneJournal;
|
|
174
|
+
/**
|
|
175
|
+
* Derive each slot's tenant by probing its CURRENT credential via the oracle, then mapping
|
|
176
|
+
* email → accountId through the pool. Clears UNKNOWN mode on a clean pass. Per §2.2:
|
|
177
|
+
* - oracle unavailable for a slot → quarantine that slot (never guess), continue.
|
|
178
|
+
* - email maps to exactly one claude-code account → record the assignment (verified now).
|
|
179
|
+
* - ambiguous (≥2 accounts share the email) or unknown email → REFUSE auto-assign for that
|
|
180
|
+
* slot + raise an attention item; never guess.
|
|
181
|
+
* Slots = the distinct enrollment `configHome`s of claude-code pool accounts.
|
|
182
|
+
*/
|
|
183
|
+
seedFromOracle(): Promise<SeedSlotOutcome[]>;
|
|
184
|
+
private raiseUnknownModeAttention;
|
|
185
|
+
private raiseSeedRefusalAttention;
|
|
186
|
+
}
|
|
187
|
+
//# sourceMappingURL=CredentialLocationLedger.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"CredentialLocationLedger.d.ts","sourceRoot":"","sources":["../../src/core/CredentialLocationLedger.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CredentialLocationLedger — the bookkeeping core of live credential re-pointing.
|
|
3
|
+
*
|
|
4
|
+
* Spec: docs/specs/live-credential-repointing-rebalancer.md §2.2.
|
|
5
|
+
*
|
|
6
|
+
* A machine-local durable ledger (`<stateDir>/credential-locations.json`) that records,
|
|
7
|
+
* for each config-home SLOT, which pool account's credential currently lives there. It is
|
|
8
|
+
* the single source of truth for "which account is in which home" once live re-pointing is
|
|
9
|
+
* enabled — every consumer that today treats a pool account's enrollment `configHome` as its
|
|
10
|
+
* live location instead resolves through `slotOf(accountId)` / `tenantOf(slot)` here.
|
|
11
|
+
*
|
|
12
|
+
* This module is Step 2 of Increment A: the bookkeeping + durability + seeding/recovery core.
|
|
13
|
+
* It performs NO keychain writes itself — the staged swap executor (§2.3, Step 5) and the
|
|
14
|
+
* write funnel (§2.2, Step 4) are separate. The identity oracle it seeds/recovers from is the
|
|
15
|
+
* `IdentityOracle` interface (Step 3 implements it against the Anthropic OAuth profile endpoint,
|
|
16
|
+
* `/api/oauth/profile`, routed through the existing intelligence-provider chokepoint).
|
|
17
|
+
*
|
|
18
|
+
* Durability posture (the §2.2 contract — NOT SubscriptionPool's posture):
|
|
19
|
+
* - missing file → NEVER-SEEDED (mode 'ok', empty assignments). Reads return null →
|
|
20
|
+
* consumers fall back to today's enrollment-home behavior. This is
|
|
21
|
+
* normal, not a degradation.
|
|
22
|
+
* - corrupt/unparseable → UNKNOWN MODE (fail-closed for moves: every mutation refuses;
|
|
23
|
+
* fail-open-LOUD for reads: reads return null AND one HIGH attention
|
|
24
|
+
* item names the degradation). Recovery is a fresh `seedFromOracle()`.
|
|
25
|
+
* This is deliberately NOT a silent fresh-start (No Silent Degradation):
|
|
26
|
+
* a quiet reset here could place a session onto the wrong account.
|
|
27
|
+
*/
|
|
28
|
+
import fs from 'node:fs';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
/** The on-disk schema version this module writes. Bumped only on a breaking shape change. */
|
|
31
|
+
export const CREDENTIAL_LEDGER_SCHEMA_VERSION = 1;
|
|
32
|
+
/** Thrown when a mutation is attempted while the ledger is in UNKNOWN mode (fail-closed). */
|
|
33
|
+
export class CredentialLedgerUnknownModeError extends Error {
|
|
34
|
+
constructor(message) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.name = 'CredentialLedgerUnknownModeError';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const MAX_COMPLETED_JOURNAL = 50;
|
|
40
|
+
const NON_CLAUDE_FRAMEWORKS = new Set(['codex-cli', 'gemini-cli', 'pi-cli']);
|
|
41
|
+
/** A claude-code account is the implicit default (framework undefined or 'claude-code'). */
|
|
42
|
+
function isClaudeCodeAccount(a) {
|
|
43
|
+
return !a.framework || a.framework === 'claude-code';
|
|
44
|
+
}
|
|
45
|
+
export class CredentialLocationLedger {
|
|
46
|
+
storePath;
|
|
47
|
+
pool;
|
|
48
|
+
oracle;
|
|
49
|
+
emitAttention;
|
|
50
|
+
now;
|
|
51
|
+
store;
|
|
52
|
+
/** UNKNOWN mode — set when the on-disk ledger was present but corrupt. Fail-closed for moves. */
|
|
53
|
+
unknownMode = false;
|
|
54
|
+
/** Dedupe the UNKNOWN-mode attention item so a re-read storm raises it once. */
|
|
55
|
+
unknownAttentionRaised = false;
|
|
56
|
+
/** In-memory index: accountId → slot (rebuilt on every mutation/load). */
|
|
57
|
+
slotByAccount = new Map();
|
|
58
|
+
/** In-memory index: slot → accountId. */
|
|
59
|
+
accountBySlot = new Map();
|
|
60
|
+
constructor(deps) {
|
|
61
|
+
this.storePath = path.join(deps.stateDir, 'credential-locations.json');
|
|
62
|
+
this.pool = deps.pool;
|
|
63
|
+
this.oracle = deps.oracle;
|
|
64
|
+
this.emitAttention = deps.emitAttention;
|
|
65
|
+
this.now = deps.now ?? (() => new Date().toISOString());
|
|
66
|
+
this.store = this.load();
|
|
67
|
+
this.reindex();
|
|
68
|
+
if (this.unknownMode)
|
|
69
|
+
void this.raiseUnknownModeAttention();
|
|
70
|
+
}
|
|
71
|
+
// ─── Load / save (atomic tmp+rename — the SubscriptionPool.save pattern) ──────────
|
|
72
|
+
load() {
|
|
73
|
+
if (!fs.existsSync(this.storePath)) {
|
|
74
|
+
// NEVER-SEEDED — not corrupt. Empty ledger; reads fall back to enrollment homes.
|
|
75
|
+
return { version: 0, assignments: [], journal: [] };
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const data = JSON.parse(fs.readFileSync(this.storePath, 'utf-8'));
|
|
79
|
+
if (data &&
|
|
80
|
+
typeof data.version === 'number' &&
|
|
81
|
+
Array.isArray(data.assignments) &&
|
|
82
|
+
Array.isArray(data.journal)) {
|
|
83
|
+
return data;
|
|
84
|
+
}
|
|
85
|
+
// Present but wrong shape → corrupt.
|
|
86
|
+
this.unknownMode = true;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// Present but unparseable → corrupt. NOT a silent fresh-start: UNKNOWN mode is entered
|
|
90
|
+
// and a HIGH attention item is raised by the constructor. (No Silent Degradation.)
|
|
91
|
+
this.unknownMode = true;
|
|
92
|
+
}
|
|
93
|
+
return { version: 0, assignments: [], journal: [] };
|
|
94
|
+
}
|
|
95
|
+
save() {
|
|
96
|
+
try {
|
|
97
|
+
const dir = path.dirname(this.storePath);
|
|
98
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
99
|
+
const tmpPath = `${this.storePath}.${process.pid}.tmp`;
|
|
100
|
+
fs.writeFileSync(tmpPath, JSON.stringify(this.store, null, 2) + '\n');
|
|
101
|
+
fs.renameSync(tmpPath, this.storePath);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// @silent-fallback-ok — a persistence failure leaves the in-memory store authoritative
|
|
105
|
+
// for this process; the next mutation retries the write. The ledger is bookkeeping, not
|
|
106
|
+
// a credential, so an unwritten entry never strands a login — at worst a recovery probe
|
|
107
|
+
// re-derives it. Surfacing every transient fs hiccup would be noise, not signal.
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
reindex() {
|
|
111
|
+
this.slotByAccount.clear();
|
|
112
|
+
this.accountBySlot.clear();
|
|
113
|
+
for (const a of this.store.assignments) {
|
|
114
|
+
// Tenant-less quarantine markers (accountId === '') carry no resolvable tenant — a
|
|
115
|
+
// seed refusal / unavailable probe records the slot as quarantined-unknown, and a read
|
|
116
|
+
// of it must be null (caller falls back), never the empty string.
|
|
117
|
+
if (!a.accountId)
|
|
118
|
+
continue;
|
|
119
|
+
this.slotByAccount.set(a.accountId, a.slot);
|
|
120
|
+
this.accountBySlot.set(a.slot, a.accountId);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// ─── Sync in-memory reads (never disk/parse per spawn; never throw) ───────────────
|
|
124
|
+
/** Current slot holding `accountId`'s credential, or null (UNKNOWN mode / never-seeded). */
|
|
125
|
+
slotOf(accountId) {
|
|
126
|
+
if (this.unknownMode)
|
|
127
|
+
return null;
|
|
128
|
+
return this.slotByAccount.get(accountId) ?? null;
|
|
129
|
+
}
|
|
130
|
+
/** Account currently tenanting `slot`, or null (UNKNOWN mode / never-seeded). */
|
|
131
|
+
tenantOf(slot) {
|
|
132
|
+
if (this.unknownMode)
|
|
133
|
+
return null;
|
|
134
|
+
return this.accountBySlot.get(slot) ?? null;
|
|
135
|
+
}
|
|
136
|
+
isUnknownMode() {
|
|
137
|
+
return this.unknownMode;
|
|
138
|
+
}
|
|
139
|
+
/** True when the ledger has at least one assignment (has been seeded). */
|
|
140
|
+
isSeeded() {
|
|
141
|
+
return !this.unknownMode && this.store.assignments.length > 0;
|
|
142
|
+
}
|
|
143
|
+
getAssignments() {
|
|
144
|
+
return this.store.assignments.slice();
|
|
145
|
+
}
|
|
146
|
+
getAssignment(slot) {
|
|
147
|
+
return this.store.assignments.find((a) => a.slot === slot) ?? null;
|
|
148
|
+
}
|
|
149
|
+
getJournal() {
|
|
150
|
+
return this.store.journal.slice();
|
|
151
|
+
}
|
|
152
|
+
get version() {
|
|
153
|
+
return this.store.version;
|
|
154
|
+
}
|
|
155
|
+
// ─── Mutations (single-writer; refuse in UNKNOWN mode — fail-closed for moves) ─────
|
|
156
|
+
assertMutable(op) {
|
|
157
|
+
if (this.unknownMode) {
|
|
158
|
+
throw new CredentialLedgerUnknownModeError(`ledger is in UNKNOWN mode (corrupt on-disk state) — ${op} refused; recover via seedFromOracle()`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/** Record (or re-point) `slot`'s tenant to `accountId`. Single-writer; bumps version. */
|
|
162
|
+
recordAssignment(slot, accountId, opts) {
|
|
163
|
+
this.assertMutable('recordAssignment');
|
|
164
|
+
const at = this.now();
|
|
165
|
+
const existing = this.store.assignments.find((a) => a.slot === slot);
|
|
166
|
+
// A slot's tenant changing means the previous tenant is no longer here — drop any stale
|
|
167
|
+
// assignment that claimed the SAME accountId in a different slot (one home per credential).
|
|
168
|
+
this.store.assignments = this.store.assignments.filter((a) => a.slot !== slot && a.accountId !== accountId);
|
|
169
|
+
const assignment = {
|
|
170
|
+
slot,
|
|
171
|
+
accountId,
|
|
172
|
+
since: existing && existing.accountId === accountId ? existing.since : at,
|
|
173
|
+
lastVerifiedAt: opts?.verifiedAt !== undefined ? opts.verifiedAt : (existing?.lastVerifiedAt ?? null),
|
|
174
|
+
quarantined: false,
|
|
175
|
+
};
|
|
176
|
+
this.store.assignments.push(assignment);
|
|
177
|
+
this.bumpAndJournal({ op: opts?.op ?? 'swap', phase: 'done', slots: [slot], detail: `tenant=${accountId}` });
|
|
178
|
+
this.reindex();
|
|
179
|
+
this.save();
|
|
180
|
+
return assignment;
|
|
181
|
+
}
|
|
182
|
+
/** Mark a slot quarantined (excluded from balancing) — oracle unavailable / divergence. */
|
|
183
|
+
quarantineSlot(slot, reason) {
|
|
184
|
+
this.assertMutable('quarantineSlot');
|
|
185
|
+
const a = this.store.assignments.find((x) => x.slot === slot);
|
|
186
|
+
if (a) {
|
|
187
|
+
a.quarantined = true;
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
// Quarantine a slot we have no confirmed tenant for: record a tenant-less quarantine
|
|
191
|
+
// marker so the slot is durably excluded until a clean probe clears it.
|
|
192
|
+
this.store.assignments.push({ slot, accountId: '', since: this.now(), lastVerifiedAt: null, quarantined: true });
|
|
193
|
+
}
|
|
194
|
+
this.bumpAndJournal({ op: 'quarantine', phase: 'done', slots: [slot], detail: reason });
|
|
195
|
+
this.reindex();
|
|
196
|
+
this.save();
|
|
197
|
+
}
|
|
198
|
+
/** Lift a quarantine after a clean re-probe. */
|
|
199
|
+
unquarantineSlot(slot) {
|
|
200
|
+
this.assertMutable('unquarantineSlot');
|
|
201
|
+
const a = this.store.assignments.find((x) => x.slot === slot);
|
|
202
|
+
if (a) {
|
|
203
|
+
a.quarantined = false;
|
|
204
|
+
this.bumpAndJournal({ op: 'unquarantine', phase: 'done', slots: [slot] });
|
|
205
|
+
this.reindex();
|
|
206
|
+
this.save();
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/** Stamp a slot's lastVerifiedAt after an identity-oracle confirmation. */
|
|
210
|
+
markVerified(slot, at) {
|
|
211
|
+
this.assertMutable('markVerified');
|
|
212
|
+
const a = this.store.assignments.find((x) => x.slot === slot);
|
|
213
|
+
if (a) {
|
|
214
|
+
a.lastVerifiedAt = at ?? this.now();
|
|
215
|
+
this.save();
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/** Append a journal entry for an in-flight swap phase (used by the executor in Step 5). */
|
|
219
|
+
appendJournal(entry) {
|
|
220
|
+
this.assertMutable('appendJournal');
|
|
221
|
+
return this.bumpAndJournal(entry);
|
|
222
|
+
}
|
|
223
|
+
bumpAndJournal(entry) {
|
|
224
|
+
this.store.version += 1;
|
|
225
|
+
const full = {
|
|
226
|
+
seq: this.store.version,
|
|
227
|
+
op: entry.op,
|
|
228
|
+
phase: entry.phase,
|
|
229
|
+
slots: entry.slots ?? [],
|
|
230
|
+
...(entry.detail !== undefined ? { detail: entry.detail } : {}),
|
|
231
|
+
at: entry.at ?? this.now(),
|
|
232
|
+
};
|
|
233
|
+
this.store.journal.push(full);
|
|
234
|
+
this.pruneJournal();
|
|
235
|
+
return full;
|
|
236
|
+
}
|
|
237
|
+
/** Keep all non-terminal (in-flight) entries + the last MAX_COMPLETED_JOURNAL terminal ones. */
|
|
238
|
+
pruneJournal() {
|
|
239
|
+
const terminal = (p) => p === 'done' || p === 'aborted';
|
|
240
|
+
const inFlight = this.store.journal.filter((e) => !terminal(e.phase));
|
|
241
|
+
const completed = this.store.journal.filter((e) => terminal(e.phase));
|
|
242
|
+
const keptCompleted = completed.slice(-MAX_COMPLETED_JOURNAL);
|
|
243
|
+
this.store.journal = [...inFlight, ...keptCompleted].sort((a, b) => a.seq - b.seq);
|
|
244
|
+
}
|
|
245
|
+
// ─── Seeding / recovery via the identity oracle (§2.2) ────────────────────────────
|
|
246
|
+
/**
|
|
247
|
+
* Derive each slot's tenant by probing its CURRENT credential via the oracle, then mapping
|
|
248
|
+
* email → accountId through the pool. Clears UNKNOWN mode on a clean pass. Per §2.2:
|
|
249
|
+
* - oracle unavailable for a slot → quarantine that slot (never guess), continue.
|
|
250
|
+
* - email maps to exactly one claude-code account → record the assignment (verified now).
|
|
251
|
+
* - ambiguous (≥2 accounts share the email) or unknown email → REFUSE auto-assign for that
|
|
252
|
+
* slot + raise an attention item; never guess.
|
|
253
|
+
* Slots = the distinct enrollment `configHome`s of claude-code pool accounts.
|
|
254
|
+
*/
|
|
255
|
+
async seedFromOracle() {
|
|
256
|
+
const claudeAccounts = this.pool.list().filter(isClaudeCodeAccount);
|
|
257
|
+
const slots = Array.from(new Set(claudeAccounts.map((a) => a.configHome))).filter(Boolean);
|
|
258
|
+
const outcomes = [];
|
|
259
|
+
// A clean slate for the rebuild; UNKNOWN mode is cleared as soon as we begin a real probe
|
|
260
|
+
// pass (the rebuild is the recovery path the spec names).
|
|
261
|
+
this.store = { version: this.store.version, assignments: [], journal: this.store.journal };
|
|
262
|
+
this.unknownMode = false;
|
|
263
|
+
this.unknownAttentionRaised = false;
|
|
264
|
+
this.bumpAndJournal({ op: 'seed', phase: 'begin', slots, detail: `${slots.length} slot(s)` });
|
|
265
|
+
for (const slot of slots) {
|
|
266
|
+
let result;
|
|
267
|
+
try {
|
|
268
|
+
result = await this.oracle.resolveSlotTenant(slot);
|
|
269
|
+
}
|
|
270
|
+
catch (err) {
|
|
271
|
+
// An oracle that THROWS is treated identically to unavailable (§2.11 — never a guess,
|
|
272
|
+
// never a mismatch). This is loud at the slot level (quarantine), not a silent swallow.
|
|
273
|
+
result = { unavailable: true, reason: `oracle threw: ${err?.message ?? 'unknown'}` };
|
|
274
|
+
}
|
|
275
|
+
if (result.unavailable || !result.email) {
|
|
276
|
+
this.store.assignments.push({ slot, accountId: '', since: this.now(), lastVerifiedAt: null, quarantined: true });
|
|
277
|
+
outcomes.push({ slot, result: 'unavailable', reason: result.reason ?? 'oracle unavailable' });
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
const matches = claudeAccounts.filter((a) => a.email && a.email === result.email);
|
|
281
|
+
if (matches.length === 1) {
|
|
282
|
+
this.store.assignments.push({
|
|
283
|
+
slot,
|
|
284
|
+
accountId: matches[0].id,
|
|
285
|
+
since: this.now(),
|
|
286
|
+
lastVerifiedAt: this.now(),
|
|
287
|
+
quarantined: false,
|
|
288
|
+
});
|
|
289
|
+
outcomes.push({ slot, result: 'assigned', accountId: matches[0].id, email: result.email });
|
|
290
|
+
}
|
|
291
|
+
else if (matches.length > 1) {
|
|
292
|
+
// Ambiguous — two pool records, one email (legal under multi-grant). Never guess.
|
|
293
|
+
this.store.assignments.push({ slot, accountId: '', since: this.now(), lastVerifiedAt: null, quarantined: true });
|
|
294
|
+
outcomes.push({ slot, result: 'ambiguous', email: result.email, reason: `${matches.length} pool accounts share ${result.email}` });
|
|
295
|
+
await this.raiseSeedRefusalAttention(slot, 'ambiguous', `${matches.length} accounts share ${result.email}`);
|
|
296
|
+
}
|
|
297
|
+
else {
|
|
298
|
+
// Probed email matches no pool account.
|
|
299
|
+
this.store.assignments.push({ slot, accountId: '', since: this.now(), lastVerifiedAt: null, quarantined: true });
|
|
300
|
+
outcomes.push({ slot, result: 'unknown-email', email: result.email, reason: `no pool account for ${result.email}` });
|
|
301
|
+
await this.raiseSeedRefusalAttention(slot, 'unknown-email', `no pool account for ${result.email}`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
this.bumpAndJournal({ op: 'seed', phase: 'done', slots, detail: `${outcomes.filter((o) => o.result === 'assigned').length} assigned` });
|
|
305
|
+
this.reindex();
|
|
306
|
+
this.save();
|
|
307
|
+
return outcomes;
|
|
308
|
+
}
|
|
309
|
+
// ─── Attention (LOUD degradation surfaces) ───────────────────────────────────────
|
|
310
|
+
async raiseUnknownModeAttention() {
|
|
311
|
+
if (this.unknownAttentionRaised || !this.emitAttention)
|
|
312
|
+
return;
|
|
313
|
+
this.unknownAttentionRaised = true;
|
|
314
|
+
try {
|
|
315
|
+
await this.emitAttention({
|
|
316
|
+
id: 'credential-ledger-unknown-mode',
|
|
317
|
+
title: 'Credential location ledger is in UNKNOWN mode',
|
|
318
|
+
summary: 'The credential-locations ledger on disk was unreadable. Credential swaps are refused (fail-closed) and credential reads fall back to enrollment homes until a fresh identity-oracle probe rebuilds it.',
|
|
319
|
+
description: 'Recovery: re-seed via the identity oracle (POST /credentials/locations re-probe, or seedFromOracle()). No credential was moved while in this state.',
|
|
320
|
+
category: 'credential-repointing',
|
|
321
|
+
priority: 'HIGH',
|
|
322
|
+
sourceContext: 'credential-location-ledger',
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
// @silent-fallback-ok — the attention emitter is best-effort; a delivery failure must
|
|
327
|
+
// not throw out of the ledger constructor and crash boot. The UNKNOWN-mode posture
|
|
328
|
+
// (reads-null + mutations-refuse) is still in force regardless of whether the notice
|
|
329
|
+
// was delivered, so the safety behavior does not depend on this emit.
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
async raiseSeedRefusalAttention(slot, kind, detail) {
|
|
333
|
+
if (!this.emitAttention)
|
|
334
|
+
return;
|
|
335
|
+
try {
|
|
336
|
+
await this.emitAttention({
|
|
337
|
+
id: `credential-ledger-seed-refusal:${kind}:${slot}`,
|
|
338
|
+
title: `Credential ledger could not auto-assign a slot (${kind})`,
|
|
339
|
+
summary: `Seeding refused to guess the tenant of ${slot}: ${detail}. The slot is quarantined (excluded from balancing) until resolved.`,
|
|
340
|
+
category: 'credential-repointing',
|
|
341
|
+
priority: 'HIGH',
|
|
342
|
+
sourceContext: 'credential-location-ledger',
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
// @silent-fallback-ok — best-effort notice; the quarantine (the actual safety action) is
|
|
347
|
+
// already durably recorded in the ledger whether or not the notice is delivered.
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
//# sourceMappingURL=CredentialLocationLedger.js.map
|