Native conversation-upload intake
API2 owns every public conversation-upload contract:
GET /v1/conversation_uploads/:idGET /conversations/upload/:id(deprecated compatibility alias)POST /v1/conversation_uploads/presign/POST /conversations/upload/presignPOST /v1/conversation_uploads/POST /conversations/upload
Cutover: Rails proxy fallback removed (2026-09-27, ENG-3488 )
Why now. The remaining Rails destination for presign/create was the
proxyConversationUpload fallback taken when API-key authentication failed,
the conversation_upload_intake_native flag was off for the organization, or
regional storage was unresolved. Rails production is configured at
minInstances: 0 / maxInstances: 0 in both regions since
#13164 (watch-path fix
#13167 ), so on the
configured topology that fallback no longer has a healthy upstream: a
forwarded upload would surface as a gateway upstream_error/timeout, not a
Rails 201. Keeping the branch therefore only preserved a failure mode; the
flag-ring evidence gates written for a live Rails upstream (Kuda
ciphertext_fallback → natural $feature_flag_called → 1:1 reconciliation)
cannot be satisfied against a zero-replica origin and are recorded below as
historical. Whether the 0..0 configuration has actually reconciled in
Flightcontrol was not verified from the authoring session.
Change. nativeDecision() no longer reads PostHog and no longer
proxies. It returns the terminal response itself:
| Condition | Previous behaviour | Now |
|---|---|---|
| Bearer token unresolvable (digest and deterministic-ciphertext miss) | forward to Rails, outcome=unauthenticated_fallback warn | 401 {"error":"Unauthorized"} (same body Rails ApiKeyAuth returns), outcome=unauthorized |
| Flag off for the organization | forward to Rails | flag not consulted; CONVERSATION_UPLOAD_INTAKE_NATIVE removed from posthog.ts |
| Storage resolver unconfigured | forward to Rails, one warn per process | 503 {"error":"Service Unavailable"}, outcome=storage_unconfigured per request |
lib/conversation-uploads/proxy.ts is deleted and dropped from the
RAILS_INTERNAL_BASE_URL source-gate allowlist, so a reintroduced upload
proxy fails CI. Exact-route ownership pins for the five contracts are
unchanged. The deprecated /conversations/upload* aliases still carry the
Deprecation/Link headers from annotateLegacyPublicApiResponse.
Tests. public-intake.db.test.ts (PGlite) asserts the 503/401
terminals, the per-request intake log lines, that the bearer token never
reaches a log, and that proxyLegacyRailsRequest is never invoked;
rails-internal-base-url.source-gate.test.ts fails if the allowlist entry or
the module returns. app.route-ownership.test.ts still pins native ownership
of the five contracts.
Rollback. Revert the package PR and redeploy API2 (the fallback was
code, not configuration). Restoring a working Rails destination
additionally requires minInstances: 3 / maxInstances: 10 for
rulebase-api-prod and rulebase-api-prod-eu per #13164; without that, the
revert only restores the forwarding attempt. The PostHog flag (id 280845,
still exact kuda-pilot at 100 % per the 2026-09-26 read) is left for the
operator to archive after the revert window; nothing reads it.
Gate (post-deploy, not yet observable). One complete 24 h window per
region with conversation_upload_native_intake outcome=unauthorized and
outcome=storage_unconfigured at zero for organizations that produced Rails
201s in the last live census (Kuda ≈ 700–900/day), outcome=created at
Kuda’s daily volume, and legacy_rails_gateway outcome=forwarded path=/conversations/upload* absent by construction. A non-zero
unauthorized count for a known customer key is the rollback trigger: it
means ENCRYPTION_DETERMINISTIC_KEY/ENCRYPTION_KEY_DERIVATION_SALT are
missing from the API2 task or the key is undecryptable (rotate with the
customer). No production census was available to the authoring session; the
sections below are the pre-cutover history.
Pre-cutover history (flag-gated rollout, 2026-09-18 → 2026-09-26)
Presigning and multipart direct creation were behind the default-off
organization PostHog flag conversation_upload_intake_native. API2 evaluated
the flag only after API-key authentication and only when the regional
conversation-upload storage resolver had credentials. Otherwise the existing
Rails proxy remained responsible for the request.
Current gate: natural matched traffic, not configuration
A read-only presence check of the deployed API2 task configuration at
2026-09-18T15:15:21Z (build cmu70; variable presence only, no values read)
showed the storage resolver is configured in both regions: the US task has
AWS_REGION and AWS_CONVERSATION_UPLOADS_BUCKET; the EU task has
CONVERSATION_UPLOADS_USE_R2, CLOUDFLARE_CONVERSATION_UPLOADS_ENDPOINT,
CLOUDFLARE_CONVERSATION_UPLOADS_BUCKET, CLOUDFLARE_S3_ACCESS_KEY_ID, and
CLOUDFLARE_S3_SECRET_ACCESS_KEY. The earlier blanket statement that API2
tasks had no conversation-upload configuration or credentials is stale.
The conversation_upload_intake_native flag is still version 1, scoped to the
single pilot organization (kuda-pilot), with last_called_at null (advisory
only — confirm with a $feature_flag_called query filtered to
$feature_flag_response = true, per the flag-evaluation evidence gate), and
there are zero conversation_upload_native_intake events since cmu70 went
live. In the post-deploy gateway slice 13:54:00Z..15:07:45Z, all 81
POST /conversations/upload requests were still forwarded to Rails (201).
The gate for expanding the flag is therefore natural matched traffic from the
pilot organization and a native-versus-Rails outcome comparison, not
provisioning. Do not replay or synthesize uploads to force that evidence.
Why the pilot never produced native events (2026-09-21 → 2026-09-23)
nativeDecision() authenticates the bearer token before evaluating the flag,
and until ENG-3375 API2 resolved
API keys by key_digest only. Kuda’s key predates that column, so Rails
authenticated it through its deterministic-encryption key equality fallback
while API2 returned unauthenticated_fallback and proxied every upload to
Rails without ever evaluating the flag. The $feature_flag_called history
(three probe events at 2026-09-19T18:16Z) and last_called_at therefore say
nothing about the canary.
ENG-3375 gives authenticateExternalApiContext the same exact-equality lookup
Rails performs: digest first; on a miss, and only when API2 has
ENCRYPTION_DETERMINISTIC_KEY and ENCRYPTION_KEY_DERIVATION_SALT, the
deterministic ciphertext of the presented token is compared to the stored
key (still active = true; no decryption, no plaintext at rest). A match
backfills key_digest/key_prefix with COALESCE and logs
event=external_api_key_auth outcome=ciphertext_fallback once per legacy
key, after which the digest path serves it. Without the env the behaviour is
unchanged, and Maintenance::BackfillApiKeyDigestsTask remains the bulk
alternative. No flag, schema, provider or production-data change is required.
Fresh evidence (exact 24 h ending 2026-09-23T01:20:02Z). Gateway
forwards Rails answered non-404: US POST /conversations/upload 201 ×1,
EU GET /conversations/ ×23, EU GET /conversation_fields ×1 — all
pre/overlapping the #12820 deployment at 00:56Z. First fully post-deploy
slice 00:57Z–01:20Z: zero meaningful forwards, four US scanner 404s. The
upload is the only remaining supported public residual; the exact 7 d window
is reported separately in the retirement plan and still carries pre-cutover
upload residue (≈700–900 201s/day before the canary can go live).
Canary gates after ENG-3375 deploys (natural traffic only, no replay).
- Exactly one
outcome=ciphertext_fallbackfor Kuda’sapi_key_id, then none (key_digestpopulated; read-only SQL check in the runbook 1a). - First natural
$feature_flag_calledresponse=trueforkuda-pilotafter that timestamp. conversation_upload_native_intake outcome=created≈ Kuda’s daily volume;unauthenticated_fallbackfor Kuda → 0; noinvalid/storage_failed/insert_failed;handoff=accepted≥ 99.9 %.- 1:1 upload/blob/attachment/delivery/conversation reconciliation for one 24 h window before any widening.
Rollback. Set conversation_upload_intake_native to 0 % (or disable);
every API2 instance resumes forwarding within 20 s and Rails still accepts
the same key (digest or ciphertext). The auth fallback itself is inert
without the env and reverts by PR; a backfilled digest is exactly what the
Rails maintenance task would have written, so no data rollback is needed.
The resolver requires these values per region (Rails uses R2 via
CONVERSATION_UPLOADS_USE_R2 in development and EU, and AWS S3 through the
task role in US); re-verify them before enabling the flag for any additional
organization:
- R2:
CONVERSATION_UPLOADS_USE_R2=true,CLOUDFLARE_CONVERSATION_UPLOADS_ENDPOINT,CLOUDFLARE_CONVERSATION_UPLOADS_BUCKET,CLOUDFLARE_S3_ACCESS_KEY_ID, andCLOUDFLARE_S3_SECRET_ACCESS_KEY. - US S3:
AWS_REGIONandAWS_CONVERSATION_UPLOADS_BUCKET, with an IAM policy allowing the API2 task role to put, delete, and presign objects.
The conversation-upload service is deliberately separate from the existing
cloudflare Active Storage service (CLOUDFLARE_R2_*). No storage credential
should be exposed to a task until the regional bucket and IAM policy have been
verified.
Rollout
- Deploy API2 with the storage resolver disabled or unconfigured.
- Verify native status reads, tenant isolation, and deprecated response headers in each region.
- Provision and verify the regional bucket, credentials, and (in US) IAM policy.
- Exercise presign and direct-create in a non-production organization while the flag remains off, then enable the flag for that organization.
- Compare native and Rails status, duplicate, storage, and delivery-ledger outcomes before expanding the rollout.
- Keep the Rails processor unchanged during the migration. API2 writes the same Rails-owned upload, Active Storage, and delivery-ledger tables and hands processing to the existing API2 queue.
The flag must remain off for every organization other than the pilot until the pilot produces natural native intake events whose status, duplicate, storage, and delivery-ledger outcomes match the Rails baseline.