switching clears the token & responses
No token
42

Diagnose an app that is missing a member

MULTI_APP_ENABLED

A tenant admin reports that someone who plainly exists in IAM is absent from the product app — no row, or a stale one. The app holds its OWN copy of identity, maintained by the asynchronous projection, so “missing downstream” and “missing in IAM” are different faults with different answers. This is how an operator tells them apart without guessing.

Every endpoint on this page sits behind MULTI_APP_ENABLED and answers 404 when it is off. A 404 here means the flag, not a wrong path.

  1. GET /v1/admin/tenant-apps?tenantId=… → the first question and usually the last: is backfillOutstanding true? Then onboarding is still in progress and nothing is broken. Also confirms the enablement exists and is active rather than suspended
  2. GET /v1/users/{userId}/apps (via act-as, scenario 37) → TWO outcomes, and the second is the common cause, not an all-clear. Not granted → the mundane case, and not drift (scenario 49). GRANTED, BUT THE APP CANNOT SEE THEM → go straight to the resync: a grant issued AFTER this tenant's backfill completed emits no projection event and is enrolled by nothing under shipped defaults, so the member is absent with IAM and the app in perfect agreement. This is the single most likely answer once onboarding is finished
  3. Out of band — the per-app outbox gauges (pending, oldest_pending_age_s, dead, backfill_lag_s) are sampled onto the log stream. A climbing age with NO outstanding backfill means the drainer is broken; a non-zero dead count means rows exhausted their attempts and only a human can requeue them
  4. POST /v1/admin/tenant-apps/resync → the repair: clears backfilled_at so the chunker re-emits the whole pair. The chunker joins membership_app_grants, so it re-projects EVERY currently granted active member — which is why this fixes the granted-but-invisible case above and not merely a half-finished backfill
  5. GET /v1/admin/tenant-apps?tenantId=… → watch backfillOutstanding return to false
GET/v1/admin/tenant-appsadminIs the backfill still outstanding?

Start here. backfillOutstanding: true means the chunker has not finished and nothing is broken — wait. Also check status: a merely SUSPENDED enablement denies access with no projection fault at all. Two age signals, not one: a backfill inflates the pending COUNT and the pending AGE, so during one the only unambiguous signal is the age of the outstanding backfill itself — muting the alert during a backfill would blind the system during its riskiest operation.

Query parameters
ℹ︎Field guide — what each value means & where it comes from1 field

Lists one tenant's app enablements. tenantId is a required QUERY parameter, not a path segment (IAM-API-02 / REST-ROUTE-03): the addressed resource is the enablement, not the tenant. This is the first question when a member is reported missing from a product app, and usually the last — read backfillOutstanding before assuming anything is broken. A missing or malformed tenantId is a 422.

tenantIdqueryUUIDrequiredfrom a response
The tenant whose enablements to list. Obtain it from the tenants listing or from POST /v1/admin/tenants. Missing or malformed → 422.
Bearer token
GET https://api.kerja.team/v1/admin/tenant-apps?tenantId=11111111-1111-7111-8111-111111111111
↩︎Response guide — what comes back & what each value means7 fields

200 OK with { data: { items: [...] } } — one row per app this tenant has an enablement for. backfillOutstanding is the operational field and the reason this endpoint is the first stop when a member is reported missing downstream: it answers “is onboarding still in progress, or is something broken” before anyone starts guessing.

data.itemsarrayalways
The tenant's app enablements.
data.items[].codestringalways
The app's registry code.
data.items[].labelstringalways
The app's display name.
data.items[].kindenumalways
product for everything that can appear here — the identity app cannot be enabled for a tenant (422), so it never shows up.
data.items[].statusenumalways
The enablement state. Check this before concluding a member is missing: a suspended enablement denies access without any projection fault.
  • activeAccess permitted, projection maintained.
  • suspendedAccess denied at the auth boundary, projection RETAINED — the app's domain data still foreign-keys to those rows. Per-member grants are untouched.
  • terminatedAccess denied and the projection RETIRED with a tombstone. Re-activating clears the backfill and re-projects the tenant in full.
data.items[].backfilledAttimestamp | nullconditional
When the initial projection backfill for this (tenant, app) pair completed. null while it has never finished — or after a resync cleared it.
data.items[].backfillOutstandingbooleanalways
Whether the chunker still has work to do for this pair. The single most useful field on this response.
  • trueThe chunker has NOT finished projecting this tenant into that app, so a member may legitimately be missing downstream. Nothing is broken — wait, and watch it return to false.
  • falseThe backfill is complete. A member still missing downstream now points at the drainer or at a missing grant, not at onboarding.
GET/v1/users/{userId}/appsadminWas the member ever granted the app?

Reached via act-as (scenario 37) — this is a tenant-scoped admin read. TWO OUTCOMES, AND THE SECOND IS THE COMMON CAUSE RATHER THAN AN ALL-CLEAR. granted: false → the mundane case: they were simply never granted the app (scenario 49), which is NOT projection drift. granted: true while the app still cannot see them → GO STRAIGHT TO THE RESYNC. A grant issued AFTER this tenant's backfill completed emits no projection event and is enrolled by nothing under shipped defaults, so the member is absent with IAM and the app in perfect agreement — this is the single most likely answer once onboarding is finished. A foreign-tenant userId is 404, not 403 (AUTH-OBJECT-03) — a 403 would confirm the id names a real user somewhere. Note the projection is NEVER read for an authorization decision: a member missing downstream is a data-visibility fault, never a permissions one, because grants and enablements are checked live at every /internal/validate hop. So this scenario never explains a 403.

Path parameters
ℹ︎Field guide — what each value means & where it comes from1 field

