feat(e0): refuse an unroutable MIME at the E0 gate instead of dead-lettering it (D104) - #342
Open
fazpu wants to merge 1 commit into
Open
feat(e0): refuse an unroutable MIME at the E0 gate instead of dead-lettering it (D104)#342fazpu wants to merge 1 commit into
fazpu wants to merge 1 commit into
Conversation
…ttering it (D104) The conversion router was consulted for the first time inside the convert worker. An upload whose MIME had no route was admitted, hashed, written to immutable raw storage, returned as a normal accepted-not-ready receipt, and only then dead-lettered through UnroutableMimeError -> mark_version_failed -> NonRetryableHandlerError. The caller discovered a terminal state by polling, having been told the upload was accepted. The waste was knowable at admission: the route table was already in memory. And the caller cannot undo it — identical bytes are a no-op (D55) and no API lets them request reprocessing, so recovery is operator work only (`remember ops replay` reopens the dead-lettered work row, after which a now-routable version converts normally). Each wrong guess costs a durable raw object and an operator ticket to discover what a set lookup already knew. The gate is in E0, not on a surface, and that placement is the decision. Three ingresses reach E0 without sharing a handler: HTTP POST /ingest, the local MCP ingest tool, and the connector sync worker, the latter two calling the composed port directly. A check on the HTTP handler would have left two of three paths still admitting bytes the convert stage can only dead-letter, while looking fixed. UploadIngestor is the one object all three write through — the library boundary already requires that ingestion always writes through E0 — so the check sits in _guard_ingest beside the D74 guard, and surfaces only render it. Two details make that placement hold rather than merely sound right. The route table is a REQUIRED argument: a default of "no check" would make the invariant as strong as every composer remembering to pass it, and every deployment has a table (the settings default is the stock text one), so omission expresses only a mistake. And routability is decided BEFORE the D74 admission query — both orders are safe since neither writes bytes, but deciding it first avoids an admission query for a request that cannot be accepted and stops a forget-state error from masking a plain "we do not convert that". This is not a media defect. The mechanism keys on absence from the table, so it fired identically for audio, video, images, office documents and archives. The table is the only authority and the gate cannot be looser than the worker: build_conversion_routes refuses composition on an unknown adapter, so a process's router keys are exactly its configuration's keys, and the gate does the same exact lookup on the same string. That guarantee is per-configuration, not global — gate and worker are separately composed, so a route-table change leaves a window where one has restarted and the other has not, which is why UnroutableMimeError stays in the worker and stays non-retryable. Surfaces: HTTP renders 415 with the accepted set. map_backend_error maps the typed error for local MCP and the 415 for remote MCP, so an agent gets unsupported_media_type with an actionable next step on both, instead of internal_error locally and a flattened engine_client_error remotely. Not in scope: a routing verdict, not a content one. An MP3 labelled text/plain still passes the gate and fails in the converter, correctly. Tests assert store.writes == 0 on refusal — the exception alone would not prove it, since the defect was that bytes became durable. Both E0 entry points are covered; one test proves D74 is not consulted for an unroutable input; one locks the gate/router key-set equivalence. Two structural audits guard the shape itself: that record_upload has exactly one caller, so a future ingress cannot quietly become a second door, and that routable_mimes keeps no default. Docs: the API reference documents the 415 and notes MCP and connector sync refuse the same types; configuration and troubleshooting no longer claim an unrouted MIME dead-letters on convert, and troubleshooting says how to recover versions dead-lettered before this existed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KfBUpQuuehJKf2kbNa2D2D
fazpu
force-pushed
the
feat/unroutable-mime-preflight
branch
from
September 1, 2026 00:17
d11f145 to
8407fdf
Compare
This was referenced Sep 3, 2026
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.
The defect
The conversion router was consulted for the first time inside the convert worker. An upload whose MIME had no route was admitted, hashed, written to immutable raw storage, returned as a normal accepted-not-ready receipt, and only then dead-lettered through
UnroutableMimeError→mark_version_failed→NonRetryableHandlerError. The caller discovered a terminal state by polling, having been told the upload was accepted.The waste was knowable at admission — the route table was already in memory — and the caller cannot undo it: identical bytes are the D55 no-op and no API lets them request reprocessing. Recovery is operator work only (
remember ops replayreopens the dead-lettered work row, after which a now-routable version converts normally). Each wrong guess costs a durable raw object and an operator ticket.This is not a media defect. The mechanism keys on absence from the table, so it fired identically for audio, video, images, office documents and archives. The stock table is two text entries.
The placement is the decision
Three ingresses reach E0 without sharing a handler: HTTP
POST /ingest, the local MCPingesttool, and the connector sync worker — the latter two calling the composed port directly. A check on the HTTP handler would have left two of three paths still admitting bytes convert can only dead-letter, while looking fixed.UploadIngestoris the one object all three write through, which is what the library boundary already requires: ingestion always writes through E0. The check sits in_guard_ingestbeside the D74 guard; surfaces only render it.Two details make that hold rather than merely sound right:
routable_mimesis required. A default of "no check" would make the invariant as strong as every composer remembering to pass it. Every deployment has a route table (the settings default is the stock text one), so omission expresses only a mistake.Scope of the guarantee
No upload is admitted under a MIME this deployment cannot convert — not no unconvertible version is ever created. The gate reads the declared MIME; convert reads
content_objects.mime, which is first-write-wins per content hash. They diverge only when the same bytes return under a different MIME whose first-seen reading has since become unrouted. Closing that costs a content-object lookup on every ingest; the trade is recorded as an open question in the analysis rather than paid or dismissed silently, and the worker'sUnroutableMimeErrorstill covers the case.Surfaces
HTTP renders 415 with the accepted set.
map_backend_errormaps the typed error for local MCP and the 415 for remote MCP, so an agent getsunsupported_media_typewith an actionable next step on both instead ofinternal_errorlocally and a flattenedengine_client_errorremotely. The SDK preserves the structured detail, trusting a code only at its bound status and path.Breaking change
rememberstack.workers.UploadIngestornow takes a requiredroutable_mimes. Passfrozenset(conversion_routes)where you build it. Deployments configured throughREMEMBERSTACK_SELFHOST_CONVERSION_ROUTESneed no change. Recorded in/docs/project-status.Corpus
decisions.mdD104plan/designs/e0_files_design.md§3plan/analysis/unroutable_mime_preflight.mdVerification
make checkclean — ruff, pyright, 1308 passed / 679 skipped.Tests assert
store.writes == 0on refusal (the exception alone would not prove it — the defect was that bytes became durable), cover both E0 entry points, prove D74 is not consulted for an unroutable input, and lock the gate/router key-set equivalence. Two structural audits guard the shape:record_uploadhas exactly the two gated callers (counted, not set-collapsed), and no runtime module outsideDocumentCataloginsertsdocument_versions.Reviewed across five adversarial
codexrounds; findings from every round are applied.🤖 Generated with Claude Code
https://claude.ai/code/session_01KfBUpQuuehJKf2kbNa2D2D
Contributor agreement
Signing on behalf of a legal entity (leave blank if accepting individually):