Story 2229: the translator who was never paid — swapping the embedding model without moving the map

WHY. The catastrophe this Story prevents is the Context Engine going live with an empty moat — the exact failure Story-2219 just finished rescuing documents from, arriving again through a different door.

Every home='record' document is supposed to be chunked, embedded, and made retrievable. The embed worker was wired, the queue had a live consumer, the backfill could reach the orphan cohort. One thing was missing: the embedder could not embed. Its configured model, cohere.embed-v4, is a Bedrock Marketplace model, and the org payer account fails the Marketplace payment-instrument check — the subscription is stuck PENDING, so every invoke_model is refused. The worker drains the queue, fails each record, and context.chunks stays at zero.

An empty retrieval index is worse than an absent one, because the retrieval layer answers as if the matter were complete. A lawyer asks the matter a question and gets a confident, incomplete answer. Station 4 could not go live behind a billing form. So the Story swaps in amazon.titan-embed-text-v2:0 — first-party, no Marketplace, verified invokable in ap-south-1 — while keeping Cohere alive behind config, because embed-v4 is still the destination the moment the payer block clears (its Hindi quality is why it was chosen).

🛰 Satellite — the one sentence

The embedding port grows a second adapter: BedrockTitanEmbedder, which invokes Titan v2 one text at a time (Titan has no batch array), and a build_embedder() factory that picks the adapter off the configured model id — so switching between Titan and Cohere is a settings/env change plus a re-embed, never a code change. The vector column does not move: Titan v2's native width is 1024, which is exactly what migration 0157 created.

✈️ Airplane — the translator, the map, and the pin board

Picture the firm's evidence room. Every page that arrives must be pinned to a giant pin board, and the board's rule is that a page's position is given by exactly 1024 coordinates. Pages near each other on the board mean similar things; that adjacency is the whole reason a lawyer can later ask a question and be handed the right pages.

Nobody in the firm can compute those coordinates by eye. That job belongs to a translator who reads a page and returns its 1024 numbers. The firm hired a specialist — brilliant at Hindi, exactly what an Indian litigation corpus needs. He is Cohere. On his first day he does not show up. He is not sick and he has not quit: the firm's bank card was declined at his agency. The agency will not release him until the payment instrument clears. Every page sent to him comes back untranslated, so nothing gets pinned, and the board stays bare while the shelves fill up.

There is a second translator already on the payroll, sitting in the same building: Titan. He is in-house (no agency, no card), he is in the same city (same AWS region — important, because the evidence room has no door to the outside world), and critically he returns the same 1024 coordinates, so the pin board does not have to be rebuilt. He has exactly one quirk: the specialist would accept a stack of 96 pages at once, whereas Titan takes one page per request. So the clerk who used to hand over stacks now hands over sheets, in a loop.

This Story hires the in-house translator, teaches the clerk the sheet-at-a-time protocol, and — the part that matters six months from now — writes the hand-off as a name on a form rather than a person in the room. The clerk reads which translator to use off a config line. When the bank card clears, someone edits one line, re-translates the pages, and the specialist is back. Nobody reopens the clerk's rulebook.

🔬 Microscopic — the four moving parts

1. Two providers, two shapes — and why that forces an adapter, not an if

The two models do not merely differ in quality; they differ in wire shape, and in one deeper property. Cohere is asymmetric: a document must be embedded with input_type="search_document" and a query with "search_query", or cosine similarity compares vectors from two different spaces. Titan v2 is symmetric — one space, no input type. That is not a parameter difference; it is a different contract, which is why each gets its own adapter behind the shared Embedder protocol.

cohere.embed-v4amazon.titan-embed-text-v2:0
Request{"texts": [...96], "input_type": ..., "embedding_types": ["float"]}{"inputText": "...", "dimensions": 1024, "normalize": true}
Response{"embeddings": {"float": [[...]]}}{"embedding": [...], "inputTextTokenCount": N}
Texts per invokeup to 96 (batched)exactly 1 (looped)
Query/documentasymmetric (input_type)symmetric (ignored)
AvailabilityMarketplace — subscription PENDINGfirst-party, regional in ap-south-1

Titan's request carries dimensions explicitly even though 1024 is its native width. An unsent dimensions is a silent contract with the provider's default — precisely the drift the fail-closed guard exists to catch. Sending it makes the configured width authoritative. normalize is true because the HNSW index in migration 0157 is built over cosine distance, which assumes unit-norm vectors.

2. The switch is the model id, and nothing else

def build_embedder(settings, *, input_type="search_document") -> Embedder:
    if _TITAN_FAMILY in settings.context_embedding_model_id:
        return BedrockTitanEmbedder.from_settings(settings)
    return BedrockCohereEmbedder(..., input_type=input_type)

