# RACF → REVEALDI observe-only handoff For John (RACF). Implio remains the live website decision. REVEALDI results are **internal comparison only**. This document describes the **REVEALDI** receiving API and observation webhook. RACF changes are listed as required work; they are **not** implemented in the RACF repository from here. Do not put credentials in this file, in Git, or in chat. Inbound API credentials are supplied separately. ## What RACF must do 1. After (or in parallel with) the existing Implio send, POST the same content to REVEALDI. Do **not** wait for REVEALDI before completing the Implio path. 2. If REVEALDI is slow or unavailable, continue the Implio flow unchanged. 3. Host image URLs that REVEALDI’s server can GET. Browser `blob:` URLs are not usable. 4. Expose an HTTPS receiver for `observation.result`. Record the payload for comparison. **Do not** hide content, restore content, take a profile offline, or show REVEALDI results to users. 5. Confirm the callback URL and its authentication requirements. Subscribe that webhook to `observation.result` only. ## Endpoint ``` POST https://api.revealdi.com/v1/submissions ``` Staging origin: `https://api.revealdi.com`. Path is `/v1/submissions`. Existing HMAC clients keep working; new fields are additive. Inbound credentials are supplied separately, not in this file. ### HMAC headers (unchanged) | Header | Meaning | | --- | --- | | `X-Panpad-Key-Id` | Inbound API key id (supplied separately) | | `X-Panpad-Timestamp` | Unix seconds | | `X-Panpad-Request-Id` | Unique UUID per HTTP attempt | | `X-Panpad-Content-SHA256` | Hex SHA-256 of the **raw JSON body bytes** | | `X-Panpad-Signature` | Hex HMAC-SHA256 of the canonical string | Canonical string (UTF-8, LF): ```text POST /v1/submissions {TIMESTAMP} {REQUEST_ID} application/json {CONTENT_SHA256} ``` Clock skew allowance: 300 seconds. Replay of the same `Request-Id` is rejected. A new `Request-Id` with the same body is the correct retry. The PHP helper retries 408, 429, 5xx, connect/read timeout, and connection reset (backoff 5 / 15 / 45 / 90 seconds, default max 5 attempts). Idempotency support alone does not retry; the sender must. ### Required JSON fields | Field | Role | | --- | --- | | `client_submission_id` | **Source event ID.** Idempotency key, namespaced by `client_id`. Not a REVEALDI id. | | `content_type` | `profile_v1`, `gallery_v1`, or `caller_v1` | | `external_ref.user_id` | Source user ID. Already required on existing HMAC bodies. | | `external_ref.profile_revision` | Item/profile revision. Already required. Use this for late/out-of-order results. | At least one modality: non-empty profile text and/or one or more images. ### Strongly recommended for this integration Confirm names with John. Implio field names are a reference only. | Field | Role | | --- | --- | | `external_ref.item_id` | Source item ID, **distinct from user ID**. Optional on existing bodies so they stay valid. Required for useful RACF correlation. | | `external_ref.content_revision` | Optional content revision if different from `profile_revision`. | | `external_ref.source_created_at` / `source_published_at` | Optional source timestamps (strings). | | `assets[].image_id` | Stable per-image id. If omitted, REVEALDI assigns `profile_image` / `profile_image_N`. | | `assets[].source_url` | `https://` URL REVEALDI will retrieve. Not a `blob:`, `data:`, or `file:` URL. | | `profile.bio` / `profile.display_name` | Text/bio when applicable. | ### Optional contact metadata (never required for moderation) `external_ref.username`, `external_ref.email`, `external_ref.admin_url`. Email is accepted and stored for the console only. It is **not** sent to models, training/correction exports, decision webhooks, or observation callbacks. ### Sample request (image URLs) ```json { "client_submission_id": "racf-evt-1001", "content_type": "profile_v1", "profile": { "display_name": "Ada", "bio": "Hello from a controlled test." }, "assets": [ { "role": "profile_image", "image_id": "photo-a", "source_url": "https://cdn.example.test/p/a.jpg?exp=..." }, { "role": "profile_image", "image_id": "photo-b", "source_url": "https://cdn.example.test/p/b.jpg?exp=..." } ], "external_ref": { "user_id": "123456", "item_id": "item-88", "profile_revision": "3", "content_revision": "3", "username": "ada", "email": "ada@example.test", "admin_url": "https://admin.example.test/users/123456" } } ``` Every extra photo uses `role: "profile_image"`. Maximum **8** images. `image_id` values must be unique in the request. Hosts must be on the client allowlist (`PANPAD_HMAC_RACF_ASSET_HOSTS` and/or admin asset hosts). ### Acceptance **201** — new event accepted and queued. Process asynchronously. Do not treat this as a moderation decision. ```json { "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", "client_submission_id": "racf-evt-1001", "content_type": "profile_v1", "policy_set": "racf_profile_v1", "external_ref": {"user_id": "123456", "profile_revision": "3", "item_id": "item-88"}, "created_at": "2026-09-08T16:00:00Z", "processing_status": "queued", "decision": null, "reason_codes": [], "user_messages": [], "primary_reason": null, "reasons": [] } ``` `id` is the REVEALDI submission id. Keep `client_submission_id` as the source event id. **200** — same `(client_id, client_submission_id)` and the same canonical body. Returns the original submission. No second job. ### Errors | HTTP | `error.code` | When | | --- | --- | --- | | 401 | auth codes | Bad HMAC, skew, replayed `Request-Id` | | 409 | `IDEMPOTENCY_CONFLICT` | Same `client_submission_id`, **different** body | | 422 | `VALIDATION_ERROR` | Unknown fields, too many images, duplicate `image_id`, empty modalities, `blob:` / `data:` / `file:` | | 422 | `ASSET_URL_REJECTED` | Non-https, credentials in URL, literal IP, localhost, host not allowlisted | | 422 | `UNSUPPORTED_CONTENT_TYPE` | Unknown `content_type` | Example: ```json {"error": {"code": "VALIDATION_ERROR", "message": "Request validation failed."}} ``` Validation failures do not consume the HMAC nonce. A 201/200 is durable acceptance, not a finished evaluation. ## Image retrieval (REVEALDI server) REVEALDI fetches each `source_url`. It does not accept uploaded bytes on this endpoint. | Rule | Value | | --- | --- | | Scheme | `https` only | | Redirects | None. 3xx is a permanent ingest failure | | Size | 8 MiB | | Timeouts | connect 5s, read 10s, overall 20s | | Types | JPEG / PNG / WebP | | SSRF | No private/metadata/localhost/`.local`/literal IPs; DNS pinned; no second lookup | | Logs | Query strings and signed-URL secrets are not written to `source_url_redacted` | Failed, expired, or partial retrieval → observation `processing_status: incomplete` (or `failed`). **`proposed_decision` is null.** Never an automatic PASS/APPROVE. Stored images stay on REVEALDI infrastructure, outside Git. Dedup key is `content_sha256`. Retention follows the existing object store. ### John must confirm - Are photo URLs public, or do they need a header/cookie REVEALDI does not have today? - Signed URL lifetime. Recommend **at least 20 minutes** after POST so retries can finish. - If a URL expires, will RACF reissue, or should REVEALDI mark incomplete? - Can the same URL later serve different bytes? REVEALDI hashes the bytes it actually downloaded. ## Observation webhook (REVEALDI → RACF) Subscribe the RACF webhook to **`observation.result` only**. Do not treat `APPROVE` / `REFUSE` / `EXCEPTION` as site actions. `racf` is an observe-only client: REVEALDI will not enqueue `content.hide`, `content.restore`, `profile.offline`, or `profile.restore` for it. Signing is the **same** as existing webhooks (`docs/webhooks.md`). Canonical path stays `panpad.webhook.v1` even though the JSON `spec_version` is `panpad.webhook.observe.v1`. ### Payload ```json { "spec_version": "panpad.webhook.observe.v1", "event_id": "evt_0123456789abcdef0123456789abcdef", "event_type": "observation.result", "application": "observe_only", "occurred_at": "2026-09-08T16:00:05Z", "client_id": "racf", "source_event_id": "racf-evt-1001", "external_ref": { "user_id": "123456", "item_id": "item-88", "profile_revision": "3", "content_revision": "3" }, "submission_id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", "revision_id": "bbbbbbbb-cccc-dddd-eeee-ffffffffffff", "moderation_run_id": "cccccccc-dddd-eeee-ffff-000000000000", "processing_status": "completed", "proposed_decision": "REFUSE", "reason_codes": ["IMAGE_NSFW"], "error_code": null, "images": [ { "image_id": "photo-a", "role": "profile_image", "ingest_status": "stored", "content_sha256": "…", "checks": { "face": {"state": "PASS", "score": 1}, "nsfw": {"state": "MATCHED", "score": 0.91, "reason": "IMAGE_NSFW"}, "ocr": {"state": "PASS", "score": null}, "possible_minor": {"state": "SHADOW", "score": 0.02}, "suggestive": {"state": "NOT RUN", "score": null} } } ], "checks": { "text": {"state": "PASS"}, "nsfw": {"state": "MATCHED", "score": 0.91, "reason": "IMAGE_NSFW"}, "possible_minor": {"state": "SHADOW"}, "suggestive": { "state": "NOT RUN", "score": null, "applied_threshold": null, "threshold_status": "uncalibrated", "reason": "Suggestive detector is untrained. Do not invent a score." } }, "models": { "face": {"source": "yunet_2023mar", "implementation": "real"}, "nsfw": {"source": "marqo_nsfw_384", "implementation": "real"} }, "policy": { "policy_set": "racf_profile_v1", "policy_version": "…", "policy_sha256": "…", "thresholds": {"IMAGE_NSFW": 0.8, "suggestive": null} }, "note": "Internal comparison only. Do not apply to the website, change visibility, or take a profile offline. Implio remains the live decision." } ``` `processing_status`: | Value | `proposed_decision` | | --- | --- | | `completed` | `APPROVE` / `REFUSE` / `EXCEPTION` from REVEALDI policy | | `incomplete` | always `null` (missing/expired/failed image) | | `failed` | always `null` | Unavailable detectors report `NOT RUN` / `UNAVAILABLE`. Suggestive Vision is observational only. It does not refuse users. When `proposed_decision` is `REFUSE`, the payload includes a compact correction only. RACF owns the card chrome. REVEALDI sends: ```json { "proposed_decision": "REFUSE", "reason_codes": ["IMAGE_NO_FACE", "IMAGE_OCR_CONTACT"], "message": "Photo: Show a clear face and remove contact details from your photo.", "user_refusal": { "bio": null, "photo": "Show a clear face and remove contact details from your photo.", "language": "en", "user_messages": ["Photo: Show a clear face and remove contact details from your photo."], "message": "Photo: Show a clear face and remove contact details from your photo." } } ``` RACF frontend/app should wrap that correction with its own strings: - Title: `Profile review not approved` - Subtitle: `Your profile didn't pass our review. Please update the required sections to go online.` - Dynamic REVEALDI message: `Bio:` / `Photo:` lines from `message` or `user_refusal` - CTA: `Fix profile` Do not expect `title`, `subtitle`, or `cta` on new payloads. Older persisted deliveries may still contain those keys; do not rewrite history. Render only `bio` / `photo` / `message` when present. Do not concatenate `reason_codes` or `reasons[]` into a paragraph. GET `/v1/submissions/{id}` uses the same correction object. Internal `reason_codes` stay complete. Email and admin URLs are stripped. ### Verification (same as existing webhooks) See `docs/webhooks.md`. PHP receiver: `revealdi_observe_webhook.php`. Do not copy an Implio endpoint. Dedupe on `event_id`. HTTP 2xx (including 204) means **the callback was accepted**, not that a website action was applied. Retries: 408, 429, 5xx, connect/read timeout; backoff 5 / 15 / 45 / 90 seconds; default max 5 attempts. 3xx and other 4xx are permanent. Delivery jobs can be retried from admin after `dead`. ### Revisions Ignore or supersede an observation when a newer `external_ref.profile_revision` (or `content_revision`) exists for the same source user/item. Late results must not overwrite a newer Implio or RACF state. ## Isolation - Profile-offline remains disabled on REVEALDI (`PANPAD_CONSOLE_CAN_PROFILE_OFFLINE=false`). - Observe-only is an integration restriction, independent of per-rule SHADOW modes. - IMAGE_NSFW enforcement stays at **0.8**. Suggestive Vision is observational SHADOW inference only. It does not send approve/refuse/unvalidate actions. ## Controlled staging test (after credentials) 1. REVEALDI supplies inbound HMAC key id + secret (out of band). 2. John confirms image hostnames for the allowlist and a callback URL. 3. REVEALDI registers the webhook with `observation.result` only. Signing secret shown once. 4. John POSTs one controlled profile with two **real https** image URLs and unique `client_submission_id`. 5. Expect 201, then an `observation.result` callback. 6. Replay the same body with a new `Request-Id` → 200, same `id`. 7. POST a `blob:` URL → 422. 8. Confirm RACF recorded the callback and **did not** change site visibility. 9. Do not enable mirrored production traffic until this controlled test is confirmed. Until John provides a receiver, REVEALDI uses a mock/internal endpoint only. ## Outstanding questions for John 1. Exact outgoing RACF JSON (field names may differ from this schema). 2. Image access: public vs signed vs authenticated; expiry; reissue. 3. Callback URL, desired path, and any extra auth RACF requires besides our HMAC headers. 4. Whether `item_id` is always present and distinct from `user_id`. 5. A controlled test submission id and confirmation that the callback arrived. ## Not in scope here Detector training. Staff-review extras. Claiming RACF end-to-end success. Paid external detection. Uploading owner photos to a third party.