| M1-44/1 |
2026-10-13 |
Base int/m1-wave10 at 5cd89cd, branch feat/M1-44-identity-api-tokens in its own worktree; the branch is not merged locally, the integration lane ports it. Text this ticket adds is English, also in German chapters and READMEs. |
Instructions of the launcher, which override the brief's merge step and language (as M1-35/1). |
| M1-44/2 |
2026-10-13 |
A personal token exists only for the person itself: the authz guard of token.create (07 section 4.2 step 9) refuses a holder of kind user other than the caller with forbidden not_self; the application layer reads the holder's kind from PrincipalSource into domain.TokenFacts. An unknown holder passes the guard, so the use case answers 404. |
The brief's ruling: a personal token is the person's own credential; an owner minting one for someone else could act as that person. |
| M1-44/3 |
2026-10-13 |
The requirement of token.create is Any(Owner, Self), token.list and token.revoke keep Any(Self, Owner). When the requirement of token.create fails for a holder that is a person other than the caller, the reason is not_self (domain.notSelfForPersonalToken); for an agent or app holder it is the reason of the first atom, owner_only (also for the owner's personal token, SR5). |
The brief requires not_self for a personal token of another person (08 section 12) and owner_only for TestPersonalTokenOfOwnerHasNoOwnerRights; Any reports the reason of its first alternative (M1-28). Red team round 1, minor 1. |
| M1-44/4 |
2026-10-13 |
Personal tokens carry no scopes (/scopes must be empty for pat as for agt). |
The brief's ruling: personal tokens act with the person's grants; 07 knows scopes only for apps. |
| M1-44/5 |
2026-10-13 |
A found but revoked or expired token is not refused by the Authenticator: it returns the actor of the token's principal with the token id and CredentialInactive (new field of kernel/actor.Actor), and authorize refuses it in check_active with 401 unauthenticated and the audit reason token_revoked. The Idempotency-Key stage (platform/httpserver), which runs before authorize, neither claims nor replays for such an actor. No DPoP proof is checked for an inactive token and last_used_at is not written. A missing token, a kind mismatch and every format failure are refused by the Authenticator (401 unauthenticated, no audit). |
The brief asks for the audit reason token_revoked; only authorize knows the action of the request and audits a denial (07 section 4.2), as M1-34 does for sessions. Red team round 1 (major): without the flag the stage replayed a stored answer to a revoked or expired token, and to a revoked DPoP-bound token without its key. The two files outside the brief's scope (kernel/actor, platform/httpserver) change by one field and one condition. |
| M1-44/6 |
2026-10-13 |
Revoking a token is a rights change: revokeToken, Tokens.Revoke outside a unit and every caller of RevokeAllForPrincipal raise permission_version; Revoke inside a unit that is no rights change and RevokeAllForPrincipal outside one fail with internal_error. access.changed is appended only where the M1-29 path computes a difference; a token changes no grant, so a revocation appends none. |
authz caches token liveness with the rights snapshot per version (M1-28/12); without the new version a revoked token would stay live in the cache of every process. 05 marks token.revoke as a rights change; the brief says "as the path computes it". |
| M1-44/7 |
2026-10-13 |
revokeToken authorizes in a first unit of work and revokes in a second one declared as the rights change; an already revoked token ends after the first with 204 and no new entry. |
A refused or empty call must not raise the rights version; the second unit locks the row, so two concurrent revocations write one entry. |
| M1-44/8 |
2026-10-13 |
PersonalTokenSwitch is bound to app.PersonalTokensDefault (always off) in internal/wire until M1-47 registers tokens.personal_enabled and rebinds it over instance.Flags; wire.IdentityWrites.PersonalTokens is the seam. Tests use a fake switch for both values. |
The brief's binding; 16 section 6.2: an unregistered flag is off. |
| M1-44/9 |
2026-10-13 |
createToken for an app answers 409 conflict app_not_installed before the body is checked. |
Apps cannot exist in M1; M2-24 replaces the answer with the installation check (brief). |
| M1-44/10 |
2026-10-13 |
Besides the port Tokens of 02 section 4.3 the root package declares TokenAdmin, the use cases of the three operations (they authorize, Tokens does not). |
As People beside Directory (M1-31): other modules use Tokens without an authorization of their own action, the handlers use TokenAdmin. The port name of 02 is unchanged. |
| M1-44/11 |
2026-10-13 |
auth.dpop_required_for is a string list with the default ["agent"] (the JSON form, so the chapter cell and the schema agree) and the cross-check dpop_required_for, which refuses values outside agent, app and personal and names only the key. |
The brief asks to register the allowed values; the configuration kinds have no enum list. |
| M1-44/12 |
2026-10-13 |
Parse accepts the kinds agt, app, pat and whi only; hpc (host pairing code, ADR 0061 addendum, 24 section 3.2) stays with the gitleaks rule and its own ticket, and whi is never accepted by the Authenticator. |
Brief M1-44 lists four kinds; pairing codes are no API tokens of createToken. |
| M1-44/13 |
2026-10-13 |
The tests of ID-T23 and the operations run in the session world of M1-34 (sessions_env_test.go with the token handlers and a fake PersonalTokenSwitch); step-up comes from the M1-35 flow. The cases run as named subtests of three groups (TestAPITokens_Issue, TestAPITokens_Authenticate, TestAPITokens_Lifecycle) with one world per group: a world costs seconds to build and costs only tenths per case, and with one world per case the package passed the default 10 minute test timeout of make test on a loaded machine. The code of the earlier session existed before these tests; mutation runs (format check after the lookup, no Bearer refusal of bound tokens, revocation without the rights change) turned TestIDT23_WrongChecksumRejectedWithoutLookup, TestIDT23_DPoPBoundAgentTokenWithoutProof and TestRevokeToken_Immediate red. |
Test first could not be shown for code written before the session was lost; the mutations prove the tests catch the behaviour. |
| M1-44/14 |
2026-10-13 |
The token examples of docs/api/openapi.yaml (listTokens, createToken, ApiToken, ApiTokenCreated) use a valid 60 character token with the marker EXAMPLE (the first checksum vector), the 6 character prefix bothy_agt_EXAMPL, scopes: [], dpop_jkt and expires_at; the server stub and the TypeScript client are regenerated. |
Red team round 1, minor 3: the old examples were no valid tokens and showed scopes an agent token cannot have; the brief allows changing openapi.yaml where the implementation proves it wrong. |
| M1-44/15 |
2026-10-13 |
Not changed after red team round 1: the table grant UPDATE of bothy_app on api_tokens (as every comparable table, 16 allows DML), apitoken.Parsed without redaction (never logged; it holds what the caller already has), no cross-check auth.personal_token_max_ttl <= auth.api_token_max_ttl (08 does not require it), no second authorization in the revoking unit of revokeToken (the window only decides whether one allow entry is written). The expiry is now cut to microseconds before the check (nit 5). |
Nits of red team round 1 with their reason. |
| M1-44/16 |
2026-10-13 |
An actor with CredentialInactive is keyed by the client IP in ratelimit.RequestKey (as the anonymous actor) and settles the failed-credentials precheck as 401 unauthenticated, so its rl.public_auth token stays spent. This replaces the acceptance of the rate limiter point in M1-44/15. |
Red team round 2 (major, reproduced): a revoked or expired token, also a revoked DPoP-bound token without its key, emptied the buckets of its principal with 429 for every live credential, and an expired row never goes away. The two stages run before authorize; the change is one condition each. |
| M1-44/17 |
2026-10-13 |
The API token store of adapters/postgres and the port identity.Tokens get their own tests in their packages (TestAPITokens_StoreRoundTrip, TestTokens_Port); a token_hash collision on insert stays internal_error like a reused primary key, and the doc comment of InsertAPIToken says so. |
make coverage counts each package by its own tests only, and the HTTP tests of adapters/httpapi left the server module under its threshold; the new store test showed that the comment promised a conflict the error mapping never gave. |
| M1-44/18 |
2026-10-15 |
Port onto main: (*server).handlers in internal/wire/serve.go fills the identity handlers through (*Identity).fillHandlers and the generic fill, which sets a nil handler to the built one; an injected handler stays and nothing is filled before its module started (TestHandlers_FillsBuiltAndKeepsInjected). |
On main the function also fills the signing key handlers of M1-32; with the Tokens branch of this ticket it reached cognitive complexity 16 (gocognit, limit 15). The integration branch made the same change as INT-W12/3 one ticket later; moving the repeated nil checks into one helper keeps the behaviour. |