Both call sites — the worker's _build_deps() (the document leg) and build_query_embedder() (the query leg) — now go through this one function. That matters more than it looks: a query embedded by a different model than the corpus is a silently wrong nearest-neighbour set. One factory, one model id, both legs.

3. Fail closed: a wrong-width vector is never persisted

context.chunks.embedding is vector(1024) — fixed width, by construction. The guard that both adapters share (_verify) rejects any vector that is not exactly settings.context_embedding_dimension wide, before the insert. And a unit test now pins the equality that makes that guard meaningful:

Settings.model_fields["context_embedding_dimension"].default == 1024
migration_0157.EMBEDDING_DIMENSION                        == 1024

The comment in settings.py asserting these two must agree has existed since CE-3. It is now a test, so the two can no longer drift apart in silence.

4. The env values, and why they live in the workflow

A Lambda deploy rewrites Environment.Variables wholesale. Values set by hand with update-function-configuration survive until the next deploy and then vanish (the EPIC-1489 ephemeral-env trap). So the model id and dimension are injected from deploy-context-embed-worker.yml, and the IAM grant is widened there too — to both families by prefix:

"Resource": ["arn:aws:bedrock:*::foundation-model/amazon.titan-embed-text-*",
             "arn:aws:bedrock:*::foundation-model/cohere.embed-*",
             "arn:aws:bedrock:${AWS_REGION}:${ACCOUNT_ID}:inference-profile/${CONTEXT_EMBEDDING_MODEL_ID}"]

Granting both is what keeps the documented swap-back a config-only change: no IAM edit, no review cycle. This also supersedes the hand-applied pramaan-context-embed-titan-staging inline policy from the issue comment — that grant now lives in the workflow, so it survives a role re-creation instead of being one console click away from disappearing.

The path a page takes

  pramaan-context-backfill  (invoke: firm_id + matter_ids)
          |
          |  registered + indexed, but never carded/chunked  -> "enrich_kicked"
          v
  SQS  pramaan-context-enrich-staging     (ESM State=Enabled)
          |
          v
  pramaan-context-embed  (Lambda, in-VPC, no NAT)
          |
          |-- chunker: document_pages.blocks -> deterministic chunks (chunk_hash)
          |-- prefetch existing hashes  (never pay Bedrock twice)
          |
          |-- build_embedder(settings) --> model id says "titan-embed-text"
          |         |
          |         v
          |   bedrock-runtime VPC endpoint (ap-south-1, region-local)
          |         |
          |         +--> amazon.titan-embed-text-v2:0   ONE inputText per invoke
          |                     |
          |                     v   {"embedding": [ ... 1024 floats ... ]}
          |            _verify(): len(vector) == 1024, else EmbeddingDimensionError
          v
  INSERT context.chunks (embedding vector(1024), embedding_model = 'amazon.titan-embed-text-v2:0')

Note the last column: embedding_model is stamped on every row. That is what makes the swap-back decidable — re-embed exactly the rows where embedding_model != current, and nothing else.

Correction to the ticket: no migration was written, because none was needed. The Story asked for a vector(1536) → vector(1024) ALTER with a 0-row guard. Staging says the column was never 1536. Three independent facts, read live before writing any code: Titan v2's native width is 1024, so the dimension change the ticket budgeted for is a no-op. Writing an ALTER from a width that never existed — with a downgrade() that would move a live column to 1536 — would have manufactured a false history and a genuinely dangerous rollback path. The invariant the guard was protecting (settings width == column width) is instead enforced where it catches drift earlier and cheaper: a unit test, run on every CI job, not a one-shot assertion that runs once at deploy.

Live proof — green is a build fact, live is a runtime fact

Titan v2 was invoked against real Bedrock in ap-south-1 before the adapter was written, so the request/response shape in the code is transcribed from the wire, not from memory:

$ aws bedrock-runtime invoke-model --region ap-south-1 \
    --model-id amazon.titan-embed-text-v2:0 \
    --body '{"inputText":"hello pramaan","dimensions":1024,"normalize":true}' /tmp/titan.json
keys  ['embedding', 'embeddingsByType', 'inputTextTokenCount']
dim   1024
tokens 5

The DONE bar is not a green pipeline. It is context.chunks moving off zero on staging after a real deploy and a real pramaan-context-backfill invoke against matter ea90a732-b0f3-4377-ba24-9d614f30c543 — the same orphan cohort Story-2219 exposed (2 registered/indexed record documents, 14 pages). That evidence is recorded on pramaan-spec#2229 after the post-merge deploy.

Recall in one sentence. The embedding model is a name on a config line, not a dependency in the code — so when the specialist translator's invoice cleared the wrong bank, the in-house one who returns the same 1024 coordinates took over without the pin board, the clerk, or the schema changing at all.