think
16px
820px

Integration prompt — ahu-ocr-tidyup: carry the submitter's id into queued/background jobs

Paste into a Claude Code session inside the ahu-ocr-tidyup repo. Follow-up to prompt-ahu-ocr-tidyup-identity.md (DONE — commit 44ce9a2, deployed to staging; report confirms bindPrincipalsetCurrentUserId(principal.sub) on the AsyncLocalStorage trace store, buildGatewayHeaders reads currentUserId(), precedence explicit ctx.userId > ambient verifier > system:<systemStage>). No gateway change is required here either — this is a second client-side identity gap, found by tracing a real transaction end to end.

Why (read first — this is the actual gap, confirmed live)

A live trace was checked against the observatory right after the previous fix deployed: a real document went through paddleocr-layout and gpu-server-akta. Both calls landed as system:paddleocr-layout / system:gpu-server-aktanot the submitting verifier's id. A broader check found this is universal: every ahu-ocr call, ever recorded, has user_id either blank or system:* — never a human id.

This is not a bug in the previous fix — it's the fix's own design working exactly as specified: "a call running in a verifier's request → their id; a detached/background pipeline → system:<stage>." The problem is that for this engine, the AI egress calls (paddleocr, gpu-server VLM extraction, Azure DI, classifier) run in queued/detached workers, disconnected from the HTTP request's AsyncLocalStorage extent, by construction — so the human branch of that precedence rule has no calls left to ever attach to. The previous fix correctly distinguishes "human request in flight" from "no human in flight," but conflates "no human right now" with "no human at all." A queued job submitted by a verifier still has a human owner — the human just isn't present at the moment the worker picks the job up.

The fix: capture the submitter's id at enqueue time (while the verifier's request context is still live) and attach it to the job's own tracking record, then restore it into the identity context when the worker later executes that job's gateway calls — instead of falling through to system:<stage>.

Scope

1. Find where a document/job becomes "detached" from the request

You already have persistent job tracking (job_id/parent_job_id appear in the audit trail, and the Job API supports poll/resume — JOB_UNKNOWN → resubmit same idempotency key). Locate wherever a job record is created — the enqueue/submit path for background extraction (the code that eventually calls the gpu-job-client.ts / azure-on-prem-* / paddleocr-layout / ocr-ensemble / classifier / doc-forensic-client clients from a worker, not from the original request handler).

2. Capture the owner at enqueue time

At the point a job is created, the submitting verifier's request context is still live (the same AsyncLocalStorage extent bindPrincipal populated). Read the current id the same way gateway-headers.ts does (currentUserId() from trace-context.ts) and store it as an ownerId field on the job record, alongside job_id. This is the one new piece of state — everything else reuses machinery that already exists.

  • If a job can be submitted with no authenticated principal at all (a truly ownerless case — e.g. an internal admin sweep with no per-doc actor), ownerId is legitimately absent; that job keeps falling through to system:<stage>. Don't invent an owner where there isn't one.

3. Restore the owner when the worker executes

Wherever the worker/background path actually performs the gateway-facing calls for that job (the same call sites the system:<systemStage> fallback fires from today), wrap the execution in a scoped identity context using the owner id instead of the ambient ("no principal here") default — e.g. a sibling to the existing runWithTrace helper, something like runWithUser(ownerId, () => processJob(job)), so currentUserId() inside that extent resolves to the owner rather than falling through.

4. Update the resolution precedence

explicit ctx.userId > job ownerId (worker context) > ambient verifier (synchronous request) > system:<systemStage>. In practice a given call site is either synchronous-in-request (ambient verifier applies, as today) or worker-detached (job ownerId now applies) — system:<systemStage> becomes the true last resort, reserved for genuinely ownerless work (scheduled reprocessing, admin bulk operations with no per-doc submitter).

5. Keep the systemStage slugs as the fallback label, not the default

The system:<stage> slugs from the previous fix (azure-ktp, paddleocr-layout, ocr-ensemble, doc-classifier, doc-forensic, etc.) stay exactly as they are — they're still correct for the truly-ownerless case. This change just means they should almost never fire in practice, because almost every OCR job has a submitting verifier.

Tests to add/extend

  • A job enqueued by verifier X, then processed outside any request context (simulate the worker path directly, no AsyncLocalStorage extent from a request) → its gateway calls carry X-User-Id: X, not system:<stage>.
  • A job with no ownerId (genuinely ownerless path, if one exists) → still emits system:<stage>, confirming the fallback isn't removed, only demoted.
  • ownerId capture at enqueue time reads the same principal source as bindPrincipal/currentUserId() — no parallel identity lookup invented.

Dormancy + verification

  • Dormancy proof: unchanged from the previous fix — additive X-* header only, URLs/bodies untouched, MODEL_GATEWAY_URL unset behaves identically.
  • Post-flip check (needs the OCR/Job gateway adapters live — Phase A/B, same caveat as last time): submit one document as a known verifier, let it run through the full async pipeline untouched by hand, then confirm in the observatory the entire trace (paddleocr-layout, gpu-server-akta, any Azure DI/classifier calls) carries that verifier's id — not just whichever stage happens to run synchronously. Cross-check against the doc_hash from that submission.

Report back

Where jobs are created/tracked (file + the job record's shape) and where ownerId was added; where the worker execution path was wrapped with the restored identity context; confirmation the precedence now reads owner-before-system; and whether any job type turned out to be genuinely ownerless (and which).