feat(b1): Railway ingestion service owns chunk persistence + terminal document status (forwarded user JWT, no service-role key)#70
Open
tornidomaroc-web wants to merge 1 commit into
Conversation
… status Enabler (b1) from register #50. The ingestion service now runs convert -> chunk -> embed -> persist chunks -> write the terminal `documents` status, and returns only a small ack {document_id, chunk_count, status}. /convert is renamed /ingest: both the request shape (it now needs document_id, kb_id and the user's token) and the response shape are incompatible with the old contract, so a version-skewed deploy gets a loud 404 instead of an old Next silently overwriting markdown_content with null on a document the service already finished correctly. Auth is Option B - a forwarded user JWT, NOT a service-role key. Next lifts the already-verified session's access_token and forwards it in X-Supabase-Token (Authorization still carries INGESTION_TOKEN); the service builds a per-request Supabase client from the public anon key plus that bearer, so auth.uid() is the uploader and the existing RLS policies apply unchanged. Register #45 proved this exact service was publicly duplicable with a shared bearer token, so an RLS-bypassing credential in that process would have been a full-database breach - the failure backfill.py's docstring forbids. The client is built per request, never module-level, because it holds the bearer header. Hardening that rode along: the target row is read through RLS before any expensive work (invisible row -> 404, so a foreign document_id never costs a Voyage batch); kb_id is taken from the database and a caller-supplied mismatch is rejected; the ready-update checks it matched a row, since an RLS-filtered UPDATE returns 200/0-rows rather than an error; persistence reuses backfill.py's idempotent shape (delete().eq(document_id) then batch-insert at 50). Error ownership moves with the work. Railway writes its own status='error' + error_message. Next keeps unconditional error writes only pre-forward; every post-forward failure - including a thrown or timed-out fetch, which previously fell to the outer catch and stranded the row at 'processing' forever - now writes through a .eq('status','processing') guard. That closes the orphan class without opening its mirror image, where Next would stomp 'error' over a 'ready' row the service had already written before the ack was lost. Next no longer receives or inserts chunks, so the 9-13x return blob (every chunk's 1024-float embedding plus the full markdown, materialized by await pyResponse.json()) is gone from the Next function's memory. Not in this PR: B6 itself - ingestion stays synchronous, Next still awaits. backfill.py's deploy posture is untouched (still local-only). B5b (§1) stays unstarted; (b1) is the B6 enabler, not B5b progress. Docs: register row #51, one new §7 block (20 -> 21, additions-only, all landed entries byte-identical), header lines 8-10 refreshed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements enabler (b1) from register #50 and logs it as register #51.
The Railway ingestion service now runs convert → chunk → embed → persist chunks → write the terminal
documentsstatus, and returns only a small ack{document_id, chunk_count, status}.src/app/api/ingest/route.tsstill creates the row, forwards, and awaits — but no longer receives or inserts chunks, and no longer materializes the 9-13× return blob register #50 measured (every chunk's 1024-float embedding plus the full markdown, viaawait pyResponse.json()).This is the B6 enabler, not B6. Ingestion stays synchronous — Next still awaits Railway. It is also not B5b progress: the B5b §1 row stays ⬜.
Auth: Option B (forwarded user JWT), not a service-role key
Railway writes as the uploading user. Next lifts the already-verified session's
access_tokenand forwards it inX-Supabase-Token(Authorizationstill carries the service-to-serviceINGESTION_TOKEN, so the two credentials stay separate). The service builds a per-request Supabase client from the public anon key + that bearer, soauth.uid()resolves to the uploader and the existing RLS policies apply unchanged (001_initial_schema.sql:53,20260501_rag_pgvector.sql:31-41).Why not a service-role key — register #45 blast radius. #45 proved this exact service was publicly duplicable with a shared bearer token. An RLS-bypassing credential living in that process would have upgraded #45 from "a duplicated converter" to a full-database read/wipe breach — precisely the failure
backfill.py's own docstring forbids. NoSUPABASE_SERVICE_ROLE_KEYwas added to the service, its Dockerfile, its env, or the route.The client is built per request, never at module level: the client object holds the bearer header, so a shared one would leak one user's token into another user's writes.
Mechanism verified offline on the pinned
supabase==2.29.0before shipping —AsyncClientOptions(headers={'Authorization': ...})reaches PostgREST asapiKey: <anon>+Authorization: Bearer <user JWT>, and supabase-py'screate()skips its own session lookup when that header is supplied, so the forwarded token is the client's only identity.Rename, and why deploy order matters
/convert→/ingest. Both the request shape (now needsdocument_id,kb_id, the user token) and the response shape are incompatible with the old contract, so a version-skewed deploy gets a loud 404 instead of an old Next silently overwritingmarkdown_contentwithnullon a document the service already finished correctly. Either deploy order is loud and recoverable; uploads inside the window fail tostatus='error'.Hardening that rode along
document_idnever costs a Voyage batch.kb_idis taken from the database, and a caller-supplied mismatch is rejected — a holder ofINGESTION_TOKENcannot file chunks under one KB while pointing them at a document in another.UPDATEreturns 200 with zero rows, not an error, and would otherwise ackreadyfor a document whose status never moved.backfill.py's idempotent shape (delete().eq(document_id)then batch-insert at 50), so a retry of a half-written document converges./healthreportssupabase_configuredas a boolean only — never the URL or key.Error ownership moved with the work
Railway writes its own
status='error'+error_message. Next keeps unconditional error writes only pre-forward; every post-forward failure — including a thrown or timed-outfetch, which previously fell to the outer catch and stranded the row atprocessingforever — now writes through a.eq('status','processing')guard.That closes the orphan class without opening its mirror image: if Railway succeeded and only the ack was lost, the row is already
readyand the guarded write is a no-op rather than stomping a correct document intoerror.Honest residual: in that lost-ack case the browser is told the upload failed while the document is in fact
ready. Data is correct; the message is pessimistic;router.refresh()shows the real row. Fixing the message is B6's territory.Not in this PR
backfill.py's deploy posture — untouched, still a local-only script, still not copied into the image.PIVOT_PLAN.md— untouched.PROGRESS.md:86(B5b ⬜) — untouched.Checks
tsc --noEmitclean.main.pycompiles. No migration and no DB change, sodb-typescannot drift by construction. Docs: register row #51, one new §7 block (20 → 21, additions-only, all 20 landed entries byte-identical — verified by sha256 of §7-minus-the-new-block againstmain).Pre-merge verification (Abo Jad) — EIGHT items, all required
Deploy Railway and Vercel both from this branch before testing — the endpoint rename means a mixed deploy 404s (loudly, by design).
supabase==2.29.0torequirements.txtand importsacreate_client/AsyncClient/AsyncClientOptionsat module scope, so a bad resolve is a boot-time crash, not a runtime one. Confirm/healthreturns 200.GET /healthreturns"supabase_configured": true. If it isfalse,SUPABASE_URLand/orSUPABASE_ANON_KEYare missing and every upload will fail closed at 503. SetSUPABASE_ANON_KEYto the anon/publishable key — never the service-role key.status='ready'. Upload through the preview UI; then confirm thedocumentsrow hasstatus='ready',embedding_status='ready', achunk_countmatching the actualchunksrow count, and non-nullmarkdown_content.chunksrows present with a non-nullvector(1024). Confirm the chunk rows exist for thatdocument_idand thatembeddingis non-null with 1024 dimensions (vector_dims(embedding)) — this is the check that the embeddings actually crossed into Postgres as vectors rather than as null/text.match_chunksretrieves from it. This is the real proof that persistence-by-Railway produced chunks the existing retrieval path can use.status='error'with no partial chunk set and no stuckprocessing. Force a failure (e.g. temporarily breakVOYAGE_API_KEYon Railway, or upload a file that fails conversion) and confirm the row lands atstatus='error'with anerror_message, zero chunk rows for that document, and nothing left atprocessing. Restore the env afterwards.study_eventskind='material_uploaded'fires exactly once for one successful upload — not zero (the emit is still gated on the ack) and not twice.SUPABASE_SERVICE_ROLE_KEYand no other RLS-bypassing credential is set, and thatSUPABASE_ANON_KEYholds the anon key (decode it: the JWT'sroleclaim must beanon, notservice_role). If a service-role key is present, do not merge — remove and rotate it first. Repo-side proof is already in this PR (grepshows zero service-role env reads inmain.py, the Dockerfile, or the route; the only hits are prose forbidding it and the local-onlybackfill.py, which the Dockerfile does not copy into the image) — item 8 verifies the live environment, which the repo cannot prove.Do not merge until all eight pass.
🤖 Generated with Claude Code