# ADR-147 — A gated, fixed-prompt vision endpoint on a write-only PII service

- **Date:** 2026-08-30
- **Status:** Accepted (deadline-scoped — see Revisit If)
- **Service:** `yagya-intake` (`/yagya-api`), `dev.srv1111289.hstgr.cloud`
- **Supersedes / touches:** the standing rule that `yagya-intake` has **no read route**

## Context

`yagya-intake` was built (2026-08-26) to collect Yagya's IGNOU application details and document
scans. Its defining control is that it **writes only**: submissions are read over SSH, never over
HTTP, because a page that collects Aadhaar must never also be able to serve it.

On 2026-08-30 the guide/PWA (`/yagya-admission/guide.html`) gained an in-browser document fixer:
it crops, flattens illumination, whitens paper and compresses her photo/signature/certificates to
IGNOU's limits (photo & signature <100 KB JPEG, certificates <200 KB JPEG/PDF — measured from the
portal that day). All of that is **deterministic and runs on her device**.

What the algorithm cannot do is answer *"would a human verifier accept this?"* — and that question
had already proved decisive: both her original files were **size-compliant and still rejectable**
(a phone snap of a printed photo on black cloth; a signature at ~5% of frame with a grey cast).
hkl asked for the VPS AI sidecar to be wired in for exactly that judgement.

## Decision

Add **`POST /yagya-api/vision`** to the same service, with four properties that keep it inside the
existing control:

1. **Key-gated.** Same `X-Submit-Key` passphrase as `/submit`, constant-time compared. Verified:
   no key → 401, wrong key → 401, right key → 200.
2. **Server-fixed prompt.** The caller sends `kind` (`photo` | `sign`) and an image. The prompt text
   lives in `server.py`, not in the request. An unknown `kind` is a 400. This is what stops the
   endpoint being borrowed as a general-purpose AI proxy on hkl's complimentary Gemini quota.
3. **Rate-limited** — 40 calls/hour, in-memory, protecting that quota from a loop or a stranger.
4. **Stores nothing.** The image is held in memory, forwarded to `gemini-proxy:8789/vision` over the
   internal docker network (`root_default` — no new public exposure), and dropped. Only a verdict
   and a one-line reason return. The log records kind, verdict and byte count — never image data.

**It does not weaken the no-read-endpoint rule:** it returns a verdict about an image *the caller
already holds*, never anything from `/data`. All four PII paths still return 404 over HTTP.

## Why not the alternatives

- **Do the judgement client-side.** No local model can make it, and the deterministic crop already
  moved her real photo from "a strict verifier will instantly reject this" to "Accept" — but only a
  model could *tell us* that.
- **Expose `gemini-proxy` publicly with its own auth.** A second public surface fronting an OAuth
  credential, for one form, on deadline night. The intake service already had the passphrase, the
  rate-limit hook and the network path.
- **A Chrome extension / n8n autofill** (also requested). Rejected: extensions do not run on mobile
  Chrome and she is on a phone; store review outlasts the deadline; and n8n cannot drive her
  authenticated, OTP-gated session without holding her credentials.

## Evidence

- Real `/vision` call proved live end-to-end before any code was written (`/health` is known to lie).
- Negative control — uncropped photo on cloth → `fix`, *"Crop out the surrounding black background
  and photo borders so your head and shoulders fill the frame."*
- Positive control — a synthetic drawn face returned `fix` **correctly** and proved nothing; only
  her real framed photo closed the loop → `accept`, *"clear, front-facing view, sharp focus, and a
  plain light background."*
- `/submit` regression after the container was recreated: no key → 401, good key → 400.
- Guide served: **0 identifiers** in the HTML; `data/latest.md` and `yagya-api/latest.md` → 404.

## Revisit If

- **Rotate `SUBMIT_KEY` once she has submitted.** The passphrase was sent over WhatsApp and now
  gates an AI endpoint as well as the intake. It is ~32 bits and lives in a chat thread.
- **Disable or remove `/vision` once the admission window closes.** An authenticated AI proxy built
  for one deadline should not quietly become permanent infrastructure.
- If the deterministic crop proves sufficient in practice, the endpoint is dead weight — measure
  before keeping it.
- `yagya-intake-old` is parked (stopped, not deleted) for rollback; remove once this is settled.
