diff --git a/docs/architecture.md b/docs/architecture.md index 8800227..f61dccb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -179,6 +179,11 @@ reservations, then opaque account ID. Anonymous traffic is balanced but never assigned using inferred identity. +When no candidate is eligible, the portable core returns a typed `NoEligibleAccountsError` carrying +the complete `CandidateExplanation` array produced during selection, including each opaque account +ID and its rejection reason. AgentOS and other consumers should render those explanations rather +than reimplementing eligibility policy. + ## Generation-safe credential lifecycle Every subscription credential has a monotonically increasing router generation. diff --git a/packages/core/test/selection.test.ts b/packages/core/test/selection.test.ts index b71ebfb..e559113 100644 --- a/packages/core/test/selection.test.ts +++ b/packages/core/test/selection.test.ts @@ -228,16 +228,40 @@ describe("selectAccount", () => { Effect.gen(function* () { const failure = yield* Effect.flip( selectAccount({ - candidates: [candidate("unknown", { usage: false })], + candidates: [ + candidate("reauth", { requiresReauthentication: true }), + candidate("blocked", { + block: AccountBlock.make({ + kind: "quota", + retryAt: now + hour + }) + }), + candidate("unknown", { usage: false }), + candidate("short-exhausted", { shortUsed: 91 }), + candidate("weekly-exhausted", { weeklyUsed: 98 }), + candidate("too-old", { observedAt: now - day - 1 }), + candidate("expired-reset", { weeklyResetAt: now }) + ], config: defaultRoutingConfig, now }) ) expect(failure).toBeInstanceOf(NoEligibleAccountsError) - expect(failure.explanations).toHaveLength(1) - expect(failure.explanations[0]?.accountId).toBe("unknown") - expect(failure.explanations[0]?.rejection.valueOrUndefined).toBe("usage_unknown") + expect( + failure.explanations.map((explanation) => [ + explanation.accountId, + Option.getOrUndefined(explanation.rejection) + ]) + ).toEqual([ + [AccountId.make("reauth"), "reauthentication_required"], + [AccountId.make("blocked"), "active_block"], + [AccountId.make("unknown"), "usage_unknown"], + [AccountId.make("short-exhausted"), "short_headroom"], + [AccountId.make("weekly-exhausted"), "weekly_headroom"], + [AccountId.make("too-old"), "usage_too_old"], + [AccountId.make("expired-reset"), "weekly_reset_elapsed"] + ]) }) ) })