Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

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

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

## Code map

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

Planned: the AgentPhone client, call lookup, transcript stream, claim extraction, the verdict
state machine, token signing and the notifier, each in its own module under `src/`.
Planned: the AgentPhone client, call lookup, transcript stream, claim extraction, token
signing and the notifier, each in its own module under `src/`.

## Verdict state machine

Every call starts in `IDENTIFYING`. Events are a lookup result, a claim, a deadline (`lookup`
or `claim`) or an error; `nextVerdict` maps the current state and one event to the next state.

```
IDENTIFYING ──one match──▶ OWNER_KNOWN ──claim names owner─────────▶ VERIFIED
│ ├──claim names someone else──▶ MISMATCH
│ ├──claim deadline────────────▶ VERIFIED_NO_CLAIM
│ └──error─────────────────────▶ UNVERIFIED
└──none / ambiguous / error / lookup deadline──▶ UNVERIFIED

VERIFIED, VERIFIED_NO_CLAIM ──claim names someone else──▶ MISMATCH
```

- Claims heard in `IDENTIFYING` are kept and checked when the owner is known: one match plus
any wrong claim goes straight to `MISMATCH`.
- In `VERIFIED` and `VERIFIED_NO_CLAIM`, only a claim for someone else changes anything.
- `MISMATCH` and `UNVERIFIED` are final. Events that do not apply to the current state, such
as a late lookup deadline, are ignored.

Its invariants, each checked over every sequence of up to three events in
`test/verdict.test.ts`:

1. **Fail closed.** No error, timeout, ambiguity or missing call yields `VERIFIED` or
`VERIFIED_NO_CLAIM` ([ADR 0002](docs/adr/0002-fail-closed.md)).
2. **Monotonic.** Once `MISMATCH` or `UNVERIFIED`, the verdict never changes.
3. **Claims only downgrade.** A claim never creates trust: its only upward move is
`OWNER_KNOWN` to `VERIFIED`, and only for the owner's name or alias
([ADR 0003](docs/adr/0003-claim-only-downgrades.md)).

### Notifications

The state machine only computes verdicts; the notifier (not built yet) decides what to send.

| Entering | Message to the person being called |
| --------------- | --------------------------------------------------------------- |
| `OWNER_KNOWN` | Disclosure: whose agent is calling, with a link to the proof |
| `MISMATCH` | Warning: the agent claimed someone other than its owner |
| Any other state | Nothing yet; whether `UNVERIFIED` sends a message is still open |

A wrong claim heard before the lookup finishes goes straight from `IDENTIFYING` to
`MISMATCH`, so the notifier sends the disclosure and the warning together. For the demo, each
message is a proof-page link printed to the terminal; SMS or iMessage comes later.

## Invariants

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

## Intentional oddities

Expand Down
42 changes: 42 additions & 0 deletions docs/adr/0003-claim-only-downgrades.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# 0003. What the caller claims can only lower the verdict

- Status: Accepted
- Date: 2026-11-01

## Context

An agent usually says who it is calling for: "I'm Aditya's assistant". That sentence is the
most natural thing to show the person being called, and also the easiest to fake. Anyone can
say any name, and a transcript can be wrong. The platform's call record, which links the
calling agent to the sub-account that owns it, is the only thing the caller cannot choose
([ADR 0001](0001-trust-anchor.md)).

The person being called is told whose agent it is as soon as the owner is known, so a claim
is never needed. Still, an agent that says "I'm calling on behalf of Alex" while owned by
someone else is the most direct form of deception, and the callee may not compare the two
names themselves. If a claim could raise the verdict, a caller would control part of the
outcome. If claims are ignored completely, that lie goes unflagged.

## Decision

- Trust comes only from the owner lookup. A claim never creates it.
- A claim is compared with the owner's name and that owner's aliases, keyed by sub-account
ID. The match is exact and case-insensitive, with no partial or fuzzy matching.
- A claim for anyone else moves the verdict to `MISMATCH`, from `OWNER_KNOWN`, `VERIFIED` or
`VERIFIED_NO_CLAIM`. A claim heard before the lookup finishes is kept and checked as soon
as the owner is known, so an early claim cannot be lost.
- A claim that matches the owner can take `OWNER_KNOWN` to `VERIFIED`, and nothing further.
It never lifts `VERIFIED_NO_CLAIM`, `MISMATCH` or `UNVERIFIED`.
- If several claims are heard, one wrong claim outweighs any number of right ones.

## Consequences

- `VERIFIED` and `VERIFIED_NO_CLAIM` are not final: a later claim for someone else still
downgrades them. Only `MISMATCH` and `UNVERIFIED` never change.
- An agent that never names anyone gets `VERIFIED_NO_CLAIM`, not `VERIFIED`. The owner is
still disclosed, so the callee knows whose agent it is. The agent never has to do anything
for the check: claims are read passively from the call.
- A real nickname that is not in the alias list shows as `MISMATCH`. A false warning is the
accepted cost of never showing a false check mark.
- Missing a claim (for example, an extraction miss) can only leave the verdict at
`VERIFIED_NO_CLAIM`. It can never produce `VERIFIED` for the wrong person.
48 changes: 48 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Shared shapes for the verdict pipeline. Types only, no logic.

/** Why a call ended UNVERIFIED. */
export type ReasonCode =
| 'lookup_timeout'
| 'ambiguous'
| 'api_error'
| 'no_agentphone_call'
| 'transcript_error'
// Sent as an error event by the orchestrator when claim extraction itself times out.
| 'claim_timeout';

