Skip to content

M1-38 Device approval and pairing

No Date Ruling Reason
M1-38/1 2026-10-14 Base int/m1-wave13 at 82693e5, branch feat/M1-38-identity-device-approval in its own worktree; not merged locally and not pushed, the PR lane ports it. Migration version 18 is the next free one on the base. Instructions of the launcher, which override the brief's merge step (as M1-37/1).
M1-38/2 2026-10-14 A wrong verification code answers 200 with the device in state revoked (revoke_reason verification_failed), audited as allow device.approve with result: rejected_wrong_code. See 08 section 5.2. The request had a committed effect (the device and its sessions are revoked); an error status would claim nothing changed, and the approving person must see that the device is gone.
M1-38/3 2026-10-14 The verification code reaches the pending device through the TokenResponse of the login that leaves it pending and through getDevice of its own id. listMyDevices from a pending device stays 403 device_pending, because authz narrows device.list of a pending device to the resource of the device itself (M1-28, 05 section 2.5); the use case still sets the code on the own entry, so the description of Device.verification_code holds once a pending device may list. Other devices and owners never get it. The authz rule is normative and older; the device needs only one way to read its code.
M1-38/4 2026-10-14 Pairing codes: 8 Crockford characters from one crypto/rand byte each (low five bits); the insert uses ON CONFLICT (pairing_code_hash) WHERE status = 'requested' DO NOTHING, and a collision draws a new code, at most 5 times (pairingCodeAttempts), then 500. 40 bits against few open pairings make a collision rare; several in a row mean a broken random source, which must not loop. The partial unique index only covers requested pairings, so a closed pairing frees its code.
M1-38/5 2026-10-14 completePairing with a proof of another key rejects a requested pairing (committed, then 401 invalid_dpop_proof); an approved pairing answers the same 401 and stays approved. The state machine of the brief has no approved -> rejected; the device exists for the approving person, only its own key can collect it, and the collection window ends it otherwise (pairing_expired).
M1-38/6 2026-10-14 approveDevice and approvePairing write in a unit of work declared as the rights change device.approve of the device's person (the caller for a pairing), the expiries as device.expire; like addOwner, a first read and authorize run before that unit, and the unit locks and checks the row again. An unknown pairing code ends the step-up and writes its deny entry in a committed unit without rights change. 05 marks both actions as rights changes; the rights hook must lock permission_version before any domain row, and a refused or unknown request must not raise the rights version. The deny entry belongs to the committed effect (the ended step-up), so it is recorded in the unit, not aggregated after a rollback.
M1-38/7 2026-10-14 An expiry found on access (an expired pending device at approveDevice, an expired pairing at completePairing) is written in its own unit of work with the anonymous principal, so it is audited and evented like the job: the system action device.expire without actor, revoked_by NULL. The request still answers 409 invalid_state_transition or 410 gone. The brief applies the expiry "lazily on access and by the cleanup job" and audits it as a system action with actor NULL; the caller did not decide the revocation.
M1-38/8 2026-10-14 devices.created_at (and approved_at of an active device) takes the clock of the login or pairing request; Devices.Register keeps the database time. A pending device past auth.device_pending_ttl counts as revoked in the Authenticator (401 session_revoked, device_inactive), in refreshToken and as a known device of a later login (409). The pending window counts from the request clock the use cases compare with; correctness must not wait for the job (brief "Correctness never depends on the job").
M1-38/9 2026-10-14 Cleanup job case: identity.auth_cleanup existed (M1-35); AuthCleanup.Run now also calls ExpirePendingDevices and ExpirePairings of the login service, each device or pairing in its own unit of work, read in batches of 200. Expired pairings are marked expired, not deleted. One job per 02 section 12.3; separate units keep one failing row from blocking the rest, and deleting old transient rows belongs to the retention job, which grants its delete.
M1-38/10 2026-10-14 Red team round 1 (NEIN), fixed with tests: the units that must commit although the request answers an error run on txn.Detach (committed in app/approval.go): the end of the step-up and its deny entry after an unknown pairing code, the expiry of a pending device found by approveDevice, and the unit of completePairing with its rejection and expiry. The approval tests send an Idempotency-Key like a client (approveDevice and approvePairing are keyed), and the wrong-code case runs once with and once without the key. With the key the Idempotency-Key stage runs the use case inside its own unit of work and rolls back every error answer, so a wrong pairing code ended neither the step-up nor left a deny entry, and a guess cost no new biometric confirmation (reproduced by the red team, TestPairingWrongCodeEndsStepUp red before the fix).
M1-38/11 2026-10-15 Port onto main: the migration keeps the number 00018 of the integration branch (INT-W14/2), its comments name the English chapter files (new checksum recorded with make migrations-lock), and the code comments, the configuration test and these rulings name the English chapter files. None of the other integration fixes of int/m1-wave14 touches this ticket alone: they join it with M1-40, M1-41, M1-43, M1-45, M1-47, M1-48 and M1-51, and api/rest/rest.gen.go stays below repo.max_file_kb with this ticket (the split of INT-W14/4 comes with M1-41). main already holds M1-37 (00016) and M1-46 (00017), as the integration branch did, so the number follows without a gap; the specification is English on main. No behaviour changes.