# Part 14 — linking a device, and approving one

[Part 5](https://docs.thinkingmachinelabs.dev/argala/guide/devices-and-key-custody.md) said a second device shows up with a
key gap and receives the key on the next sync. That is the mechanism. This
part covers the ceremony around it: who decides that the device in the gap is
yours, and what your app displays while that decision is pending.

The question matters because the relay publishes the roster. It has never held
a graph key and cannot mint one, but it can **add a row**: a device ID it made
up, with a wrapping key whose private half it holds. Every honest device then
sees a member with a gap and, being helpful, fills it. The graph key leaves the
fleet without a single byte of ciphertext being broken.

More importantly, a planted device can wait. It does not need an immediate
grant; it can remain in the roster until the next `rotate()`. Rotation is the
response to a stolen laptop and therefore the worst possible moment to hand
out keys.

Every wrap to another device therefore passes through the same gate, whether
creating a graph, filling a gap, or rotating.

## Three ways a device gets approved

```mermaid
flowchart TD
  D[a device appears in the roster] --> P{pinned here already?}
  P -- "yes, fingerprint matches" --> W[wrap the key to it]
  P -- "yes, fingerprint changed" --> C[pin: conflicted<br/>device.conflict<br/>never wrapped again]
  P -- no --> POP{has it written a record<br/>this device opened and verified,<br/>and does its selfSig check out?}
  POP -- yes --> AP[pin: approved, via 'possession'] --> W
  POP -- no --> H[device.pending<br/>show the code]
  H --> HU{a human compares<br/>twelve characters}
  HU -- approve --> AH[pin: approved, via 'human'] --> W
  HU -- reject --> R[pin: rejected — silent, no repeat prompt]
```

**Itself, on first run.** A device pins its own keys so that creating a graph
can wrap to it. Trust on first use; there is no in-band fix for that.

**Possession.** If this device has opened a record that device sealed under a
graph key _and_ verified its signature under the signing key the roster
publishes for it, that device demonstrably held the key. The relay cannot fake
this: the signature lives inside ciphertext it cannot produce. The device's
`selfSig` — a self-signature over its own fingerprint — closes the other half
by binding the published _wrapping_ key to the same device. When both checks
pass, the device is pinned without a prompt.

This is what makes the gate practical. Without it, every already-linked device,
every invited member and every recovered laptop would raise a prompt on upgrade
day. A product that asks a hundred questions on Monday teaches users to ignore
its prompts. With proof of possession, a prompt means something arrived that
nothing else could vouch for.

**A human.** Everything else. The joining device shows its own code; an
approver shows what it computed from what the relay published; a person
compares two strings.

## The joining side

```ts
import { formatPairingCode } from '@argala/client';

const engine = await ArgalaEngine.open({ ...opts, deviceLabel: "Sam's iPad" });
const code = await engine.myPairingCode(); // "k4h29tbex0mr"
show(formatPairingCode(code)); // "k4h2 · 9tbe · x0mr"
```

The engine returns the raw twelve characters because two devices comparing
strings should compare the same string. `formatPairingCode` is for the screen.

`myPairingCode()` touches nothing the relay said — it is derived from this
device's own keys. That is the whole point: the approver derives the same
twelve characters from what the relay _published_, and a relay in the middle
cannot make the two agree without a preimage search against a PBKDF2 stretch.

Show it next to `status.graphs['g:acme'].key === 'waiting'` so the UI can say
"sealed, not empty".

## The approving side

```ts
engine.on('device.pending', async (e) => {
  // e.deviceId, e.memberId, e.fingerprint
  const pending = await engine.pendingDevices('g:acme');
  showApprovalSheet(pending); // each: { deviceId, memberId, label, fingerprint, code, conflicted }
});

// the human tapped yes, having compared the code
await engine.approveDevice(deviceId, fingerprint);
await engine.syncGraph('g:acme'); // the wrap goes out on the next sync
```

`approveDevice` takes the fingerprint rather than looking it up, so that what
gets approved is what the human was shown — not whatever the relay is serving
by the time the tap lands.

Codes are derived only in `pendingDevices()` and `myPairingCode()`, never on a
sync path. The PBKDF2 stretch is an appropriate cost when a person is about to
read twelve characters from a screen, but not a cost to pay for every device
on every sync.

## Events, and what to draw

| event / state                      | means                                                      | show                                             |
| ---------------------------------- | ---------------------------------------------------------- | ------------------------------------------------ |
| `device.pending`                   | a device nothing can vouch for is waiting                  | an approval prompt, once per device per boot     |
| `device.conflict`                  | keys published for an approved device changed              | **an incident** — this has no benign explanation |
| `status.linking.mode`              | `'observe'` or `'approve'`                                 | a setup warning while `observe`                  |
| `status.linking.pending`           | how many are still unanswered                              | a badge; approve or reject clears it             |
| `error` scope `linking.unenforced` | keys were handed out unapproved, because mode is `observe` | log it; it fires once per boot                   |

A `conflicted` pin blocks that device forever. That is deliberate — the relay
uses set-once device IDs, so there is no benign way for a device's
published keys to change. It is also why `forgetDevice()` exists: there has to
be a path from any state back to "ask me again", or a relay that rewrote one
row could brick an honest laptop with no way out.

```ts
engine.trustedDevices(); // [{ deviceId, via: 'human' | 'possession' | 'self', state, at, ... }]
await engine.rejectDevice(deviceId); // terminal and silent
await engine.forgetDevice(deviceId); // evaluate it again
```

Show `via` on a "devices you trust" screen. A person should be able to see
which of these a human actually looked at and which were inferred.

## Turning it on

```ts
await ArgalaEngine.open({ ...opts, linking: 'approve' });
```

**The default is `observe`, which reports and hands the key over anyway.** That
is the weaker setting and it is deliberate, for one release: an older relay
drops the `excluding` field on a key put and then refuses the rotation as
incomplete. Flipping the default before relays upgrade would break revocation,
which is worse than the hole it closes. `status.linking` and the once-per-boot
`linking.unenforced` error exist so a deployment cannot quietly forget it is
running unenforced.

Under `approve`, a device nobody approved is simply left out — named in
`excluding` on the key put, so the relay accepts the rotation as complete
rather than deadlocking on it. **The gate never throws.** A device nobody has
approved must not be able to stop a rotation, or the gate becomes a way to
disable the only remedy argala has for a compromised device.

## What this does not fix

- **A hurried human.** Twelve characters compared by somebody who wants to get
  on with their day provide a meaningful but imperfect defence. Put the label
  and the member next to the code, and make rejecting easy.
- **The first device.** It pins itself. Trust on first use is trust on first
  use.
- **Devices already linked before you turned this on.** They are pinned by
  possession as soon as they write anything you can open — which is the point,
  but it does mean a device already inside the fleet was never asked about.
- **A read-only member's device.** Possession-pinning needs it to have
  _written_ something. A device that only ever reads will always need a human.
- **`popPins: false`** switches possession-pinning off, which means approving
  every device by hand, including every one already linked. It exists for
  deployments that want that. Most do not.

---