/** The one in-progress AgentPhone call that matched, with the sub-account that owns its agent. */
export type CallRecord = {
kind: 'one';
callId: string;
agentId: string;
ownerSub: string;
ownerName: string;
from: string;
to: string;
startedAt: string;
};

export type LookupResult =
| CallRecord
| { kind: 'none' }
| { kind: 'ambiguous'; count: number }
| { kind: 'error'; reason: ReasonCode };

/** What the caller said they represent. Untrusted: it can only lower a verdict. */
export type ClaimResult = { kind: 'claimed'; principal: string } | { kind: 'none' };

export type Verdict =
// Claims heard before the lookup finishes are kept, so an early lie is not lost.
| { state: 'IDENTIFYING'; claims: readonly string[] }
| { state: 'OWNER_KNOWN'; owner: CallRecord }
| { state: 'VERIFIED'; owner: CallRecord; claimed: string }
| { state: 'VERIFIED_NO_CLAIM'; owner: CallRecord }
| { state: 'MISMATCH'; owner: CallRecord; claimed: string }
| { state: 'UNVERIFIED'; reason: ReasonCode };

export type VerdictEvent =
| { type: 'lookup'; result: LookupResult }
| { type: 'claim'; result: ClaimResult }
// Two separate timers: the lookup must finish in time, then the claim window closes.
| { type: 'deadline'; which: 'lookup' | 'claim' }
| { type: 'error'; reason: ReasonCode };
106 changes: 106 additions & 0 deletions src/verdict.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// The verdict state machine. Pure: no I/O, no clock, so every path can be tested.
import type { CallRecord, LookupResult, ReasonCode, Verdict, VerdictEvent } from './types.js';

/** Extra names per owner, keyed by sub-account ID (the identity anchor), not by name. */
export type Aliases = ReadonlyMap<string, readonly string[]>;

export const initialVerdict: Verdict = { state: 'IDENTIFYING', claims: [] };

type State<S extends Verdict['state']> = Extract<Verdict, { state: S }>;

export function nextVerdict(prev: Verdict, event: VerdictEvent, aliases: Aliases): Verdict {
switch (prev.state) {
case 'IDENTIFYING':
return fromIdentifying(prev, event, aliases);
case 'OWNER_KNOWN':
return fromOwnerKnown(prev, event, aliases);
case 'VERIFIED':
case 'VERIFIED_NO_CLAIM':
// A later claim for someone else still downgrades. Nothing else changes an earned verdict.
if (event.type === 'claim' && event.result.kind === 'claimed') {
const after = withOwner(prev.owner, [event.result.principal], aliases);
if (after.state === 'MISMATCH') return after;
}
return prev;
case 'MISMATCH':
case 'UNVERIFIED':
return prev;
}
}

function fromIdentifying(
prev: State<'IDENTIFYING'>,
event: VerdictEvent,
aliases: Aliases,
): Verdict {
switch (event.type) {
case 'lookup':
return fromLookup(event.result, prev.claims, aliases);
case 'claim':
if (event.result.kind === 'none') return prev;
return { state: 'IDENTIFYING', claims: [...prev.claims, event.result.principal] };
case 'deadline':
// The claim window only starts once the owner is known.
return event.which === 'lookup' ? unverified('lookup_timeout') : prev;
case 'error':
return unverified(event.reason);
}
}

function fromLookup(result: LookupResult, claims: readonly string[], aliases: Aliases): Verdict {
switch (result.kind) {
case 'one':
return withOwner(result, claims, aliases);
case 'none':
return unverified('no_agentphone_call');
case 'ambiguous':
return unverified('ambiguous');
case 'error':
return unverified(result.reason);
}
}

function fromOwnerKnown(
prev: State<'OWNER_KNOWN'>,
event: VerdictEvent,
aliases: Aliases,
): Verdict {
switch (event.type) {
case 'claim':
if (event.result.kind === 'none') return prev;
return withOwner(prev.owner, [event.result.principal], aliases);
case 'deadline':
if (event.which === 'lookup') return prev;
return { state: 'VERIFIED_NO_CLAIM', owner: prev.owner };
case 'error':
return unverified(event.reason);
case 'lookup':
// Out of order: the owner is already known, and a second lookup cannot replace it.
return prev;
}
}

/** Any claim naming someone other than the owner wins: MISMATCH beats VERIFIED. */
function withOwner(owner: CallRecord, claims: readonly string[], aliases: Aliases): Verdict {
const wrong = claims.find((claimed) => !isOwner(owner, claimed, aliases));
if (wrong !== undefined) return { state: 'MISMATCH', owner, claimed: wrong };
const right = claims[0];
if (right !== undefined) return { state: 'VERIFIED', owner, claimed: right };
return { state: 'OWNER_KNOWN', owner };
}

// Exact and case-insensitive. No partial or fuzzy match: a false MISMATCH is safer than a
// false VERIFIED.
function isOwner(owner: CallRecord, claimed: string, aliases: Aliases): boolean {
const names = [owner.ownerName, ...(aliases.get(owner.ownerSub) ?? [])];
const want = normalize(claimed);
return names.some((name) => normalize(name) === want);
}

function normalize(name: string): string {
return name.trim().toLowerCase();
}

function unverified(reason: ReasonCode): Verdict {
return { state: 'UNVERIFIED', reason };
}
Loading
Loading