Skip to content

M1-45 Disable a person, revoke a device, end a session

No Date Ruling Reason
M1-45/1 2026-10-13 Base int/m1-wave12 at da08f58, branch feat/M1-45-identity-revocation in its own worktree; the branch is not merged locally, the integration lane ports it. Text this ticket adds is English, also in the German chapter 08. Instructions of the launcher, which override the brief's merge step and language (as M1-44/1).
M1-45/2 2026-10-13 devices.revoke_reason holds a machine code with a CHECK: revoked_by_self for the device's own person, revoked_by_owner for an owner and another person's device, user_disabled from user.disable, inactive from the job; the free text of the request goes only into the audit entry (details.reason). See 08 section 10 and 08 section 5.4. The brief's ruling 1; 03 devices already constrains the column to codes.
M1-45/3 2026-10-13 session.revoke stores logout when the session's own person ends it and admin otherwise; the session and every live child session end with the same reason, their families with logout resp. session_revoked (the family rule of M1-34). See 08 section 10. The brief's ruling 2: 03 has no own code for "ended remotely by the person"; the audit entry names the actor anyway.
M1-45/4 2026-10-13 Disabling a disabled person, revoking a revoked device and ending a revoked session succeed without effect: 200 with the object (204 for a session) and an allow entry with no_change: true, decided in a first unit of work without rights change, so no rights version is raised and no event appended. A deleted person is 409 invalid_state_transition. The brief's ruling 3 (06 section 2 "No effect", R-API-27); phase 1 of a rights change raises permission_version unconditionally and a no-op must not invalidate every rights cache.
M1-45/5 2026-10-13 Each use case authorizes twice: in the first unit (so a refused call never raises the rights version) and again inside the unit declared as the rights change, after phase 1 locked permission_version and after the resource row is locked. When the only two owners disable each other at once, the second unit sees its caller disabled and answers 401 session_revoked. The brief's transaction shape; decision and change then see the same rights version, and TestDisableUser_ConcurrentOwners fails without the second check (it answers 409 last_owner instead).
M1-45/6 2026-10-13 SR8 for user.disable is the invariant check of the M1-29 rights hook before the commit, no own last-owner query: the last active owner gets 409 last_owner, the whole unit rolls back and the hook writes the deny entry after the rollback (TestAZM26_DisableLastOwnerFails compares every touched table row by row). Because every rights change checks SR8, device.revoke and session.revoke also fail with 409 last_owner in an installation without any active owner, which bootstrap-owner rules out; the tests add an owner. The brief's design note; the outcome equals a check first.
M1-45/7 2026-10-13 SessionEnded of M1-34 is reused with its list SessionIDs and gains At; every step that ends sessions publishes one event per device, so each ended session is in exactly one event with its own device. DeviceRevoked {DeviceID, UserID, Reason, At} is new, one per revoked device. No subscriber exists in M1; the golden subscriptions of internal/wire do not change. The brief asks to reuse the M1-34 event; M3-22 consumes SessionIDs. Before, one event named the device of the first session only, which would have been wrong for user.disable across devices.
M1-45/8 2026-10-13 The use cases live in app.Lifecycle (port identity.Lifecycle), not in a Service; the commands DisableUserCmd, RevokeDeviceCmd and RevokeSessionCmd are declared in the port package with Actor actor.Actor, because the handlers depend on the port only. The new store port app.LifecycleStore holds what no store had (SetPrincipalDisabled, LockDevice, LockLiveDevicesOfUser, LockLiveSessionsOfUser, LockLiveSessionsOfDevice, RevokeDevices, RevokeKeysOfDevices, InactiveDevices); LockUser, RevokeSessions, the family revocation of Sessions and Tokens.RevokeAllForPrincipal are reused. The brief names the earlier tickets' names as binding; the identity tickets have one use case type per area (People, Sessions, Tokens). The suggested List* names lock, so they are called Lock*.
M1-45/9 2026-10-13 Lock order inside the rights change: the principal row, then the person's devices, then the sessions (each table in ascending id), then the writes in the order of 08 section 11. A login that inserts a session holds a key share lock on its device; locking the devices first makes such a session either committed and read, or wait until after the revocation, where the revoked device refuses it.
M1-45/10 2026-10-13 auth.device_inactive_revoke_after (default 180d, minimum 1h) is registered. An active device whose last_seen_at (else approved_at) is older counts as inactive at once: the Authenticator reports its key as not active (401 session_revoked, audit device_inactive) and records no activity for it, and a refresh is 401 session_revoked. The job identity.auth_cleanup revokes such devices with the steps of device.revoke, one rights change per device (device.expire, reason inactive, revoked_by NULL, sessions inactive, audit without actor); selecting reads outside a rights change, so a run without such a device raises no version. AuthCleanup.Run returns deleted challenges plus revoked devices. The brief's design note "Inactive devices" and 08 section 5.4: requests never wait for the job. Recording the refused request as contact would revive the device.
M1-45/11 2026-10-13 access.changed is appended as the M1-29 path computes it: for the affected principal only when its visible spaces and channels differ. user.disable of a person who sees something appends one; device.revoke and session.revoke change no grant and normally append none, but still raise permission_version (05 "Permissions" = yes). Same rule as M1-44/6; the hook is the only writer of access.changed.
M1-45/12 2026-10-13 Audit details: user.disable {reason, sessions_revoked, devices_revoked, tokens_revoked}; device.revoke {reason, user_id, revoke_reason, sessions_revoked}; session.revoke {user_id, revoke_reason, sessions_revoked}; device.expire {revoke_reason, user_id, sessions_revoked}; a call without effect {..., no_change: true}. SEC-AUD-5: IDs, codes, counts and the reason text, never token values.
M1-45/13 2026-10-13 The integration tests run in the session world of M1-34 (sessions_env_test.go, now with the revocation handlers and a DeviceRevoked subscriber) as two groups with one world each (TestLifecycle_Revocation, and TestLifecycle_Owners for the cases that need an exact number of owners), so the package stays as close as possible to its size before; on this machine under a load average above 40 the whole package with -race took about ten minutes already (M1-44/13). The use cases were written before the integration tests in this session: the red run of the tests against the domain-only commit fails to build (identity.DeviceRevoked undefined), and seven mutations (inactive check off, no second authorize, tokens kept, no first no-change unit, one SessionEnded per call, reason always admin, keys kept) each turn a named test red. Test first could only be shown for the domain functions; the mutations show the tests catch each behaviour (as M1-44/13).
M1-45/14 2026-10-15 Port onto main: code and tests are taken byte for byte from the integration squash with the integration fixes INT-W14/9 (one identity.DeviceRevoked with the typed reason of M1-38 and At, one query LockDevice and repository method, the cleanup and Authenticator order, and the test helpers freshPhone and personalOff) and the part of INT-W14/10 that was still missing on main: freeHandle of the header login (M1-43) uses fieldCodeTaken instead of the literal. Code comments, query comments, the configuration test and these rulings name the English chapter files and headings (06 section 2 "No effect", 05 column "Permissions"); the texts of 08 sections 10 and 11 are added in English at the same places. No migration. Generated files are regenerated, not merged. main is English and already holds M1-38, M1-41, M1-43 and the split helpers of INT-W14/10. No behaviour changes.