Skip to content

Commit 8483884

Browse files
authored
Merge pull request #2 from Aditya292248/pr2-verdict
Add the verdict state machine
2 parents 9d585e3 + 164e0fa commit 8483884

5 files changed

Lines changed: 437 additions & 13 deletions

File tree

‎ARCHITECTURE.md‎

Lines changed: 64 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,73 @@
55
The verifier watches for an in-progress AgentPhone call to one configured number. For that
66
call it asks the platform which agent placed it and which sub-account owns that agent; that
77
platform record is the only source of identity ([ADR 0001](docs/adr/0001-trust-anchor.md)).
8-
It then compares the owner with whoever the caller claims to represent, computes a verdict,
9-
signs it, and notifies the person being called. Every step that fails, times out or is
10-
ambiguous ends in `UNVERIFIED` ([ADR 0002](docs/adr/0002-fail-closed.md)).
8+
As soon as the owner is known, the person being called is told whose agent it is, whether or
9+
not the agent ever says. If the agent later claims to represent someone else, a second
10+
message warns them. Every step that fails, times out or is ambiguous ends in `UNVERIFIED`
11+
([ADR 0002](docs/adr/0002-fail-closed.md)).
1112

12-
Only the skeleton exists so far: config, logging and a health endpoint.
13+
So far there is config, logging, a health endpoint and the verdict state machine. Nothing
14+
feeds the state machine real events yet.
1315

1416
## Code map
1517

16-
| Path | Role |
17-
| --------------- | ----------------------------------------------------------------------- |
18-
| `src/config.ts` | Parses and validates the environment. The only reader of `process.env`. |
19-
| `src/log.ts` | One-line JSON event log, keyed by call ID. |
20-
| `src/server.ts` | Entry point. `node:http` server; `GET /health` only for now. |
21-
| `test/` | `node:test` suites, run through `tsx`. |
22-
| `docs/adr/` | Decision records. |
18+
| Path | Role |
19+
| ---------------- | ----------------------------------------------------------------------- |
20+
| `src/config.ts` | Parses and validates the environment. The only reader of `process.env`. |
21+
| `src/log.ts` | One-line JSON event log, keyed by call ID. |
22+
| `src/server.ts` | Entry point. `node:http` server; `GET /health` only for now. |
23+
| `src/types.ts` | Lookup results, claims, verdict states, events and reason codes. |
24+
| `src/verdict.ts` | `nextVerdict(prev, event, aliases)`: the pure verdict state machine. |
25+
| `test/` | `node:test` suites, run through `tsx`. |
26+
| `docs/adr/` | Decision records. |
2327