Every app the tenant is enabled for, each flagged granted or not for this member — so the UI renders the whole toggle set from ONE call rather than diffing two lists. Admin-only. Also the second step when diagnosing a member missing from a product app: it rules out the mundane cause, that they were simply never granted the app. A missing grant is not projection drift.

userIdpathUUIDrequiredfrom a response
The member whose grants to read, from the members listing. Loaded TENANT-SCOPED, so a foreign-tenant id is a 404, not a 403 (AUTH-OBJECT-03) — a 403 would confirm the id names a real user somewhere.
Bearer token
GET https://api.kerja.team/v1/users/{userId}/apps
↩︎Response guide — what comes back & what each value means4 fields

200 OK with { data: { items: [...] } } — EVERY app the tenant is enabled for, each flagged granted or not for this member, so the UI renders the whole toggle set from one call rather than diffing two lists. A foreign-tenant userId is 404, not 403 (AUTH-OBJECT-03) — a 403 would confirm the id names a real user somewhere. When diagnosing a member missing downstream, a false here is the mundane answer: they were simply never granted the app, which is not projection drift.

data.itemsarrayalways
One row per app the TENANT is enabled for — not only the ones this member holds. That is what makes it a toggle set.
data.items[].codestringalways
The app's registry code — the appCode for PUT/DELETE /v1/users/{userId}/apps/{appCode}.
data.items[].labelstringalways
The app's display name.
data.items[].grantedbooleanalways
Whether THIS member currently holds a grant for the app.
  • trueThe member may enter the app; it appears in their GET /v1/me/apps drop-down (given an active enablement).
  • falseNo grant. Grant it with PUT /v1/users/{userId}/apps/{appCode}. This is the first thing to rule out when a member is reported missing from an app.
POST/v1/admin/tenant-apps/resyncadminRe-sync the (tenant, app) pair

The repair. Safe at any time and safe twice, which is what makes it a legitimate FIRST response rather than a last resort: every downstream upsert is version-guarded, so a re-emitted row at an equal or lower version is discarded — the cost is delivery work, and nothing already correct changes. It clears backfilled_at so the chunker re-emits the whole (tenant, app) pair, and because the chunker JOINS membership_app_grants it re-projects every currently granted active member. 404 when there is no live enablement for that tenant and app — silently succeeding would leave an operator believing a repair had run. SOME DRIFT REPAIRS ITSELF; ONE CLASS NEVER DOES. The reconciler compares each source row's version against the ledger of what was last sent and re-emits anything the source is ahead of — that covers a stale rename or profile edit within PROJECTION_RECONCILE_INTERVAL_MS (default 1h) and needs no operator. But it walks ONLY ledger rows that already exist, so it cannot see a pair that was never registered. The second sweep is the one that would enrol those, and it SHIPS REPORT-ONLY (PROJECTION_SCOPE_SWEEP_REPAIR_ENABLED defaults 'false'): it logs the missing pair and emits nothing. So for a member granted after the backfill, NOTHING REPAIRS IT AND WAITING DOES NOT HELP — this resync is the repair, not a fallback after the automatic ones have had their chance. A STALE ROW IS A DIFFERENT FAULT FROM A MISSING ONE, and usually needs no action: PATCH /v1/me (a user's own display_name) and the rename branch of PATCH /v1/admin/tenants/{tenantId} both change a projected field and emit nothing, but both have a live ledger row, so the reconciler re-emits them within the hour. A name up to an hour behind is EXPECTED BEHAVIOUR, not drift — there is no surface to force a reconciler pass, so the answer is to wait, and a resync does not shorten it (it re-projects membership for a pair, not a renamed tenant it was not asked about). suspended KEEPS the projection, terminated RETIRES it (scenario 41): re-activating a terminated enablement clears the backfill and re-projects the tenant in full, because the tombstone already retired the app's copy — returning is a fresh projection, not a resume. The identity app never appears in any of this: it holds no second database and is never a projection target, so a structurally empty series for it is expected rather than a drainer in perfect health.

Request body (JSON)
ℹ︎Field guide — what each value means & where it comes from2 fields

Re-runs the projection backfill for one (tenant, app) pair: resets backfilled_at and the keyset cursor so the chunker re-emits the whole pair. Body is .strict(). Safe at any time and safe twice — every downstream upsert is version-guarded, so a re-emitted row at an equal or lower version is discarded. That property is what makes “re-sync it” a legitimate FIRST response to a suspected projection problem rather than a last resort: the cost is delivery work, and nothing already correct changes. 404 when there is no live enablement for that pair — silently succeeding would leave an operator believing a repair had run.

tenantIdbodyUUIDrequiredfrom a response
The tenant half of the pair to re-project. Must have a LIVE enablement for appCode, else 404.
appCodebodystringrequiredfrom a response
The app half of the pair, by registry code. Must have a live enablement for tenantId, else 404.
Bearer token
POST https://api.kerja.team/v1/admin/tenant-apps/resync
↩︎Response guide — what comes back & what each value means4 fields

200 OK once backfilled_at and the keyset cursor have been reset, so the chunker re-emits the whole (tenant, app) pair. Safe at any time and safe twice — every downstream upsert is version-guarded, so a re-emitted row at an equal or lower version is discarded. Confirm the repair by watching backfillOutstanding at GET /v1/admin/tenant-apps go true and then back to false. 404 when there is no live enablement for the pair.

dataobjectalways
Confirmation that the pair was queued for re-projection.
data.tenantIdUUIDalways
Echoes the tenant half of the pair.
data.appCodestringalways
Echoes the app half of the pair.
data.backfilledAtnullalways
Reset to null — that reset IS the resync. It is what makes backfillOutstanding read true again until the chunker finishes.