think
16px
820px

Background-job owner identity — design

Date: 2026-07-08
Status: spec
Source: integration prompt prompt-ahu-ocr-tidyup-job-owner.md (applied by
analogy). Follow-up to
specs/2026-07-08-audit-identity-and-trace-uniqueness-design.md.

Problem

The X-User-Id work attributed every synchronous request path. But the
chatbot also runs detached background jobs in the ahu-ai-workers stack
(BullMQ, apps/internal-web/src/workers/) that make AI egress after the
originating admin's HTTP request is gone:

  • nightly-eval fires N queries at agent/…/runs — each triggers model +
    embedder egress inside the agent — with no X-User-Id/X-Request-Id.
    That traffic lands unattributed (ahu-chatbot/internal/ blank), exactly the
    gap the observatory security layer can't pin to a person.
  • knowledge-reingest triggers ai-ahu-rag's /admin/ingest. The embedding
    egress happens in that separate engine, but the trigger carries no identity to
    forward.

This is the same class of gap the OCR engine fixed: a queued job still has a
human owner (the admin who launched it) — the human just isn't present when the
worker executes. The synchronous X-User-Id work correctly attributes
"human-in-flight" calls but had no owner to attach to detached worker calls.

Design

Mirror the OCR fix — capture the owner at enqueue, restore it in the worker:

  1. Capture at enqueue. POST /api/admin/jobs already runs under
    requireMutationSession() (the admin session is live). Compute the same
    non-PII opaque id used everywhere else — opaqueStaffId(auth.user.email)
    and store it as ownerId on the job payload (server-injected; a client-sent
    ownerId is overridden). This is the one new piece of state.
  2. Restore in the worker. Each worker reads data.ownerId and sets
    X-User-Id: ownerId on its egress, plus a unique per-call
    X-Request-Id: ${sessionId}-t0-${nonce} (each eval question = its own turn;
    the nonce is why same-session evals no longer collide). The agent's
    RequestIdMiddleware seeds user_id_var → the eval's model + embedder egress
    attribute to the admin. knowledge-reingest forwards the same header to
    ai-ahu-rag.
  3. Fallback label, not blank. If a job has no ownerId (a future
    cron/repeatable eval with no submitter), the worker sends
    X-User-Id: system:<queue> (system:nightly-eval / system:knowledge-reingest)
    — labelled ownerless work, never blank. Today every job is admin-triggered,
    so this is the rare last resort.

Precedence: explicit > job ownerId > system:<queue>.

Shared job payload types move to src/lib/jobs/types.ts (imported by the route
and both workers) so ownerId can't drift; the worker handlers are extracted as
pure exported functions so they're testable without a live Redis worker.

Dormancy

Additive X-* headers only; URLs/bodies untouched. With MODEL_GATEWAY_URL
unset the agent egress still hits plain vLLM, which ignores the header.

Tests

  • Enqueue: authed admin → job.data.ownerId === opaqueStaffId(email); a
    client-supplied ownerId is overridden.
  • nightly-eval handler: ownerId present → each /runs call carries
    X-User-Id: ownerId + a unique X-Request-Id; absent → system:nightly-eval.
  • knowledge-reingest handler: forwards X-User-Id (owner or
    system:knowledge-reingest).

Out of scope

Tagging synthetic eval traffic with a distinct X-Surface (e.g. eval) so the
observatory can exclude it from real-traffic metrics — noted in prompt 1; needs
an agent-side surface override. Separate ticket.