24-
Planned: the AgentPhone client, call lookup, transcript stream, claim extraction, the verdict
25-
state machine, token signing and the notifier, each in its own module under `src/`.
28+
Planned: the AgentPhone client, call lookup, transcript stream, claim extraction, token
29+
signing and the notifier, each in its own module under `src/`.
30+
31+
## Verdict state machine
32+
33+
Every call starts in `IDENTIFYING`. Events are a lookup result, a claim, a deadline (`lookup`
34+
or `claim`) or an error; `nextVerdict` maps the current state and one event to the next state.
35+
36+
```
37+
IDENTIFYING ──one match──▶ OWNER_KNOWN ──claim names owner─────────▶ VERIFIED
38+
│ ├──claim names someone else──▶ MISMATCH
39+
│ ├──claim deadline────────────▶ VERIFIED_NO_CLAIM
40+
│ └──error─────────────────────▶ UNVERIFIED
41+
└──none / ambiguous / error / lookup deadline──▶ UNVERIFIED
42+
43+
VERIFIED, VERIFIED_NO_CLAIM ──claim names someone else──▶ MISMATCH
44+
```
45+
46+
- Claims heard in `IDENTIFYING` are kept and checked when the owner is known: one match plus
47+
any wrong claim goes straight to `MISMATCH`.
48+
- In `VERIFIED` and `VERIFIED_NO_CLAIM`, only a claim for someone else changes anything.
49+
- `MISMATCH` and `UNVERIFIED` are final. Events that do not apply to the current state, such
50+
as a late lookup deadline, are ignored.
51+
52+
Its invariants, each checked over every sequence of up to three events in
53+
`test/verdict.test.ts`:
54+
55+
1. **Fail closed.** No error, timeout, ambiguity or missing call yields `VERIFIED` or
56+
`VERIFIED_NO_CLAIM` ([ADR 0002](docs/adr/0002-fail-closed.md)).
57+
2. **Monotonic.** Once `MISMATCH` or `UNVERIFIED`, the verdict never changes.
58+
3. **Claims only downgrade.** A claim never creates trust: its only upward move is
59+
`OWNER_KNOWN` to `VERIFIED`, and only for the owner's name or alias
60+
([ADR 0003](docs/adr/0003-claim-only-downgrades.md)).
61+
62+
### Notifications
63+
64+
The state machine only computes verdicts; the notifier (not built yet) decides what to send.
65+
66+
| Entering | Message to the person being called |
67+
| --------------- | --------------------------------------------------------------- |
68+
| `OWNER_KNOWN` | Disclosure: whose agent is calling, with a link to the proof |
69+
| `MISMATCH` | Warning: the agent claimed someone other than its owner |
70+
| Any other state | Nothing yet; whether `UNVERIFIED` sends a message is still open |
71+
72+
A wrong claim heard before the lookup finishes goes straight from `IDENTIFYING` to
73+
`MISMATCH`, so the notifier sends the disclosure and the warning together. For the demo, each
74+
message is a proof-page link printed to the terminal; SMS or iMessage comes later.
2675

2776
## Invariants
2877

