feat(telemetry): classify codex trust-grant fallbacks and attribute grant lane (#10001)

* feat(telemetry): classify codex trust-grant fallbacks and attribute grant lane

* fix(telemetry): tighten codex trust-grant classification
This commit is contained in:
Brennan Benson 2026-07-22 17:12:22 -07:00 committed by GitHub
parent db56a9b7f3
commit 407d7afc11
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 336 additions and 74 deletions

View File

@ -28,6 +28,7 @@
"../src/main/codex/codex-managed-trust-reconciliation.ts",
"../src/main/codex/codex-process-exit-deadline.ts",
"../src/main/codex/codex-trust-config-rollback.ts",
"../src/main/codex/codex-trust-grant-telemetry.ts",
"../src/main/codex/codex-trust-grant-host.ts",
"../src/main/codex/codex-trust-grant-ledger.ts",
"../src/main/codex/codex-user-hook-trust-rebase-client.ts",

View File

@ -6,6 +6,7 @@ import {
} from '../../shared/wsl-login-shell-command'
export const WSL_CODEX_AVAILABILITY_TIMEOUT_MS = 5_000
export const WSL_CODEX_NOT_FOUND_MESSAGE = 'Codex CLI not found in the WSL login-shell PATH.'
export function buildWslCodexAvailabilityArgs(distro: string): string[] {
const command = [buildCodexPathLookup(), '[ -n "$resolved" ]'].join('\n')
@ -16,7 +17,7 @@ export function buildWslCodexIdentityArgs(distro: string): string[] {
const command = [
buildCodexPathLookup(),
'if [ -z "$resolved" ]; then',
" printf '%s\\n' 'Codex CLI not found in the WSL login-shell PATH.' >&2",
` printf '%s\\n' '${WSL_CODEX_NOT_FOUND_MESSAGE}' >&2`,
' exit 127',
'fi',
'printf \'%s\\n\' "$resolved"',
@ -29,7 +30,7 @@ export function buildWslCodexAppServerArgs(distro: string, linuxHomePath: string
const command = [
buildCodexPathLookup(),
'if [ -z "$resolved" ]; then',
" printf '%s\\n' 'Codex CLI not found in the WSL login-shell PATH.' >&2",
` printf '%s\\n' '${WSL_CODEX_NOT_FOUND_MESSAGE}' >&2`,
' exit 127',
'fi',
`export CODEX_HOME=${quotePosixShell(linuxHomePath)}`,

View File

@ -321,7 +321,7 @@ describe('runCodexHookTrustGrantSession', () => {
})
const result = await runCodexHookTrustGrantSession(request)
expect(result.outcome).toBe('verify-failed')
expect(result).toMatchObject({ outcome: 'verify-failed', reasonClass: 'list-mismatch' })
})
it('rejects duplicate normalized aliases that conceal a missing expected key', async () => {
@ -337,7 +337,8 @@ describe('runCodexHookTrustGrantSession', () => {
})
await expect(runCodexHookTrustGrantSession(request)).resolves.toMatchObject({
outcome: 'verify-failed'
outcome: 'verify-failed',
reasonClass: 'list-mismatch'
})
expect(existsSync(recordFile)).toBe(false)
})

View File

@ -41,6 +41,13 @@ export type CodexGrantedHookTrust = {
trustedHash: string
}
/** Closed verify-failure taxonomy crosses the grant-bridge JSON envelope, so
* telemetry never has to parse the free-form `reason` diagnostics string. */
export type CodexTrustGrantSessionVerifyClass =
| 'list-mismatch'
| 'post-grant-untrusted'
| 'post-grant-mismatch'
export type CodexHookTrustGrantSessionResult =
| {
outcome: 'granted'
@ -48,7 +55,7 @@ export type CodexHookTrustGrantSessionResult =
/** False when every expected entry was already trusted (no write). */
wroteTrust: boolean
}
| { outcome: 'verify-failed'; reason: string }
| { outcome: 'verify-failed'; reason: string; reasonClass: CodexTrustGrantSessionVerifyClass }
type CodexHookListing = {
key: string
@ -117,7 +124,8 @@ export async function runCodexHookTrustGrantSession(
) {
return {
outcome: 'verify-failed',
reason: `hooks/list reported ${managedListings.length} entries covering ${managedKeyCoverage.size} of ${expectedKeys.size} expected managed entries`
reason: `hooks/list reported ${managedListings.length} entries covering ${managedKeyCoverage.size} of ${expectedKeys.size} expected managed entries`,
reasonClass: 'list-mismatch'
}
}
@ -144,13 +152,17 @@ export async function runCodexHookTrustGrantSession(
!setContainsEvery(verifiedKeyCoverage, expectedKeys) ||
untrusted.length > 0
) {
return {
outcome: 'verify-failed',
reason:
untrusted.length > 0
? `post-grant verify left ${untrusted.length} entries ${untrusted[0].trustStatus}`
: `post-grant verify reported ${verifiedListings.length} entries covering ${verifiedKeyCoverage.size} of ${expectedKeys.size} expected entries`
}
return untrusted.length > 0
? {
outcome: 'verify-failed',
reason: `post-grant verify left ${untrusted.length} entries ${untrusted[0].trustStatus}`,
reasonClass: 'post-grant-untrusted'
}
: {
outcome: 'verify-failed',
reason: `post-grant verify reported ${verifiedListings.length} entries covering ${verifiedKeyCoverage.size} of ${expectedKeys.size} expected entries`,
reasonClass: 'post-grant-mismatch'
}
}
return {
outcome: 'granted',

View File

@ -13,9 +13,9 @@ import {
CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS,
getCodexTrustGrantDiagnostics,
grantManagedCodexHookTrust,
setCodexTrustGrantTelemetry,
type CodexManagedTrustGrantPlan
} from './codex-hook-trust-grant'
import { setCodexTrustGrantTelemetry } from './codex-trust-grant-telemetry'
import { readCodexTrustGrantLedgerHome } from './codex-trust-grant-ledger'
import {
computeTrustKey,
@ -76,7 +76,8 @@ function buildPlan(entries: CodexTrustEntry[]): CodexManagedTrustGrantPlan {
tomlPath: join(runtimeHomeDir, 'config.toml'),
managedCommand: MANAGED_COMMAND,
managedEntries: entries,
host: { kind: 'native' }
host: { kind: 'native' },
telemetryLane: 'real-home'
}
}
@ -248,7 +249,11 @@ describe('grantManagedCodexHookTrust', () => {
it('falls back on verify-failed without marking unsupported', () => {
const entries = [managedEntry('session_start')]
const runner = vi.fn(() => ({ outcome: 'verify-failed' as const, reason: 'missing entries' }))
const runner = vi.fn(() => ({
outcome: 'verify-failed' as const,
reason: 'missing entries',
reasonClass: 'list-mismatch' as const
}))
_internals.setGrantSessionRunnerSync(runner)
expect(grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({
@ -307,7 +312,11 @@ describe('grantManagedCodexHookTrust', () => {
mkdirSync(runtimeHomeDir, { recursive: true })
_internals.setGrantSessionRunnerSync(() => {
writeFileSync(plan.tomlPath, '[hooks.state."rpc-partial"]\ntrusted_hash = "changed"\n')
return { outcome: 'verify-failed', reason: 'post-write listing failed' }
return {
outcome: 'verify-failed',
reason: 'post-write listing failed',
reasonClass: 'post-grant-mismatch'
}
})
expect(grantManagedCodexHookTrust(plan)).toMatchObject({
@ -347,3 +356,93 @@ describe('grantManagedCodexHookTrust', () => {
expect(request.hooksListCwd).toBe('/home/alice/.codex-runtime')
})
})
describe('trust-grant telemetry detail', () => {
type CapturedEvent = Record<string, unknown>
function captureTelemetry(): CapturedEvent[] {
const events: CapturedEvent[] = []
setCodexTrustGrantTelemetry((event) => {
events.push(event)
})
return events
}
it('attributes the plan lane on granted events', () => {
const events = captureTelemetry()
const entries = [managedEntry('session_start')]
_internals.setGrantSessionRunnerSync(() => grantedSessionResult(entries))
expect(grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({ lane: 'rpc' })
expect(events).toEqual([{ outcome: 'granted', hostKind: 'native', lane: 'real-home' }])
})
it('reports the managed lane independently of host kind', () => {
const events = captureTelemetry()
const entries = [managedEntry('session_start')]
_internals.setGrantSessionRunnerSync(() => grantedSessionResult(entries))
grantManagedCodexHookTrust({ ...buildPlan(entries), telemetryLane: 'managed' })
expect(events).toEqual([{ outcome: 'granted', hostKind: 'native', lane: 'managed' }])
})
it('classifies error fallbacks on the wire', () => {
const events = captureTelemetry()
const entries = [managedEntry('session_start')]
_internals.setGrantSessionRunnerSync(() => {
throw new Error('spawn codex ENOENT')
})
expect(grantManagedCodexHookTrust(buildPlan(entries))).toMatchObject({
lane: 'fallback',
reason: 'error'
})
expect(events).toEqual([
{
outcome: 'fallback',
hostKind: 'native',
lane: 'real-home',
reason: 'error',
errorClass: 'binary-missing'
}
])
})
it('carries the session verify class through the fallback event', () => {
const events = captureTelemetry()
const entries = [managedEntry('session_start')]
_internals.setGrantSessionRunnerSync(() => ({
outcome: 'verify-failed' as const,
reason: 'post-grant verify left 1 entries untrusted',
reasonClass: 'post-grant-untrusted' as const
}))
grantManagedCodexHookTrust(buildPlan(entries))
expect(events).toEqual([
{
outcome: 'verify_failed',
hostKind: 'native',
lane: 'real-home',
reason: 'verify-failed',
verifyClass: 'post-grant-untrusted'
}
])
})
it('classifies module-detected verify failures', () => {
const events = captureTelemetry()
const entries = [managedEntry('session_start'), managedEntry('stop')]
_internals.setGrantSessionRunnerSync(() => grantedSessionResult([entries[0]!, entries[0]!]))
grantManagedCodexHookTrust(buildPlan(entries))
expect(events).toEqual([
{
outcome: 'verify_failed',
hostKind: 'native',
lane: 'real-home',
reason: 'verify-failed',
verifyClass: 'duplicate-key'
}
])
})
})

View File

@ -3,6 +3,13 @@ import {
type CodexHookTrustGrantRequest,
type CodexHookTrustGrantSessionResult
} from './codex-app-server-client'
import {
classifyCodexTrustGrantError,
emitCodexTrustGrantTelemetry,
type CodexTrustGrantFallbackReason,
type CodexTrustGrantTelemetryLane,
type CodexTrustGrantVerifyClass
} from './codex-trust-grant-telemetry'
import { runCodexHookTrustGrantSessionSync } from './codex-app-server-grant-bridge'
import {
codexAppServerCapabilityCache,
@ -46,69 +53,31 @@ export type CodexManagedTrustGrantPlan = {
/** Managed trust identities Orca just wrote (no trustedHash). */
managedEntries: readonly CodexTrustEntry[]
host: CodexTrustGrantHost
telemetryLane: CodexTrustGrantTelemetryLane
/** Match a pane where CODEX_HOME is absent instead of an explicit managed home. */
useDefaultCodexHome?: boolean
}
export type CodexTrustGrantFallbackReason =
| 'disabled'
| 'no-managed-entries'
| 'unsupported'
| 'unsupported-cached'
| 'verify-failed'
| 'retry-cached'
| 'error'
export type { CodexTrustGrantFallbackReason, CodexTrustGrantTelemetryLane }
export type CodexManagedTrustGrantOutcome =
| { lane: 'rpc'; entries: CodexTrustEntry[] }
| { lane: 'fallback'; reason: CodexTrustGrantFallbackReason }
export type CodexTrustGrantDiagnostics = {
granted: number
ledgerHits: number
fellBack: number
verifyFailed: number
lastFallbackReason: CodexTrustGrantFallbackReason | null
}
const diagnostics: CodexTrustGrantDiagnostics = {
const diagnostics = {
granted: 0,
ledgerHits: 0,
fellBack: 0,
verifyFailed: 0,
lastFallbackReason: null
lastFallbackReason: null as CodexTrustGrantFallbackReason | null
}
export type CodexTrustGrantDiagnostics = typeof diagnostics
const transientRetryAfterByHost = new Map<string, number>()
export function getCodexTrustGrantDiagnostics(): CodexTrustGrantDiagnostics {
return { ...diagnostics }
}
type CodexTrustGrantTelemetry = (event: {
outcome: 'granted' | 'fallback' | 'verify_failed'
hostKind: 'native' | 'wsl'
reason?: CodexTrustGrantFallbackReason
}) => void
// Why: hook-service is bundled into plain-node CLI entries where electron
// (and therefore the telemetry client) cannot load; the Electron main process
// injects the tracker at startup instead of a static import.
let telemetry: CodexTrustGrantTelemetry = () => {}
export function setCodexTrustGrantTelemetry(tracker: CodexTrustGrantTelemetry): void {
telemetry = tracker
}
function emitTelemetry(event: Parameters<CodexTrustGrantTelemetry>[0]): void {
try {
telemetry(event)
} catch (error) {
// Why: observability must never turn a verified grant into fallback or
// violate this launch-prep API's no-throw contract.
console.warn('[codex-trust-grant] failed to emit telemetry', error)
}
}
type GrantSessionRunnerSync = (
request: CodexHookTrustGrantRequest
) => CodexHookTrustGrantSessionResult
@ -118,7 +87,8 @@ let runSessionSync: GrantSessionRunnerSync = runCodexHookTrustGrantSessionSync
function fallback(
plan: CodexManagedTrustGrantPlan,
reason: CodexTrustGrantFallbackReason,
detail?: unknown
detail?: unknown,
verifyClass?: CodexTrustGrantVerifyClass
): CodexManagedTrustGrantOutcome {
diagnostics.fellBack += 1
diagnostics.lastFallbackReason = reason
@ -129,10 +99,13 @@ function fallback(
`[codex-trust-grant] falling back to self-computed trust (reason=${reason}, host=${plan.host.kind})`,
detail ?? ''
)
emitTelemetry({
emitCodexTrustGrantTelemetry({
outcome: reason === 'verify-failed' ? 'verify_failed' : 'fallback',
hostKind: plan.host.kind,
reason
lane: plan.telemetryLane,
reason,
...(reason === 'error' ? { errorClass: classifyCodexTrustGrantError(detail) } : {}),
...(verifyClass !== undefined ? { verifyClass } : {})
})
return { lane: 'fallback', reason }
}
@ -272,7 +245,7 @@ export function grantManagedCodexHookTrust(
hostKey,
Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS
)
return fallback(plan, 'verify-failed', result.reason)
return fallback(plan, 'verify-failed', result.reason, result.reasonClass)
}
const byNormalizedKey = new Map(expected.map((item) => [item.normalizedKey, item]))
@ -287,7 +260,12 @@ export function grantManagedCodexHookTrust(
hostKey,
Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS
)
return fallback(plan, 'verify-failed', `unexpected granted key ${granted.key}`)
return fallback(
plan,
'verify-failed',
`unexpected granted key ${granted.key}`,
'unexpected-key'
)
}
if (seenNormalizedKeys.has(granted.normalizedKey)) {
restoreCodexTrustConfig(plan.tomlPath, configSnapshot)
@ -295,7 +273,12 @@ export function grantManagedCodexHookTrust(
hostKey,
Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS
)
return fallback(plan, 'verify-failed', `duplicate granted key ${granted.key}`)
return fallback(
plan,
'verify-failed',
`duplicate granted key ${granted.key}`,
'duplicate-key'
)
}
seenNormalizedKeys.add(granted.normalizedKey)
grantedEntries.push({ ...match.entry, trustedHash: granted.trustedHash })
@ -310,7 +293,12 @@ export function grantManagedCodexHookTrust(
hostKey,
Date.now() + CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS
)
return fallback(plan, 'verify-failed', 'granted entry set did not cover expected entries')
return fallback(
plan,
'verify-failed',
'granted entry set did not cover expected entries',
'coverage'
)
}
transientRetryAfterByHost.delete(hostKey)
try {
@ -327,7 +315,11 @@ export function grantManagedCodexHookTrust(
`[codex-trust-grant] granted ${grantedEntries.length} managed hook entries via codex app-server ` +
`(host=${plan.host.kind}, wrote=${result.wroteTrust}, ${Date.now() - startedAtMs}ms)`
)
emitTelemetry({ outcome: 'granted', hostKind: plan.host.kind })
emitCodexTrustGrantTelemetry({
outcome: 'granted',
hostKind: plan.host.kind,
lane: plan.telemetryLane
})
return { lane: 'rpc', entries: grantedEntries }
} catch (error) {
return fallback(plan, 'error', error)

View File

@ -196,6 +196,7 @@ function installRealHomeCodexHook(userDataPath: string): RealHomeCodexHookLane {
managedCommand: material.command,
managedEntries,
host: { kind: 'native' },
telemetryLane: 'real-home',
useDefaultCodexHome: true
})
if (grant.lane === 'rpc') {

View File

@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import { WSL_CODEX_NOT_FOUND_MESSAGE } from '../codex-accounts/wsl-codex-command'
import { CodexAppServerTimeoutError } from './codex-app-server-client'
import { classifyCodexTrustGrantError } from './codex-trust-grant-telemetry'
describe('classifyCodexTrustGrantError', () => {
it.each([
[new CodexAppServerTimeoutError('entry exceeded 20000ms session deadline'), 'timeout'],
[new Error('spawn codex ENOENT'), 'binary-missing'],
[new Error('spawn /Users/ada/.local/bin/codex ENOENT'), 'binary-missing'],
[
new Error(
`codex app-server exited before completing the session: ${WSL_CODEX_NOT_FOUND_MESSAGE}`
),
'binary-missing'
],
[new Error('spawn wsl.exe ENOENT'), 'unexpected'],
[
new Error("ENOENT: no such file or directory, open '/home/ada/.codex/config.toml'"),
'unexpected'
],
[new Error('codex trust-grant entry bundle not found'), 'entry-failed'],
[new Error('codex trust-grant entry produced no result (exit 1)'), 'entry-failed'],
[
new Error('codex app-server exited before completing the session: panicked at main.rs'),
'early-exit'
],
[new Error('codex app-server config/batchWrite failed: unknown key'), 'rpc-failed'],
[new Error('write EPIPE'), 'unexpected'],
['not an error object', 'unexpected']
] as const)('classifies %s as %s', (error, expected) => {
expect(classifyCodexTrustGrantError(error)).toBe(expected)
})
it('keeps an ENOENT-mentioning stderr tail classified as early-exit', () => {
expect(
classifyCodexTrustGrantError(
new Error('codex app-server exited before completing the session: ENOENT in codex output')
)
).toBe('early-exit')
})
})

View File

@ -0,0 +1,91 @@
import type { CodexTrustGrantSessionVerifyClass } from './codex-app-server-client'
import { WSL_CODEX_NOT_FOUND_MESSAGE } from '../codex-accounts/wsl-codex-command'
/** Which install surface asked for the grant: the system-default real ~/.codex
* or a managed (mirror/per-account) home. Telemetry attribution only the
* grant behaves identically; host_kind alone cannot distinguish the lanes
* (native hosts grant for both surfaces). */
export type CodexTrustGrantTelemetryLane = 'real-home' | 'managed'
export type CodexTrustGrantFallbackReason =
| 'disabled'
| 'no-managed-entries'
| 'unsupported'
| 'unsupported-cached'
| 'verify-failed'
| 'retry-cached'
| 'error'
/** Closed classification of `reason: 'error'` fallbacks. Errors cross the
* grant-bridge envelope as message text (only timeout/unsupported keep their
* name), so classes are matched on the bounded message shapes each layer
* produces never forwarded raw. */
export type CodexTrustGrantErrorClass =
| 'binary-missing'
| 'timeout'
| 'entry-failed'
| 'early-exit'
| 'rpc-failed'
| 'unexpected'
export type CodexTrustGrantVerifyClass =
| CodexTrustGrantSessionVerifyClass
| 'unexpected-key'
| 'duplicate-key'
| 'coverage'
export function classifyCodexTrustGrantError(error: unknown): CodexTrustGrantErrorClass {
if (!(error instanceof Error)) {
return 'unexpected'
}
if (error.name === 'CodexAppServerTimeoutError') {
return 'timeout'
}
const message = error.message
if (message.includes('codex trust-grant entry')) {
return 'entry-failed'
}
if (
/^spawn (?:.*[\\/])?codex(?:\.(?:cmd|exe|bat))? ENOENT$/.test(message) ||
message.includes(WSL_CODEX_NOT_FOUND_MESSAGE)
) {
return 'binary-missing'
}
if (message.includes('exited before completing the session')) {
return 'early-exit'
}
if (/codex app-server \S+ failed:/.test(message)) {
return 'rpc-failed'
}
return 'unexpected'
}
export type CodexTrustGrantTelemetryEvent = {
outcome: 'granted' | 'fallback' | 'verify_failed'
hostKind: 'native' | 'wsl'
lane: CodexTrustGrantTelemetryLane
reason?: CodexTrustGrantFallbackReason
errorClass?: CodexTrustGrantErrorClass
verifyClass?: CodexTrustGrantVerifyClass
}
type CodexTrustGrantTelemetry = (event: CodexTrustGrantTelemetryEvent) => void
// Why: hook-service is bundled into plain-node CLI entries where electron
// (and therefore the telemetry client) cannot load; the Electron main process
// injects the tracker at startup instead of a static import.
let telemetry: CodexTrustGrantTelemetry = () => {}
export function setCodexTrustGrantTelemetry(tracker: CodexTrustGrantTelemetry): void {
telemetry = tracker
}
export function emitCodexTrustGrantTelemetry(event: CodexTrustGrantTelemetryEvent): void {
try {
telemetry(event)
} catch (error) {
// Why: observability must never turn a verified grant into fallback or
// violate the launch-prep no-throw contract of the grant lane.
console.warn('[codex-trust-grant] failed to emit telemetry', error)
}
}

View File

@ -916,7 +916,8 @@ function installManagedHooksIntoWslRuntime(
tomlPath: plan.tomlPath,
managedCommand: command,
managedEntries: trustEntries,
host: { kind: 'wsl', distro: plan.wslDistro, linuxRuntimeHome: plan.linuxRuntimeHome }
host: { kind: 'wsl', distro: plan.wslDistro, linuxRuntimeHome: plan.linuxRuntimeHome },
telemetryLane: 'managed'
})
if (grant.lane === 'fallback') {
// Why: WSL runtime homes may carry user hook approvals we did not rebuild
@ -1353,7 +1354,8 @@ export class CodexHookService {
tomlPath,
managedCommand: command,
managedEntries: managedTrustEntries,
host: { kind: 'native' }
host: { kind: 'native' },
telemetryLane: 'managed'
})
if (grant.lane === 'rpc') {
recentGrantEntries = grant.entries

View File

@ -150,7 +150,7 @@ import {
ensureRealHomeCodexHookState,
isRealHomeCodexHookLaneUsable
} from './codex/codex-real-home-hook-install'
import { setCodexTrustGrantTelemetry } from './codex/codex-hook-trust-grant'
import { setCodexTrustGrantTelemetry } from './codex/codex-trust-grant-telemetry'
import { startCodexSessionBackfillInBackground } from './codex/codex-session-backfill'
import { startCodexSessionIndexHealInBackground } from './codex/codex-session-index-heal'
import { createCodexSessionMigrationScheduler } from './codex/codex-session-migration-scheduler'
@ -1852,11 +1852,14 @@ app.whenReady().then(async () => {
// Why: the trust-grant module is bundled into plain-node CLI entries where
// the telemetry client cannot load, so the tracker is injected here instead
// of imported there.
setCodexTrustGrantTelemetry(({ outcome, hostKind, reason }) => {
setCodexTrustGrantTelemetry(({ outcome, hostKind, lane, reason, errorClass, verifyClass }) => {
track('codex_trust_grant', {
outcome,
host_kind: hostKind,
...(reason !== undefined ? { fallback_reason: reason } : {})
lane,
...(reason !== undefined ? { fallback_reason: reason } : {}),
...(errorClass !== undefined ? { error_class: errorClass } : {}),
...(verifyClass !== undefined ? { verify_class: verifyClass } : {})
})
})
// Why: the error-tracking lane (telemetry-error-tracking.md) is its own

View File

@ -367,11 +367,15 @@ const daemonStartFailedSchema = z.object({ error_class: errorClassSchema }).stri
// Rollout signal for granting Codex hook trust via codex app-server RPCs
// instead of Orca's self-computed trusted_hash. `fallback`/`verify_failed`
// spikes mean the RPC lane is not taking; steady-state ledger skips are not
// reported (they would only measure launch volume).
// reported (they would only measure launch volume). `lane` attributes the
// grant surface (real ~/.codex vs managed home); `error_class`/`verify_class`
// are closed classifications so `error` fallbacks are diagnosable in the
// field — e.g. `binary-missing` = codex CLI absent, no rollout impact.
const codexTrustGrantSchema = z
.object({
outcome: z.enum(['granted', 'fallback', 'verify_failed']),
host_kind: z.enum(['native', 'wsl']),
lane: z.enum(['real-home', 'managed']),
fallback_reason: z
.enum([
'disabled',
@ -382,6 +386,19 @@ const codexTrustGrantSchema = z
'retry-cached',
'error'
])
.optional(),
error_class: z
.enum(['binary-missing', 'timeout', 'entry-failed', 'early-exit', 'rpc-failed', 'unexpected'])
.optional(),
verify_class: z
.enum([
'list-mismatch',
'post-grant-untrusted',
'post-grant-mismatch',
'unexpected-key',
'duplicate-key',
'coverage'
])
.optional()
})
.strict()