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-evalfires N queries atagent/…/runs— each triggers model +
embedder egress inside the agent — with noX-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-reingesttriggersai-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:
- Capture at enqueue.
POST /api/admin/jobsalready 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 asownerIdon the job payload (server-injected; a client-sent
ownerIdis overridden). This is the one new piece of state. - Restore in the worker. Each worker reads
data.ownerIdand sets
X-User-Id: ownerIdon 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
RequestIdMiddlewareseedsuser_id_var→ the eval's model + embedder egress
attribute to the admin.knowledge-reingestforwards the same header to
ai-ahu-rag. - 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-suppliedownerIdis overridden. nightly-evalhandler:ownerIdpresent → each/runscall carries
X-User-Id: ownerId+ a uniqueX-Request-Id; absent →system:nightly-eval.knowledge-reingesthandler: forwardsX-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.