Skip to content

3. No local token-minting bridge

Date: 2026-07-28

Status

Accepted

Context

imbi-api has no delegation primitive yet: ADR 0016 is accepted and entirely unimplemented, and POST /auth/token dispatches only authorization_code, refresh_token, and client_credentials. So delegated (run-as-user) tasks cannot work until the token-exchange grant ships.

There is, however, a shortcut. imbi.common.auth.core.create_access_token accepts an email and returns a valid access token, and any service holding IMBI_AUTH_JWT_SECRET can call it. imbi.slackbot.identity.mint_token does exactly this today and injects the result as the bearer for every tool call. The scheduler could do the same and deliver run-as-user before ADR 0016 lands.

The scheduler design accordingly specified a bridge: IMBI_SCHEDULER_ALLOW_LOCAL_MINT, defaulting to false, refused outside development unless set explicitly, limited to tasks whose subject equals their creator, never combined with scheduler:impersonate, recording identity_kind='minted' on every run so the audit gap is visible, and deleted rather than deprecated once the exchange grant ships. Its own recommendation was to build phase 1 with service-account identity only and skip minting; the flag existed to make the alternative explicit.

What changed is that the bridge stopped being worth its cost. Phase 2 — the Consent model, the token-exchange grant, AuthContext.actor — is deferred: imbi-api does not implement the exchange grant, so a delegated_user task is stored but every firing is recorded as skipped. The bridge's justification was to cover the gap between the phases, but covering it means minting tokens for users who never consented, which is precisely the audit gap the bridge was supposed to be a concession to. A visible skipped run is the better failure.

Decision

Do not build the bridge. No IMBI_SCHEDULER_ALLOW_LOCAL_MINT setting, no minting code path, no identity_kind='minted' value in the run model.

Phase 1 executes identity.kind='service_account' only. A task with kind='delegated_user' is still accepted and stored — the definition is valid, and rejecting it at creation would make the phase-2 cutover a data migration rather than a code change. It simply cannot run: resolving its identity raises, so every firing is recorded as skipped with a pointer to the token-exchange grant it is waiting on, until phase 2 lands and the exchange makes it work.

Consequences

Positive

  • The audit gap this service could have introduced is never introduced. There is no window in which a scheduler_runs row says a user did something the user never consented to.
  • Nothing to delete later, so no risk that the "temporary" path outlives its exit criteria — the common fate of flags like this one.
  • One fewer setting, one fewer identity kind, one fewer branch in identity resolution, and one fewer value in the run-state vocabulary.

Negative

  • Delegated tasks do not work until phase 2 lands. Phase 1 delivers system tasks on the scheduler's service account, which is the correct model for the work being migrated first (the score-recompute daily tick is a system task). A user wanting a run-as-user task waits for phase 2 rather than getting a version whose attribution is a lie.
  • If phase 2 were to slip badly, this decision would have to be revisited. That is a scheduling risk, not a design one, and reversing it is adding back a gated code path.

Note on the wider hole

Removing the scheduler's minting path does not close the platform's. Any service holding IMBI_AUTH_JWT_SECRET can still mint a token for any user with no consent record, no act claim, no jti in TokenMetadata, and nothing in audit distinguishing it from that user logging in — and imbi-slackbot ships that today.

The case for token exchange is therefore consent, attribution, and revocation, not capability; ADR 0016's claim that "run as a user cannot be satisfied by any existing mechanism" is not accurate. Delegation only closes the hole once local minting stops being supported. Retiring imbi.slackbot.identity.mint_token in favor of the exchange grant is an imbi-api and slackbot decision, out of scope here, and recorded so it is not left implicit.