| M1-37/1 |
2026-10-13 |
Base int/m1-wave11 at 20645565, branch feat/M1-37-identity-bootstrap in its own worktree; not merged locally and not pushed, the PR lane ports it. |
Instructions of the launcher, which override the brief's merge step (as M1-36/1). |
| M1-37/2 |
2026-10-13 |
W3 and W5 are resolved as PLAN section 7 and the spec state them: the host commands are bothy setup bootstrap-owner and bothy setup issue-enrollment-code (02 section 3.3, 08 section 4); a code is redeemed with device keys only through redeemEnrollmentCode (08 section 3.2), and the redeeming device becomes active. |
Brief "Scope" (spec first); the implementation follows it. |
| M1-37/3 |
2026-10-13 |
The setup gate (httpserver.SetupGate) wraps the mux of the public listener in step 4 of the chain (Chain.UseGate), after the correlation ID and the client IP, before authentication and rate limiting; the internal listener is untouched. It lets through only the operations whose action in the generated route table is instance.discovery.read (resolved with (*http.ServeMux).Handler, never a hand list); everything else, unknown paths and /api/v1/gateway included, gets 503 setup_required without Retry-After. The first true of the setup state is cached in an atomic.Bool for the process; a read error is 503 unavailable (fail closed). |
Brief "Setup gate", 08 section 4, R-ID-07, T-ID-04: a token must not change the answer, and setup never reverts, so the cache cannot go stale; the bootstrap takes effect at the next request without a restart. |
| M1-37/4 |
2026-10-13 |
A code is 20 bytes of crypto/rand as 32 Crockford Base32 characters, shown as en_ plus eight groups of four joined by -. Input is trimmed, an en_ prefix in any case dropped, - and spaces removed, upper-cased, O read as 0 and I, L as 1; anything else than 32 characters of the alphabet is no code and is never looked up. Stored is only HMAC-SHA-256(pepper, "enrollment-code/v1" 0x00 canonical) with the token pepper. |
Brief "Enrollment code format and storage", 08 section 3.2, 03 principle 9, ID-T27. |
| M1-37/5 |
2026-10-13 |
CompleteLogin takes the existing port types: LoginCompletion.Device is identity.RegisterDevice (its UserID, State and ApprovedBy are set by the use case) and LoginResult holds identity.Session, identity.Device and identity.TokenSet; the use cases live on app.Service, which also implements the new incoming port identity.Enrollment for the two operations. A device confirmed by a code records the person as approver (approved_by), because the M1-33 rule requires an approver for an active device and the code is the person's own confirmation. |
The brief names DeviceRegistration, SessionView, DeviceView and TokenPair, which M1-33 and M1-34 built as these types; a second set of names would only convert. |
| M1-37/6 |
2026-10-13 |
The allow entry of auth.login.complete names the person who logged in as actor, with the new session and device, and the session as resource; the details are {auth_method, device_id, session_id, new_device, device_state}. A rejected code is one aggregated denial (unauthenticated, cause and the matched code id, never the code); a rejected login with a matching code is a denial with cause login_rejected after RecordFailedAttempt. Only rejections of the request (400, 401, 409, 422) count as failed attempts, never a server failure. |
The request itself is anonymous, but a login is the person's action; the generic per-action audit writes no allow entry, so CompleteLogin writes exactly one (test). A database outage must not burn a person's code. |
| M1-37/7 |
2026-10-13 |
RecordFailedAttempt refuses to run inside a unit of work (kind internal) and opens its own short one; Match and MarkUsed require the ambient unit. Issue locks the person's principal row before it ends the other codes, so two concurrent issues leave one valid code. |
The redeeming unit holds the row lock; counting inside it would deadlock or roll back with it (brief "Failed attempts"). |
| M1-37/8 |
2026-10-13 |
The info log line of a setup command carries the action and, when the telemetry pseudonym key is readable, bothy.principal.pseudo_id, but not the handle; the expiry goes to stderr as a text line, because the log allowlist has no time key. Neither carries the code. |
19 SEC-LOG-4 (people only as pseudonym) is the core rule; the brief's "handle" in the log line would break it. |
| M1-37/9 |
2026-10-13 |
bothy setup assembles the server parts it needs in wire.Setup (pool of bothy_app, audit, event log with its partitions, Authorizer with the rights hooks, signing keys, identity) and runs the partition maintenance of the event log once before the bootstrap. Configuration, secret and database errors exit 1, invalid arguments (also a taken or invalid handle) 2, a completed setup 3, a pending setup 4 for issue-enrollment-code, a handle that is no active owner 2. |
16 section 7.1 (bothy_app), ADR 0066 (Keys.EnsureActive before the transaction); the bootstrap appends access.changed, whose partition exists when serve ran, and the command must not depend on that. |
| M1-37/10 |
2026-10-13 |
The instance port gets LockForSetup and CompleteSetup (both inside the unit of work), and the migration grants bothy_app the column right UPDATE (setup_completed_at) on instance; every other column stays read only. createEnrollmentCode documents its 409 (reason user_not_active) in openapi.yaml. auth_sessions.last_ip is written at login from the client IP. |
08 section 4 steps 1 and 6 run as bothy_app; a SELECT ... FOR UPDATE needs an update right, and the column grant is the least one. The brief defines the 409, which the contract lacked (proven wrong, spec first). |
| M1-37/11 |
2026-10-13 |
A person whose code is rejected because the person is not active gets the same 401 as any rejected code and counts no attempt; TestCreateEnrollmentCodeRights checks an agent through the use case, which answers 401 until M1-44 makes agent tokens valid (then 403). |
08 "Login protection"; API tokens do not exist on this base. |
| M1-37/12 |
2026-10-13 |
A disabled local provider closes the code path of a person with a local identity: redeemEnrollmentCode share-locks the provider and rejects the code with the same 401 as every rejected code (cause local_provider_disabled, no attempt counted), and createEnrollmentCode answers 409 conflict (reason local_provider_disabled) instead of issuing a code that could not be redeemed. A recovery code issued on the host (no creator) still logs an owner in, without the closed identity (auth_sessions.identity_id NULL). A code redeemed by a known pending device of the same person with the same key leaves the device pending (brief "CompleteLogin": state unchanged); M1-38 approves it. The plain code stays a string in IssuedEnrollmentCode (fixed by the brief) and is wrapped in the redacting Token at the port. |
Red team M1-37 (Major): 08 section 3.3 and R-ID-12, disabling a driver prevents new logins, and M1-36/7 orders the login before the disable. Host access is the root of trust (16 section 4.3), so an owner must not be locked out by the disable. |
| M1-37/13 |
2026-10-14 |
Port onto main: the migration keeps the number 00016 of the integration branch, its comments name the English chapter files (new checksum recorded with make migrations-lock), and loginCfg of the login tests in identity/app also sets APITokenMaxTTL, PersonalTokenMaxTTL and DPoPRequiredFor; the non-ASCII case of TestNormalizeEnrollmentCode uses É instead of an umlaut. |
main already holds M1-44, whose NewIdentity refuses a configuration without the token limits; the ticket was written on a base without M1-44, and the integration added the same line (INT-W13/4). The language gate of main (D-W-94) rejects umlauts in tracked files, also escaped; É is likewise a non-ASCII letter outside the alphabet. |