@@ -33,6 +82,8 @@ state machine, token signing and the notifier, each in its own module under `src
3382
- Caller ID and anything the caller says are untrusted input. They can lower a verdict but
3483
never raise one.
3584
- The verdict for a call never moves from `MISMATCH` or `UNVERIFIED` back to `VERIFIED`.
85+
- `src/verdict.ts` does no I/O and reads no clock. Timers live in the caller and arrive as
86+
deadline events.
3687

3788
## Intentional oddities
3889

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# 0003. What the caller claims can only lower the verdict
2+
3+
- Status: Accepted
4+
- Date: 2026-11-01
5+
6+
## Context
7+
8+
An agent usually says who it is calling for: "I'm Aditya's assistant". That sentence is the
9+
most natural thing to show the person being called, and also the easiest to fake. Anyone can
10+
say any name, and a transcript can be wrong. The platform's call record, which links the
11+
calling agent to the sub-account that owns it, is the only thing the caller cannot choose
12+
([ADR 0001](0001-trust-anchor.md)).
13+
14+
The person being called is told whose agent it is as soon as the owner is known, so a claim
15+
is never needed. Still, an agent that says "I'm calling on behalf of Alex" while owned by
16+
someone else is the most direct form of deception, and the callee may not compare the two
17+
names themselves. If a claim could raise the verdict, a caller would control part of the
18+
outcome. If claims are ignored completely, that lie goes unflagged.
19+
20+
## Decision
21+
22+
- Trust comes only from the owner lookup. A claim never creates it.
23+
- A claim is compared with the owner's name and that owner's aliases, keyed by sub-account
24+
ID. The match is exact and case-insensitive, with no partial or fuzzy matching.
25+
- A claim for anyone else moves the verdict to `MISMATCH`, from `OWNER_KNOWN`, `VERIFIED` or
26+
`VERIFIED_NO_CLAIM`. A claim heard before the lookup finishes is kept and checked as soon
27+
as the owner is known, so an early claim cannot be lost.
28+
- A claim that matches the owner can take `OWNER_KNOWN` to `VERIFIED`, and nothing further.
29+
It never lifts `VERIFIED_NO_CLAIM`, `MISMATCH` or `UNVERIFIED`.
30+
- If several claims are heard, one wrong claim outweighs any number of right ones.
31+
32+
## Consequences
33+
34+
- `VERIFIED` and `VERIFIED_NO_CLAIM` are not final: a later claim for someone else still
35+
downgrades them. Only `MISMATCH` and `UNVERIFIED` never change.
36+
- An agent that never names anyone gets `VERIFIED_NO_CLAIM`, not `VERIFIED`. The owner is
37+
still disclosed, so the callee knows whose agent it is. The agent never has to do anything
38+
for the check: claims are read passively from the call.
39+
- A real nickname that is not in the alias list shows as `MISMATCH`. A false warning is the
40+
accepted cost of never showing a false check mark.
41+
- Missing a claim (for example, an extraction miss) can only leave the verdict at
42+
`VERIFIED_NO_CLAIM`. It can never produce `VERIFIED` for the wrong person.

‎src/types.ts‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Shared shapes for the verdict pipeline. Types only, no logic.
2+
3+
/** Why a call ended UNVERIFIED. */
4+
export type ReasonCode =
5+
| 'lookup_timeout'
6+
| 'ambiguous'
7+
| 'api_error'
8+
| 'no_agentphone_call'
9+
| 'transcript_error'
10+
// Sent as an error event by the orchestrator when claim extraction itself times out.
11+
| 'claim_timeout';
12+
13+
/** The one in-progress AgentPhone call that matched, with the sub-account that owns its agent. */
14+
export type CallRecord = {
15+
kind: 'one';
16+
callId: string;
17+
agentId: string;
18+
ownerSub: string;
19+
ownerName: string;
20+
from: string;
21+
to: string;
22+
startedAt: string;
23+
};
24+
25+
export type LookupResult =
26+
| CallRecord
27+
| { kind: 'none' }
28+
| { kind: 'ambiguous'; count: number }
29+
| { kind: 'error'; reason: ReasonCode };
30+
31+
/** What the caller said they represent. Untrusted: it can only lower a verdict. */
32+
export type ClaimResult = { kind: 'claimed'; principal: string } | { kind: 'none' };
33+
34+
export type Verdict =
35+
// Claims heard before the lookup finishes are kept, so an early lie is not lost.
36+
| { state: 'IDENTIFYING'; claims: readonly string[] }
37+
| { state: 'OWNER_KNOWN'; owner: CallRecord }
38+
| { state: 'VERIFIED'; owner: CallRecord; claimed: string }
39+
| { state: 'VERIFIED_NO_CLAIM'; owner: CallRecord }
40+
| { state: 'MISMATCH'; owner: CallRecord; claimed: string }
41+
| { state: 'UNVERIFIED'; reason: ReasonCode };
42+
43+
export type VerdictEvent =
44+
| { type: 'lookup'; result: LookupResult }
45+
| { type: 'claim'; result: ClaimResult }
46+
// Two separate timers: the lookup must finish in time, then the claim window closes.
47+
| { type: 'deadline'; which: 'lookup' | 'claim' }
48+
| { type: 'error'; reason: ReasonCode };

‎src/verdict.ts‎

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
// The verdict state machine. Pure: no I/O, no clock, so every path can be tested.
2+
import type { CallRecord, LookupResult, ReasonCode, Verdict, VerdictEvent } from './types.js';
3+
4+
/** Extra names per owner, keyed by sub-account ID (the identity anchor), not by name. */
5+
export type Aliases = ReadonlyMap<string, readonly string[]>;
6+
7+
export const initialVerdict: Verdict = { state: 'IDENTIFYING', claims: [] };
8+
9+
type State<S extends Verdict['state']> = Extract<Verdict, { state: S }>;
10+
11+
export function nextVerdict(prev: Verdict, event: VerdictEvent, aliases: Aliases): Verdict {
12+
switch (prev.state) {
13+
case 'IDENTIFYING':
14+
return fromIdentifying(prev, event, aliases);
15+
case 'OWNER_KNOWN':
16+
return fromOwnerKnown(prev, event, aliases);
17+
case 'VERIFIED':
18+
case 'VERIFIED_NO_CLAIM':
19+
// A later claim for someone else still downgrades. Nothing else changes an earned verdict.
20+
if (event.type === 'claim' && event.result.kind === 'claimed') {
21+
const after = withOwner(prev.owner, [event.result.principal], aliases);
22+
if (after.state === 'MISMATCH') return after;
23+
}
24+
return prev;
25+
case 'MISMATCH':
26+
case 'UNVERIFIED':
27+
return prev;
28+
}
29+
}
30+
31+
function fromIdentifying(
32+
prev: State<'IDENTIFYING'>,
33+
event: VerdictEvent,
34+
aliases: Aliases,
35+
): Verdict {
36+
switch (event.type) {
37+
case 'lookup':
38+
return fromLookup(event.result, prev.claims, aliases);
39+
case 'claim':
40+
if (event.result.kind === 'none') return prev;
41+
return { state: 'IDENTIFYING', claims: [...prev.claims, event.result.principal] };
42+
case 'deadline':
43+
// The claim window only starts once the owner is known.
44+
return event.which === 'lookup' ? unverified('lookup_timeout') : prev;
45+
case 'error':
46+
return unverified(event.reason);
47+
}
48+
}
49+
50+
function fromLookup(result: LookupResult, claims: readonly string[], aliases: Aliases): Verdict {
51+
switch (result.kind) {
52+
case 'one':
53+
return withOwner(result, claims, aliases);
54+
case 'none':
55+
return unverified('no_agentphone_call');
56+
case 'ambiguous':
57+
return unverified('ambiguous');
58+
case 'error':
59+
return unverified(result.reason);
60+
}
61+
}
62+
63+
function fromOwnerKnown(
64+
prev: State<'OWNER_KNOWN'>,
65+
event: VerdictEvent,
66+
aliases: Aliases,
67+
): Verdict {
68+
switch (event.type) {
69+
case 'claim':
70+
if (event.result.kind === 'none') return prev;
71+
return withOwner(prev.owner, [event.result.principal], aliases);
72+
case 'deadline':
73+
if (event.which === 'lookup') return prev;
74+
return { state: 'VERIFIED_NO_CLAIM', owner: prev.owner };
75+
case 'error':
76+
return unverified(event.reason);
77+
case 'lookup':
78+
// Out of order: the owner is already known, and a second lookup cannot replace it.
79+
return prev;
80+
}
81+
}
82+
83+
/** Any claim naming someone other than the owner wins: MISMATCH beats VERIFIED. */
84+
function withOwner(owner: CallRecord, claims: readonly string[], aliases: Aliases): Verdict {
85+
const wrong = claims.find((claimed) => !isOwner(owner, claimed, aliases));
86+
if (wrong !== undefined) return { state: 'MISMATCH', owner, claimed: wrong };
87+
const right = claims[0];
88+
if (right !== undefined) return { state: 'VERIFIED', owner, claimed: right };
89+
return { state: 'OWNER_KNOWN', owner };
90+
}
91+
92+
// Exact and case-insensitive. No partial or fuzzy match: a false MISMATCH is safer than a
93+
// false VERIFIED.
94+
function isOwner(owner: CallRecord, claimed: string, aliases: Aliases): boolean {
95+
const names = [owner.ownerName, ...(aliases.get(owner.ownerSub) ?? [])];
96+
const want = normalize(claimed);
97+
return names.some((name) => normalize(name) === want);
98+
}
99+
100+
function normalize(name: string): string {
101+
return name.trim().toLowerCase();
102+
}
103+
104+
function unverified(reason: ReasonCode): Verdict {
105+
return { state: 'UNVERIFIED', reason };
106+
}

0 commit comments

Comments
 (0)