M1-37: First setup, enrollment codes and setup lock¶
Status: ready | Depends on: M1-19, M1-36 | Milestone: M1
Goal¶
After this ticket a fresh instance answers every public request except the discovery-class reads
with 503 setup_required until the instance operator runs bothy setup bootstrap-owner on the
host. That command creates the first owner in one transaction and prints a one-time enrollment
code. The owner redeems the code on a device that registers only its device keys (no passkey
needed, contradiction W5) and gets an active device with a DPoP-bound session. Owners issue
enrollment codes for local accounts over the API, and lost access is recovered on the host with
bothy setup issue-enrollment-code. The ticket also builds the shared login-completion use case
that every later login flow (M1-38 to M1-43) reuses, so device, session and token creation exist
exactly once.
Read first¶
docs/spec/08-identity.md: "Local accounts", "Mapping an external identity", "Enrollment code", "Initial installation", "Registration" and "Approval" (section 5), "DPoP", "Login protection", "Tests" (ID-T06, ID-T07, ID-T11, ID-T27), "Decisions of this chapter" (R-ID-07, R-ID-09), "Configuration values of this chapter".docs/spec/03-data-model.md: "Principles" 1, 9, 12, 13; tablesinstance,principals,users,owners,identity_providers,user_identities,enrollment_codes,devices,device_keys,auth_sessions,refresh_token_families,idempotency_keys,audit_events.docs/spec/05-actions.md: rules 1.5 and 1.10, "Columns Owner, Step-up, Audit, Permissions", "Decision"; actionsinstance.discovery.read,instance.bootstrap,owner.recover,auth.login.complete,user.create,user.update.docs/spec/02-architecture.md: "Commands of the binary", "Module list", "Allowed dependencies", "Cross-cutting ports", "Layers within a module", "Transactions and Unit of Work", "Request path", "Error kinds", "Start".docs/spec/06-api.md: "DPoP", "Login flows", "Errors", "Idempotency", "Endpoints" (Auth, Users), "Contract tests and gate".docs/spec/07-permissions.md: "Principal context", "Flow".docs/spec/16-operations.md: "Two listeners", "Roles" (database roles).docs/operations/install-compose.md: section "Start".docs/PLAN.md: section 3 "Way of working per ticket"; section 7 "Known contradictions" W3, W5.- ADR 0013, ADR 0015, ADR 0019, ADR 0020, ADR 0021.
docs/api/openapi.yaml: operationscreateEnrollmentCode,createUser,redeemEnrollmentCode,getJwks,getDiscovery(beginEnrollmentandcompleteEnrollmentonly for context, built in M1-40); schemasEnrollRedeemRequest,DeviceRegistration,TokenResponse,EnrollmentCode,UserCreate,UserCreated; responsesServiceUnavailable,Unauthorized.docs/security/threat-model.md: T-ID-03, T-ID-04.- Code of the dependencies:
services/server/internal/identity(M1-26, M1-31, M1-33, M1-34, M1-35, M1-36),internal/instance(M1-15, M1-32),internal/platform/httpserver(M1-18, M1-19, M1-20),api/routesandservices/server/api/operations.yaml(M1-17),internal/kernel/auditing(M1-24),internal/platform/pg/pgtest(M1-12).
Fixed requirements that apply¶
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
- Test first: every behavior change starts with a failing test (red), then code (green), then
cleanup. No en dash or em dash in any text. White label: examples use Alex, Sam, Robin and
example.com; example secrets containEXAMPLE. Configuration values only through named keys listed indocs/operations/configuration.md. - Until
instance.setup_completed_atis set, the server answers only health checks andinstance.discovery.read; every other request ends with503 setup_required. There is no "first visitor becomes owner" (08 "Initial installation", R-ID-07, T-ID-04). - The first owner is created only by a host command, directly against the database, in one
transaction with the six steps of 08 "Initial installation" (check, principal and owner,
signing keys, enrollment code, audit with system action
instance.bootstrapand no actor, mark setup complete). - Enrollment codes: at least 128 random bits from the cryptographic generator, shown exactly once,
stored only as HMAC-SHA-256 with the server pepper (
auth.token_pepper_ref), validauth.enrollment_code_ttl, single use, invalid afterauth.enrollment_code_max_attemptsfailed attempts, never sent by e-mail (08 "Enrollment code", 03 principle 9, ID-T11, ID-T27). - Redeeming a code confirms the first device: the device that redeems it becomes
active(08 "Enrollment code", "Approval"). - Recovery on the host only for active owners, with an audit entry (08 "Initial installation", R-ID-09).
- No information about accounts or codes: every rejected code yields the same answer (08 "Login protection").
- Every route goes through
authorizewith its catalogue action; the system actioninstance.bootstrapis not reachable over API or gateway (05 rules 1.1 and 1.10).
Scope¶
The specification is in place (planning, spec first; W3 and W5 resolved there): operation
redeemEnrollmentCode (POST /auth/enroll/redeem, auth.login.complete, rl.public_auth,
x-idempotency-exempt: auth_flow, schema EnrollRedeemRequest), EnrollmentCode.purpose, the
problem code setup_required in x-problem-codes (503) and the descriptions of
createEnrollmentCode and createUser (one-time secret) in openapi.yaml; 06 "Idempotency" row
"Response with a one-time secret" and R-API-26; 08 "3.2" (format, purposes table, one valid code
per person, definition of a failed attempt, redemption paths) and "4" (local provider and local
identity created by bootstrap-owner, application role, recovery with the system action
owner.recover); 05 owner.recover; 16 "4.3" (host access is the root of trust) and "7.1"
(bothy setup uses bothy_app); docs/operations/install-compose.md (setup commands); PLAN rows
M1-37 and M1-40. Change any of them only where the implementation proves them wrong (spec first,
make api-lint api-checks docs linkcheck green). docs/DECISIONS.md: one entry per ruling listed
under Design notes "Rulings to record".
Production code under services/server/:
migrations/<next sequence>_identity_enrollment_codes.sql: tableenrollment_codesexactly as in 03 (columns, checks, FKs, unique index oncode_hash, partial index(user_id) WHERE used_at IS NULL, index(expires_at)). Use the next free goose sequence number;make migrations-lintmust pass.internal/identity/adapters/postgres/queries/enrollment_codes.sqlplus regenerated sqlc code.internal/identity/domain/enrollment.go: code generation, normalization, display format, purpose rules, attempt rules (pure functions).internal/identity/app/enrollment.go: serviceEnrollmentCodeswithIssue,Match,RecordFailedAttempt,MarkUsed(used by M1-40 and M1-41, so keep them exported within the module); use caseRedeemEnrollmentCode; use caseCreateEnrollmentCode(API).internal/identity/app/login_completion.go: use caseCompleteLogin(Design notes).internal/identity/app/bootstrap.go: use casesBootstrapOwnerandIssueRecoveryCode.internal/identity/adapters/httpapi: handlersRedeemEnrollmentCode,CreateEnrollmentCode; extend theCreateUserhandler forlocal_account.internal/platform/httpserver/setupgate.go: middlewareSetupGatewith the consumer interfaceSetupState;internal/wireconnects it to the setup state ofinternal/instance(M1-15).- One-time secrets: the handlers of
createEnrollmentCodeandcreateUser(withlocal_account) callidempotency.MarkOneTimeSecret(ctx)of M1-20; see Design notes. cmd/bothy: subcommandssetup bootstrap-ownerandsetup issue-enrollment-code.services/server/api/operations.yaml: enableredeemEnrollmentCodeandcreateEnrollmentCode(P-04); regenerateapi/restandapi/routes(make codegen-checkclean).internal/kernel/errs(or the code registry M1-18 uses): codesetup_required, kindunavailable, status 503.internal/identity/README.md(ports, tables, actions, commands),services/server/CHANGELOG.md.
Public interfaces (Go, names fixed; adapt the UUID type to the one kernel/ids provides):
// internal/platform/httpserver
type SetupState interface {
// SetupCompleted reports whether instance.setup_completed_at is set.
SetupCompleted(ctx context.Context) (bool, error)
}
func SetupGate(state SetupState, routes RouteLookup) func(http.Handler) http.Handler
// internal/identity/app
type DeviceTrust int
const (
TrustConfirmed DeviceTrust = iota + 1 // enrollment code, device pairing: new device is active
TrustUnconfirmed // passkey, password, OIDC, trusted header: new device is pending
)
type LoginCompletion struct {
UserID ids.ID
IdentityID *ids.ID // nil for logins without a provider identity
AuthMethod string // one of auth_sessions.auth_method
Device DeviceRegistration // validated registration from the request (M1-33 type)
ProofJKT string // RFC 7638 thumbprint of the key that signed the DPoP proof
Trust DeviceTrust
ClientIP netip.Addr
}
type LoginResult struct { Session SessionView; Device DeviceView; Tokens TokenPair; NewDevice bool }
func (s *Service) CompleteLogin(ctx context.Context, in LoginCompletion) (LoginResult, error)
type EnrollmentPurpose string // "first_login" | "recovery" | "identity_link"
type IssuedEnrollmentCode struct { ID ids.ID; Code string; Purpose EnrollmentPurpose; ExpiresAt time.Time }
type MatchedEnrollmentCode struct { ID, UserID ids.ID; Purpose EnrollmentPurpose }
func (e *EnrollmentCodes) Issue(ctx context.Context, userID ids.ID, purpose EnrollmentPurpose, createdBy *ids.ID) (IssuedEnrollmentCode, error)
func (e *EnrollmentCodes) Match(ctx context.Context, code string, allowed ...EnrollmentPurpose) (MatchedEnrollmentCode, error) // row locked FOR UPDATE in the ambient UoW
func (e *EnrollmentCodes) RecordFailedAttempt(ctx context.Context, codeID ids.ID) error // own transaction
func (e *EnrollmentCodes) MarkUsed(ctx context.Context, codeID, deviceID ids.ID) error
Out of scope¶
- Device approval with verification code, pairing, pending-device expiry (M1-38).
- Password with TOTP (M1-39); passkeys and the enrollment with passkey
beginEnrollmentandcompleteEnrollment(M1-40); OIDC login and linking with anidentity_linkcode (M1-41); trusted header login (M1-43). setup_requiredand other fields in the discovery document (M1-46); this ticket only lets discovery-class operations through the gate.- Gateway handshake (M1-64); the gate covers the gateway path because it wraps the public listener.
- Cleanup of expired codes (retention jobs, M7).
Boundaries¶
- A new migration needs its line in
migrations.sha256: runmake migrations-lockafter creating the file and beforemake check; never edit a merged migration (M1-01, ADR 0007). - Work in a dedicated git worktree on branch
feat/M1-37-identity-bootstrap. - Conventional Commits with module scope (for example
feat(identity): redeem enrollment codes) and DCO sign-off (git commit -s). - Never push, tag, open pull requests or create remote repositories.
- Do not modify files outside Scope, except
docs/DECISIONS.md, thedocs/PLAN.mdrows named in Scope and CHANGELOG entries. - Follow the host rules supplied by the launcher. Tests bind only
127.0.0.1and port 0; PostgreSQL runs only as the disposable container ofpgtest. - Stop only processes you started, by PID. Temporary directories only via
mktemp -d, removed at the end. - Never read or print secrets. No network calls except package downloads for pinned tools.
- If an element of the core contract (chapters 00, 03, 04, 05) that this brief names is missing
or different, stop with
Verdict: BLOCKED(P-11) instead of building a deviation.
Design notes¶
Enrollment code format and storage:
- 20 random bytes from
crypto/rand(160 bits, above the 128-bit floor) encoded as 32 Crockford Base32 characters (alphabet0123456789ABCDEFGHJKMNPQRSTVWXYZ). Display form:en_plus eight groups of four characters joined by-, for exampleen_EXAM-PLE0-...in docs only with the wordEXAMPLEin the value. - Normalization of input: trim, drop an optional
en_prefix (any case), remove-and spaces, uppercase, mapOto0andI,Lto1; anything that is not 32 alphabet characters is not a code. - Stored value:
code_hash = HMAC-SHA-256(key = pepper, msg = "enrollment-code/v1" || 0x00 || canonical). The pepper comes from the secret provider throughauth.token_pepper_ref; reuse the pepper accessor that M1-34 uses for refresh tokens. The code itself never reaches a log, an audit detail, an event orriver_job; logs carry only the code ID. - The generator takes an
io.Reader; the package variable used in production iscrypto/rand.Reader. No package underinternal/identityimportsmath/randormath/rand/v2(test in ID-T27).
Purposes (column enrollment_codes.purpose):
| Purpose | Issued by | May be consumed by |
|---|---|---|
first_login |
bootstrap-owner; createUser with local_account: true; createEnrollmentCode for a person with a local identity and no device row yet |
redeemEnrollmentCode, completeEnrollment (M1-40), OIDC linking (M1-41) |
recovery |
bothy setup issue-enrollment-code; createEnrollmentCode for a person with a local identity and at least one device row |
redeemEnrollmentCode, completeEnrollment (M1-40) |
identity_link |
createEnrollmentCode for a person without a local identity |
OIDC linking only (M1-41) |
Issuing a code sets expires_at = now on every other unused, unexpired code of the same person, so
at most one code per person is valid. Reason: a forgotten older code would stay a usable secret.
Failed attempts: a code can only be attributed when it matches by hash. A failed attempt is a
request whose code matched an unused, unexpired code with remaining attempts and that was then
rejected (device registration invalid, DPoP proof key not equal to device.public_jwk, unknown
platform or algorithm, device ID already taken; for M1-40 also a failed WebAuthn verification).
RecordFailedAttempt increments attempts in its own short transaction after the failing unit
of work rolled back, so the count survives the rollback. A code with attempts >=
auth.enrollment_code_max_attempts is invalid. Non-matching codes are limited per client IP by
rl.public_auth and audited as denials.
Redemption redeemEnrollmentCode, in this order:
authorize(anonymous, auth.login.complete, instance)through the normal chain.- Verify the DPoP proof as a login proof without
ath(06 "DPoP" point 3) with the verifier from M1-33, including the nonce rules. Failure:401 invalid_dpop_prooforuse_dpop_nonce; the code is not looked up. - Normalize the code. Malformed, unknown, used, expired (
now >= expires_at), exhausted, wrong purpose, or the person is notprincipals.status = 'active':401 unauthenticatedwith the same body (onlycorrelation_iddiffers) and an audit denial. Lookup usesMatch, which locks the row withSELECT ... FOR UPDATEinside the unit of work. CompleteLoginwithTrustConfirmed,AuthMethod = "enrollment_code",IdentityID= the person's active local identity if one exists, otherwise nil. Rejections here roll back and callRecordFailedAttempt.MarkUsedsetsused_atandused_by_device_idin the same unit of work. Respond201withTokenResponse.
Two parallel redemptions of the same code: the row lock serializes them; the second sees used_at
and gets the generic 401.
CompleteLogin (shared by all login flows):
ProofJKTmust equal the RFC 7638 thumbprint ofDevice.public_jwk, else401 invalid_dpop_proof.- Unknown
device_id(UUIDv7, checked withkernel/ids): createdevices(stateactiveforTrustConfirmed,pendingforTrustUnconfirmed;approved_atset only when active) anddevice_keys(dpop, andstep_upwhenDevice.step_up_keyis present, both withjkt). - Known
device_idof the same person, not revoked, whose activedpopkey hasjkt = ProofJKT: reuse it; its state does not change. Any other knowndevice_id(other person, revoked, other key):409 conflict. - The person must be
active; otherwise401 unauthenticated. M1-41 extends this for provisionedpendingpeople; do not add that here. - Create the session, refresh family (
dpop_jkt = ProofJKT) and token pair with the session issuing code from M1-34;auth_sessions.identity_id = IdentityID,auth_method = AuthMethod,last_ip = ClientIP. Setuser_identities.last_login_atwhenIdentityIDis set. - Exactly one audit entry for
auth.login.completeper completed login, decisionallow, details{auth_method, device_id, session_id, new_device, device_state}. If the generic per-action audit from M1-24/M1-28 already writes the entry, add the details there; never two entries (test).
Setup gate:
- Wraps the public listener only (
server.listen_addr); the internal listener with/healthzand/readyzis untouched. - Allowed before setup: every operation whose action in the generated route table is
instance.discovery.read(todaygetDiscovery,getJwks,getWebhookKeys,getBrandingLogo,getProblemType). Derive the list fromapi/routes, never hardcode it. - Everything else, including unknown paths and the gateway path, gets
503with problem codesetup_requiredand noRetry-After(setup waits for a person, not for time). - The gate runs before authentication (request path step 4) and before rate limiting, so a
request carrying any token still gets
503. Resolve the operation with the same route table thatapi/routesuses (for example(*http.ServeMux).Handlerto get the pattern without dispatching). SetupCompletedis read per request until it returnstrue; then the result is cached for the process lifetime (anatomic.Bool), because setup never reverts. A database error answers503 unavailable(fail closed). Bootstrap on the host takes effect without a server restart.
bothy setup bootstrap-owner:
- Flags:
--handle(required),--display-name(required),--locale(defaultde),--time-zone(defaultUTC), plus the global--config. Handle and display name are validated with the rules from M1-26 (handle pattern,principal.handle_max_chars, no control or bidi characters). - Uses the normal configuration and the application database role
bothy_app(same asserve): the transaction only writes rows, so the schema owner role is not needed (least privilege). - Before the transaction:
Keys.EnsureActiveof theinstanceport from M1-32 (idempotent; ADR 0066: the server creates the keys insecrets.signing_key_dir,secrets-initdoes not; if that function opens its own transaction, call it before the bootstrap transaction). The command therefore needs the writable key store likeserve(Compose: run it in theapiservice). - One unit of work with the permission-change option for the new principal (
instance.bootstraphas "Permissions"yes), system actor:- Lock the instance record (
SELECT ... FOR UPDATEthrough the instance port; if M1-15 creates the record lazily, call its ensure function first).setup_completed_atset: abort. - Ensure a
localidentity provider: reuse the existing one, else createkind = 'local',name = 'Local accounts',enabled = true,allow_provisioning = false,config = {}(schemaidentity_provider.local.v1) through the provider store from M1-36 withoutauthorize(system path). - Create principal (
kind = 'user',status = 'active'),users,owners(granted_by = NULL,reason = 'bootstrap'), and the local identity (subject= handle). Issue(first_login, createdBy = nil).- Audit: actor NULL, action
instance.bootstrap, decisionallow, resourceinstance, details{owner_principal_id, enrollment_code_id, local_provider_id}. - Set
setup_completed_atthrough the instance port.
- Lock the instance record (
- After commit write exactly one line to stdout: the code in display form, nothing else (08
section 4, 02 section 3.3), so scripts read it without a format. Logs go to stderr: one info
line with the handle and
expires_atin RFC 3339 UTC, the person only as pseudonym (19 SEC-LOG-4), never the code. Nothing else prints the code. - Exit codes:
0success;1infrastructure error (configuration, secrets, database);2invalid arguments;3setup already completed (nothing changed).
bothy setup issue-enrollment-code --user <handle>:
- Setup must be complete (else exit
4). The person must be an active owner (else exit2with "not an active owner"). Issues arecoverycode (created_by = NULL), prints it once like above. - Audit: actor NULL, system action
owner.recover(05, R-AZ-15), resourceuser, details{purpose: "recovery", enrollment_code_id}(values are codes, 19 SEC-AUD-5). Reason: a host command must not look like an action of a person, and every audit entry names a known action. - Recovery works for owners with or without a local identity; redemption then creates an active device without an identity.
createEnrollmentCode (POST /users/{user_id}/enrollment-codes, action user.update, owner
with step-up through the catalogue): target must be a human with status = 'active', else 409
conflict with reason: user_not_active; purpose per the table above; 201 with code,
purpose, expires_at.
createUser with local_account: true (extends M1-31 and removes its interim 400 for
local_account): requires an enabled local provider (else
409 conflict, reason: local_provider_disabled); creates the local identity (subject =
handle) and a first_login code in the same unit of work and returns it as enrollment.
One-time secrets and idempotency: a response that carries a one-time secret is marked by the
handler with idempotency.MarkOneTimeSecret(ctx) (M1-20). The idempotency layer then stores status
and {"one_time_secret": true} as body instead of the response; a replay with the same key answers
409 conflict with reason: one_time_secret (06 "Idempotency", R-API-26). Reason:
idempotency_keys.response_body must not hold a secret in plain text (03 principle 9).
Configuration used (all existing; add none): auth.enrollment_code_ttl,
auth.enrollment_code_max_attempts, auth.token_pepper_ref, auth.session_max_age,
auth.refresh_token_ttl, auth.access_token_ttl, ratelimit.rl.public_auth.*.
Error codes: setup_required (503, in x-problem-codes), unauthenticated, invalid_dpop_proof,
use_dpop_nonce (401), validation_failed (400), conflict (409), rate_limited (429).
Rulings to record in docs/DECISIONS.md (link the spec places named in Scope): W3 and W5
resolution; setup gate placement and caching; code display format and normalization.
Tests (write them first)¶
Go test names carry the spec test ID where one exists. Integration tests use pgtest
(PostgreSQL 18, disposable container, random port), the controllable clock from kernel/clock,
and the test authenticator and step-up helper from M1-31/M1-35.
Unit (internal/identity/domain):
TestEnrollmentCodeFormat: 32 Crockford characters, 160 bits, display form with prefix and eight groups.TestNormalizeEnrollmentCode: table with lowercase, hyphens, spaces,O/I/Lmapping, missing prefix, wrong length, characterU, empty input.TestIDT27_EnrollmentCodeEntropyAndSource: generator reads exactly 20 bytes from the injected reader; the production reader iscrypto/rand.Reader;go list -depsof every package underinternal/identitycontains neithermath/randnormath/rand/v2.TestEnrollmentPurposeRules: issuing purpose and allowed consumers per table.
Unit (internal/platform/httpserver):
TestSetupGateAllowsOnlyDiscoveryActionswith a fake route table and fakeSetupState.TestSetupGateCachesCompletion: after the firsttruethe fake is not called again.TestSetupGateFailsClosed:SetupStateerror gives503 unavailable.
Integration:
TestIDT06_RequestsBeforeSetupGet503:GET /api/v1/me(with and without a token),POST /api/v1/auth/enroll/redeem, an unknown path and the gateway path return503with problem codesetup_required;GET /api/v1/instance/jwksis not503;/readyzon the internal listener is unaffected; afterBootstrapOwnerthe sameGET /api/v1/mewithout a token returns401without a server restart.TestIDT07_SecondBootstrapAbortsWithoutChange: row counts ofprincipals,owners,user_identities,identity_providers,enrollment_codes,audit_eventsand the instance record are equal before and after; exit code3.TestBootstrapIsAtomic: a fake audit recorder failing at step 5 leaves no row andsetup_completed_atNULL.TestBootstrapConcurrent: two parallel runs, exactly one succeeds.TestBootstrapAuditEntry: one entryinstance.bootstrap, actor NULL, details without the code.TestBootstrapPrintsCodeOnce: captured stdout is exactly one line and that line equals the code in display form; captured stderr and logs contain it zero times. Same check forsetup issue-enrollment-code.TestRedeemActivatesFirstDevice:201; deviceactive; sessionauth_method = 'enrollment_code'; refresh familydpop_jktequals the proof thumbprint;used_atandused_by_device_idset; one audit entryauth.login.complete.TestIDT11_CodeUsedTwiceRejected,TestIDT11_ExpiredCodeRejected(clock pastexpires_at),TestIDT11_CodeRejectedAfterMaxAttempts(afterauth.enrollment_code_max_attemptsfailed attempts a correct request fails): all three bodies are equal exceptcorrelation_id.TestRedeemFailedAttemptSurvivesRollback: proof key differs fromdevice.public_jwk;attemptsis incremented although the unit of work rolled back.TestRedeemUnknownCodeDoesNotTouchAttemptsandTestRedeemWithoutDPoPProof(no lookup, attempts unchanged).TestRedeemConcurrentSameCode: one201, one401.TestRedeemRejectsDisabledPerson.TestIssueExpiresOlderCodes.TestCompleteLoginDeviceRules: table for new device confirmed and unconfirmed, known device with same key (state kept), known device of another person, revoked device, other key, proof key mismatch.TestCreateEnrollmentCodeRights: owner with step-up201; owner without fresh step-up401 step_up_required; non-owner human403; agent token403; disabled target409; purpose selection for the three cases.TestOneTimeSecretNotStoredForReplay:idempotency_keys.response_bodyofcreateEnrollmentCodeandcreateUsercontains no code; replay answers409withreason: one_time_secret.TestCreateUserLocalAccountIssuesCode.TestRecoveryCommand: active owner gets arecoverycode and can redeem it; non-owner exit2; before setup exit4; audit entry with system actionowner.recoverand actor NULL.- The route-count test from M1-17/M1-25 (AZ-P1) passes with the two new operations.
Commands that must pass:
make api-lint api-checks codegen-check arch migrations-lint config-doc-check docs
cd services/server && go test -race ./internal/identity/... ./internal/platform/httpserver/... ./cmd/bothy/...
make check
Acceptance criteria¶
- The implementation matches openapi.yaml, 05, 06, 08, 16 and install-compose.md (changed only
where proven wrong);
make api-lint api-checks docs linkcheckgreen. - ID-T06, ID-T07, ID-T11 and ID-T27 are green and named after their IDs.
- Before setup only
instance.discovery.readoperations answer without503 setup_required; bootstrap takes effect without restart. -
bothy setup bootstrap-ownercreates owner, local provider, local identity, code, audit entry and setup mark in one transaction; second run exits3with nothing changed. - A redeemed code yields an active device and a DPoP-bound session; every rejected code yields
the same
401body. - Failed attempts are counted only for matching codes and survive rollback.
- No code value appears in logs, audit details, events, jobs or
idempotency_keys. -
CompleteLoginis the only place that creates devices, sessions and tokens for logins. -
make checkgreen, coverage of the changed Go packages at or abovequality.coverage.go_min_percent.
Definition of done¶
- Code and tests as above; doc comments on every exported symbol (purpose, parameters, return value, errors); comments explain why.
internal/identity/README.mdlists the new table, use cases, operations, commands and the purposes table;services/server/CHANGELOG.mdhas an entry under "Unreleased".- Specification and API changes from Scope are in the same branch; generated files are
regenerated;
docs/DECISIONS.mdhas the rulings. No ADR is needed unless you take an architecture decision beyond this brief; then write it with the next free number. make checkgreen.make reviewand an independent code review over the diff, then the red team over the diff perdocs/quality/red-team-gate.md(focus: setup takeover, code guessing, secrets at rest); verdicts recorded in the report.- Merge locally into the base branch only if review is clean, the red team verdict is
JAandmake checkis green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}: first line Verdict: DONE or Verdict: BLOCKED <reason>; then the
commits, the files with line counts, test results with counts (unit, integration), what is not
proven, and follow-ups that could not be done in scope with the reason. Then write
{{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.