import Foundation public enum CodexDashboardSourceKind: String, Codable, Sendable { case liveWeb case cachedDashboard } public enum CodexDashboardDisposition: String, Codable, Sendable { case attach case displayOnly case failClosed } public enum CodexDashboardAllowedEffect: String, Codable, CaseIterable, Hashable, Sendable { case usageBackfill case creditsAttachment case refreshGuardSeed case historicalBackfill case cachedDashboardReuse } public enum CodexDashboardCleanup: String, Codable, CaseIterable, Hashable, Sendable { case dashboardSnapshot case dashboardDerivedUsage case dashboardDerivedCredits case dashboardRefreshGuardSeed case dashboardCache } public struct CodexDashboardKnownOwnerCandidate: Equatable, Hashable, Sendable { public let identity: CodexIdentity public let normalizedEmail: String? public let sourceIsolationIdentifier: String? public init( identity: CodexIdentity, normalizedEmail: String?, sourceIsolationIdentifier: String? = nil) { self.identity = identity self.normalizedEmail = normalizedEmail self.sourceIsolationIdentifier = sourceIsolationIdentifier } public func hash(into hasher: inout Hasher) { switch self.identity { case let .providerAccount(id): hasher.combine("providerAccount") hasher.combine(id) case let .emailOnly(normalizedEmail): hasher.combine("emailOnly") hasher.combine(normalizedEmail) case .unresolved: hasher.combine("unresolved") } hasher.combine(self.normalizedEmail) hasher.combine(self.sourceIsolationIdentifier) } } public struct CodexDashboardOwnershipProofContext: Equatable, Sendable { public let currentIdentity: CodexIdentity public let expectedScopedEmail: String? public let trustedCurrentUsageEmail: String? public let dashboardSignedInEmail: String? public let knownOwners: [CodexDashboardKnownOwnerCandidate] public init( currentIdentity: CodexIdentity, expectedScopedEmail: String?, trustedCurrentUsageEmail: String?, dashboardSignedInEmail: String?, knownOwners: [CodexDashboardKnownOwnerCandidate]) { self.currentIdentity = currentIdentity self.expectedScopedEmail = expectedScopedEmail self.trustedCurrentUsageEmail = trustedCurrentUsageEmail self.dashboardSignedInEmail = dashboardSignedInEmail self.knownOwners = knownOwners } } public struct CodexDashboardRoutingHints: Equatable, Sendable { public let targetEmail: String? public let lastKnownDashboardRoutingEmail: String? public init(targetEmail: String?, lastKnownDashboardRoutingEmail: String?) { self.targetEmail = targetEmail self.lastKnownDashboardRoutingEmail = lastKnownDashboardRoutingEmail } } public struct CodexDashboardAuthorityInput: Equatable, Sendable { public let sourceKind: CodexDashboardSourceKind public let proof: CodexDashboardOwnershipProofContext public let routing: CodexDashboardRoutingHints public init( sourceKind: CodexDashboardSourceKind, proof: CodexDashboardOwnershipProofContext, routing: CodexDashboardRoutingHints) { self.sourceKind = sourceKind self.proof = proof self.routing = routing } } public enum CodexDashboardDecisionReason: Equatable, Sendable { case exactProviderAccountMatch case trustedEmailMatchNoCompetingOwner case trustedContinuityNoCompetingOwner case wrongEmail(expected: String?, actual: String?) case sameEmailAmbiguity(email: String) case unresolvedWithoutTrustedEvidence case providerAccountMissingScopedEmail case providerAccountLacksExactOwnershipProof case missingDashboardSignedInEmail } public struct CodexDashboardAuthorityDecision: Equatable, Sendable { public let disposition: CodexDashboardDisposition public let reason: CodexDashboardDecisionReason public let allowedEffects: Set public let cleanup: Set public init( disposition: CodexDashboardDisposition, reason: CodexDashboardDecisionReason, allowedEffects: Set, cleanup: Set) { self.disposition = disposition self.reason = reason self.allowedEffects = allowedEffects self.cleanup = cleanup } } public enum CodexDashboardPolicyError: LocalizedError, Equatable, Sendable { case displayOnly(CodexDashboardAuthorityDecision) public var errorDescription: String? { switch self { case .displayOnly: "Codex dashboard may be displayed, but it cannot be attached to the active account." } } } public enum CodexDashboardAuthority { /// Evaluates whether a Codex dashboard snapshot may attach to the active account. /// /// App callers may keep `.displayOnly` dashboard data visible, but must not attach usage, /// credits, refresh guards, or historical backfill. /// /// CLI callers should surface `.displayOnly` as `CodexDashboardPolicyError.displayOnly` /// instead of treating it as a generic failure. /// /// Cached dashboard callers may restore data only when the decision includes /// `.cachedDashboardReuse` in `allowedEffects`. public static func evaluate(_ input: CodexDashboardAuthorityInput) -> CodexDashboardAuthorityDecision { let proof = input.proof let currentIdentity = Self.normalizeIdentity(proof.currentIdentity) let expectedScopedEmail = CodexIdentityResolver.normalizeEmail(proof.expectedScopedEmail) let trustedCurrentUsageEmail = CodexIdentityResolver.normalizeEmail(proof.trustedCurrentUsageEmail) let dashboardSignedInEmail = CodexIdentityResolver.normalizeEmail(proof.dashboardSignedInEmail) let knownOwners = Self.normalizeKnownOwners(proof.knownOwners) // Routing hints are intentionally excluded from ownership proof. They may help fetch or route // dashboard requests, but they must never influence attach/display/fail-closed policy. guard let dashboardSignedInEmail else { return Self.makeDecision( disposition: .failClosed, reason: .missingDashboardSignedInEmail, sourceKind: input.sourceKind) } if let expectedScopedEmail, dashboardSignedInEmail != expectedScopedEmail { return Self.makeDecision( disposition: .failClosed, reason: .wrongEmail(expected: expectedScopedEmail, actual: dashboardSignedInEmail), sourceKind: input.sourceKind) } switch currentIdentity { case let .providerAccount(id): guard expectedScopedEmail != nil else { return Self.makeDecision( disposition: .failClosed, reason: .providerAccountMissingScopedEmail, sourceKind: input.sourceKind) } if Self.knownOwnerCount(for: dashboardSignedInEmail, in: knownOwners) > 1 { return Self.makeDecision( disposition: .displayOnly, reason: .sameEmailAmbiguity(email: dashboardSignedInEmail), sourceKind: input.sourceKind) } let exactMatch = knownOwners.contains { candidate in candidate.identity == .providerAccount(id: id) && candidate.normalizedEmail == dashboardSignedInEmail } if exactMatch { return Self.makeDecision( disposition: .attach, reason: .exactProviderAccountMatch, sourceKind: input.sourceKind) } return Self.makeDecision( disposition: .failClosed, reason: .providerAccountLacksExactOwnershipProof, sourceKind: input.sourceKind) case let .emailOnly(normalizedEmail): guard dashboardSignedInEmail == normalizedEmail else { return Self.makeDecision( disposition: .failClosed, reason: .wrongEmail(expected: normalizedEmail, actual: dashboardSignedInEmail), sourceKind: input.sourceKind) } if Self.knownOwnerCount(for: normalizedEmail, in: knownOwners) > 1 { return Self.makeDecision( disposition: .displayOnly, reason: .sameEmailAmbiguity(email: normalizedEmail), sourceKind: input.sourceKind) } return Self.makeDecision( disposition: .attach, reason: .trustedEmailMatchNoCompetingOwner, sourceKind: input.sourceKind) case .unresolved: guard let trustedCurrentUsageEmail else { return Self.makeDecision( disposition: .failClosed, reason: .unresolvedWithoutTrustedEvidence, sourceKind: input.sourceKind) } guard dashboardSignedInEmail == trustedCurrentUsageEmail else { return Self.makeDecision( disposition: .failClosed, reason: .wrongEmail(expected: trustedCurrentUsageEmail, actual: dashboardSignedInEmail), sourceKind: input.sourceKind) } if Self.knownOwnerCount(for: trustedCurrentUsageEmail, in: knownOwners) > 1 { return Self.makeDecision( disposition: .displayOnly, reason: .sameEmailAmbiguity(email: trustedCurrentUsageEmail), sourceKind: input.sourceKind) } return Self.makeDecision( disposition: .attach, reason: .trustedContinuityNoCompetingOwner, sourceKind: input.sourceKind) } } private static func normalizeIdentity(_ identity: CodexIdentity) -> CodexIdentity { switch identity { case let .providerAccount(id): if let normalizedID = CodexIdentityResolver.normalizeAccountID(id) { return .providerAccount(id: normalizedID) } return .unresolved case let .emailOnly(normalizedEmail): if let normalizedEmail = CodexIdentityResolver.normalizeEmail(normalizedEmail) { return .emailOnly(normalizedEmail: normalizedEmail) } return .unresolved case .unresolved: return .unresolved } } private static func normalizeKnownOwners( _ candidates: [CodexDashboardKnownOwnerCandidate]) -> Set { Set(candidates.map { candidate in let identity = self.normalizeIdentity(candidate.identity) let sourceIsolationIdentifier: String? = switch identity { case .providerAccount: nil case .emailOnly, .unresolved: candidate.sourceIsolationIdentifier } return CodexDashboardKnownOwnerCandidate( identity: identity, normalizedEmail: CodexIdentityResolver.normalizeEmail(candidate.normalizedEmail), sourceIsolationIdentifier: sourceIsolationIdentifier) }) } private static func knownOwnerCount( for email: String, in candidates: Set) -> Int { candidates.count { $0.normalizedEmail == email } } private static func makeDecision( disposition: CodexDashboardDisposition, reason: CodexDashboardDecisionReason, sourceKind: CodexDashboardSourceKind) -> CodexDashboardAuthorityDecision { CodexDashboardAuthorityDecision( disposition: disposition, reason: reason, allowedEffects: self.allowedEffects(disposition: disposition, sourceKind: sourceKind), cleanup: disposition == .attach ? [] : Set(CodexDashboardCleanup.allCases)) } private static func allowedEffects( disposition: CodexDashboardDisposition, sourceKind: CodexDashboardSourceKind) -> Set { guard disposition == .attach else { return [] } switch sourceKind { case .liveWeb: return [ .usageBackfill, .creditsAttachment, .refreshGuardSeed, .historicalBackfill, ] case .cachedDashboard: return [.cachedDashboardReuse] } } }