previews / firm-migrations / activation at scale

Platform · Firm migrations

Activation at migration scale

Imported files land inert. Waking them up is roughly 96 container-days of processing work and a few tens of thousands of dollars of model calls, and the compute half of that bill barely moves however fast you run it. What moves is what gives way: one OpenSearch node, one burstable database, one rate limit.

Status: Draft for review

Read this page before the imported file activation tech plan. That page locks the interfaces, the contract and the build order. This one holds the problem it solves, every measurement behind it, and the constraints those measurements impose. Both sit downstream of the files tech plan, which gets a firm's matters and file bytes into v3, and beside the imported firm-member activation plan, which gives its people a login.

TL;DR.

Why imported files stay inert

The import pipeline gets a firm's matters, people and file bytes into v3. The files then sit there. There is no preview unless the file is its own preview, so a docx shows nothing and nothing ever flips canPreview. The file is never ingested, so it is invisible to AI search and to Lawrence, and the UI shows it stuck “analysing” (IngestionStatusCell.tsx:23 renders NOT_REQUESTED, PENDING and null all as “Analysing”). And it contributes nothing to the matter's case context, so a migrated matter looks empty to every AI surface.

All of that is by construction. Placement deliberately stops at “bytes are in the product, scanned”, and the import writes no domain events at all. The enrichment machinery that serves everyday uploads is entirely event-driven, so it is never told an imported matter appeared.

The three chains an everyday upload enters

Previews. GuardDuty scans the uploaded object, SNS carries the result to webhook-api as webhook/s3/malwareScan.*, and a matter-service Inngest function (id matter-service/malwareScan.completed, retries: 0) picks it up. Files whose FilePreview is PENDING go to the PDF-converter Lambda; image, video, audio and pdf are born SUCCESS because the file is its own preview. The placement stack stamps imported objects lawhive-source: import and that handler skips them, so an imported file can only get a preview if something asks explicitly. No backfill or regenerate path exists anywhere.

Ingestion. matter-service's insertMatterFile emits matter-service/file.uploaded; lawrence-api requests POST /ingest on ingestion-management-service (KMS-signed, identified by bucket plus key plus matterId, with external_asset_id set to the matfil_ id) and writes aiIngestionV3Status: PENDING. Ingestion pipelines into OpenSearch and reports back over SNS to a lawrence-api webhook, which writes SUCCESS / FAILED / UNSUPPORTED. A duplicate request 409s unless reingest is set. Imported files already satisfy every request field, and File.aiIngestionV3Status defaults to NOT_REQUESTED.

Case context. The same file.uploaded event, plus notes, messages, emails and transcripts, reaches lawrence-api's caseContextUpdateRequired, which emits app/case/batch-case-context-update.requested {caseId}. lawrence-engine extracts statements per asset (documents are read from ingestion output, never the raw file), passes a materiality gate, and generates the case context object, throttled to one per 30 minutes per case. A document whose text is not ingested yet fails retrieval and is only retried when another event lands on the matter. The engine also carries case-context-service/backfill/coordinator.requested, a chronological, throttle-bypassing, idempotent replay per case, built for exactly the migrated-matter shape.

Two upload-path bugs activation would inherit

lawrence-api writes PENDING even when the ingest POST failed, because 422 and 409 are swallowed, so a rejected file reads “in progress” forever. And canPreview is written once at insert and never updated, with opposite semantics on the two write paths. Both are upload-path bugs today; at migration scale they become the difference between a report you can trust and one you cannot.

How big the books are

Every figure below covers the live slice of each firm. Closed and cancelled matters are out of scope for activation; that decision, and how it lands mechanically, is in the activation plan.

Lawhive Legal (v2), live slice: 17,288 open cases / ~1.08M files / ~1.16 TB. That is ACTIVE at 12,260 cases and 834k files, SUBMITTED_FOR_CLOSING at 3,459 cases and 242k files, and OPENING, PENDING and REASSIGNING at roughly 1.6k cases and 8k files. Live cases are file-heavier than the book average, ~63 files per case against 43. The full book, for reference, is 37,958 cases / 1.63M files / 1.77 TB, so activating live-only cuts the work by about 34%.

The extension mix over live cases, top 20 extensions from the v2 production database, is 47% .eml/.msg (454k), 36% PDF (344k), 8.4% images, 7.7% office documents, ~2k media. Applying the full-book extension mix at a scale factor of ~0.66 for live gives preview tiers of ~45% previewable free, and a converter tier of ~90k files if the .eml/.msg filed emails become communications, or ~590k if they stay files. That 6× swing is decided by the files tech plan, not here. Book-wide case-context asset drivers are 1.11M messages, 631k emails and 84k notes; the live share of those is unmeasured.

Woodstock (LEAP), live slice: 11,089–15,029 open matters of 27,005, and a document estate nobody has counted. At v2's 47 files per matter the live slice is ≈ 520k–710k documents; conveyancing-heavy at 60 to 100 per matter puts it at ≈ 670k–1.5M. This is the programme's largest single unknown, and every LEAP figure on this page inherits it. Combined across the two firms, the books hold about 65k matters.

What enrichment costs

Inside a processing task

Processing averages 22.2 seconds per task (p50 13, p90 34). Summed over seven days of production busy-seconds, that time goes here:

ComponentShare of thread-timeAvg sNature
Summaries (2 × gpt-5-mini)49% (416k s)14.8network wait
PDF extraction41% (351k s)23.2 / batch taskTextract is network wait today; Unstructured is CPU
Image (vision call)5.5%3.5network wait
Office docs / text / audio~4.5%~13mostly CPU
Email (.eml / .msg)0.3%0.4CPU, trivial

Applied to the live extension mix, that gives email ≈ 15s (0.4 extract plus 14.8 summary), image ≈ 18s, small PDF ≈ 38s and office document ≈ 28s, so a weighted ≈ 24s per document, of which ~15s is LLM wait (~102 container-days ≈ $650, which is why the $620 estimate below holds).

Two consequences. On today's Textract path a processing thread is I/O-blocked most of its life, so WORKER__COUNT could rise well past 3 per container. Under the Unstructured ruling that lever mostly disappears for PDFs: the hi_res layout model's ONNX runtime defaults to using every core for a single inference and Tesseract grinds a full core per page, so one PDF task can saturate the container and extra concurrent tasks just contend — same throughput at best, slower plus memory pressure at worst. The counters are thread-pinning (cap OMP/ONNX threads per task so each task is ~1 core, then WORKER__COUNT ≈ vCPU — right for a backfill lane, wrong for live latency) and, cleanly, horizontal containers, which is what the money-to-speed arithmetic already assumes. The email-heavy share (47% of the live book, ~15s of LLM wait each) still rewards concurrency, so the optimal WORKER__COUNT is mix-dependent — the burst test measures 1 vs 3, with and without pinning, per doc type.

Extraction: Textract against Unstructured

Per-document external calls in the processing stage:

Doc typeExtractionSummariesIndexing
PDF on the v3 pathAWS Textract (LAYOUT+TABLES) per 5-page batch, billed per page2 LLM calls/doc (split_and_aggregate; the 900k-token window means almost all docs go direct, so 2 calls total)1 embedding call per 1,024-token chunk (text-embedding-3-small, negligible cost)
PDF on the v2 pathlocal Unstructured hi_res (free, heavy CPU)samesame
docx / xlsx / pptx, text, .eml / .msglocal (free)samesame
Image1 vision call (gpt-5.4-mini default)samesame
Audiowhisper-1 per ≤60-minute segment

Which extractor a file gets is decided by a substring check on its S3 path: mat_ means Textract, anything else means local Unstructured. v3 matter ids are mat_ (packages/id-schemas/src/matter-service/matter-id.ts), so imported file keys (matters/mat_.../matterFiles/...) land on the Textract path by default.

At about $19 per 1,000 pages (LAYOUT+TABLES), Lawhive Legal's live-slice PDF estate (~350k–600k PDFs × ~5–8 pages) is $33k–90k of Textract: an order of magnitude above the LLM summaries (~2 gpt-5-mini calls × ~1.08M docs ≈ $2k–10k) and three orders above embeddings. The full book would have been $50k–130k. LEAP is worse, because conveyancing is PDF-heavy: Textract on ~2.5M LEAP pages would have been ~$48k. Defaulting extraction to Unstructured removes that line and replaces it with worker compute, which is why the sizing question below matters more than the licence question.

The same mat_ prefix carries a second consequence. The case-context pipeline hard-maps cas_* to UK prompts and mat_* to US prompts, with no other input (core/utils/locale.py, and statement_pipeline/main.py:187). That convention stops holding the moment UK matters live under mat_ ids, so every migrated matter would generate context against US prompts until jurisdiction becomes an explicit field.

Case context per matter

On the backfill path, a matter with N assets costs ≈ 2N + 1 + B LLM calls: one extraction call and one factuality call per asset, one initial case-context generation, and one reconciliation per chronological bucket, where B ≈ max(5, ceil(N/40)). The backfill worker bypasses the materiality gate entirely (budget MCC at zero) and there is no separate summary call.

N assetsLLM calls
20~45
50~106
100~206
500~1,014

A Lawhive Legal live matter averages ~63 files + ~29 messages + ~17 emails ≈ 110–120 assets (assets are files plus notes plus the last 50 messages plus emails plus calls plus ICI), so the mean is ≈ 235 calls per matter and the live book is ≈ 3.5–4M LLM calls. Medians are far lower, since the median matter has 15 files, but cost scales with the mean and the p99 tail of 400-file matters is real. Woodstock's live slice at ~100 calls per matter (documents only; the LEAP feed has no messages, emails or calls) is ≈ 1.1–1.5M calls.

The models come from the Langfuse production configs: extraction and factuality, the 2N bulk, run gpt-5-mini at low; initial generation and chain-of-key reconciliation, the 1 + B calls, run full gpt-5 at low. Fallbacks are sonnet-4-5 and gemini-2.5-pro. Per mean live matter (~115 assets) that is ~230 mini calls (≈ $1.60 at ~10–15k in / 1–2k out each) plus ~6 gpt-5 calls (≈ $0.50 at ~30k in / 5k out each), so ~$2 per matter, with median matters far cheaper.

Four cost dominators sit in the code rather than in the call count:

The two books priced

At ~$2 per matter, Lawhive Legal's live book is ≈ $25–45k of case-context generation and Woodstock's live book ≈ $12–20k. With Textract defaulted out, case context is the migration's biggest line item at ~$40–65k across both books.

Two levers exist if the top of that range holds: trim the asset inventory (454k of Lawhive Legal's live files are .eml/.msg whose content ingestion has already summarised), and cap the context echo in extraction prompts.

LEAP pays full freight, because there is nothing precomputed to lean on. v2's ingestion artifacts are reusable; case context is priced as generated fresh for both books; and LEAP has no ingestion cache at all, so its files run the whole pipeline.

LEAP live book (11.1k–15k matters)base (47 docs/matter)property-heavy (60–100)
CCO generation (~100 calls/matter ≈ $0.85)$9.5–13k$15–20k
Ingestion summaries (2 × gpt-5-mini per doc)$2.5–5kup to ~$8k
Embeddings~$40~$70
Processing compute (~80–110 container-days)$500–700~$1k
Total≈ $13–19k≈ $24–29k

How fast it can go

The compute bill is constant

Total processing work for the Lawhive Legal live slice is roughly 1.08M docs × ~23s ≈ ~96 container-days at today's conservative 3 threads per container. At 4 vCPU / 16 GB (≈ $5.60/day) that is about $540 of Fargate whatever the calendar time, because scaling out does not add container-days, it only divides them. The LLM bill (~$2–10k for summaries and case context) is equally speed-independent.

The business decision is not “pay more to go faster”. It is: pick the calendar time. The cost barely moves.

Lane size (containers @ 3 threads)Lawhive Legal live drainFargate cost
8~12 days~$540
24~4 days~$540
48~2 days~$540
96~1 day~$540

Raising WORKER__COUNT divides the container count further only where I/O wait dominates (emails, images — not Unstructured PDFs, which contend for the same cores; see the extraction section). Woodstock's live slice (~0.5–0.7M docs, pending the census) is roughly half the same table. Per case, at any lane size in it, one median live case (~60 files) activates in minutes; the calendar time is purely how long the whole book takes.

Against eu-west-2 rates a processing container (4 vCPU / 16 GB) costs about $6.40 a day, so the ~96 container-days are ~$620; the $540 figure uses us-east rates. For scale, the standing production processing fleet is 3 containers ≈ $580/month, and the whole ingestion fleet (all five services at minimum counts) is ≈ $1.0–1.3k/month. The entire Lawhive Legal live backfill therefore costs about one month of the existing processing fleet. One caveat: if Unstructured extraction is CPU-bound and roughly 2× slower per PDF, add ~30–60 container-days, still under $1.5k.

Capacity today

From production Axiom spans over seven days:

Stageavg s/task (p50 / p90)slots at rest → autoscaledceiling/dayactual/day
preparation1.19 (0.6 / 2.2)20 × 1–5 inst~1.4M+~2.0k
processing22.2 (13 / 34)3 threads × 3–15 inst = 9 → 45~35k → ~175k~5.5k
indexing (incl. embed)4.13 (1.1 / 3.5)10 × 1–10 inst~209k+~5.4k

Processing is the narrow stage by an order of magnitude. At rest, production processes 9 documents concurrently, a ceiling of about 35k tasks a day against roughly 5.5k a day of live demand. Production WORKER__COUNT is 20 for preparation, 3 for processing and 10 for indexing, and the summarisation model is gpt-5-mini at reasoning effort low.

Those are floors rather than caps, because prod already autoscales the workers on queue depth. A queue-depth-autoscaling module in the ingestion terraform stack drives the ECS services directly, outside Flightcontrol, which is why flightcontrol.prd.json reads autoscalingEnabled: false. Preparation runs 1 to 5 tasks at a target of 10 messages per task, processing runs 3 to 15 at a target of 3 messages per task (with a 30s scale-up cooldown and 3 extra tasks whenever the backlog passes 50), and indexing runs 1 to 10 at a target of 10. So under sustained depth processing reaches 45 slots, about 175k tasks a day, without anyone touching a dial.

That is still a configured ceiling rather than a measured throughput, and the shared dependencies below bind long before it. The capacity audit's ~9k docs/day figure is observed live demand, not pipeline capacity. Maximum capacity has never been deliberately measured. That audit's one-time LLM cost guess of $2–8k is superseded by the model on this page plus the dev tracing numbers still to come.

Three ways capacity gets measured

1 · Paper ceiling

Average seconds per document per stage from production Axiom spans, multiplied by fleet threads from SSM, at rest and at the autoscaling ceiling. It is a bound rather than a measurement, and it flatters CPU-heavy Unstructured.

2 · Dev burst test

Enqueue thousands of documents, then read the SQS drain slope at two worker scales. Linear means worker-bound. Sub-linear means a shared ceiling binds, and that ceiling is the real number.

3 · The first production burst

Run on an isolated lane, safe by construction, and it doubles as the first honest measurement.

Where the platform breaks as-is

There is no priority mechanism anywhere in ingestion: standard SQS queues, no message attributes, and one static input queue per worker. A backfill sent down the default lane therefore competes directly with live production traffic for the same autoscaling fleet.

The queues for a second lane are already there. The ingestion terraform stack declares all six, a default and a backfill queue per stage, each with its DLQ, in both dev and prd, and the prd backfill queues exist today. They carry a 1000s visibility timeout against the default lane's 300s, which is the shape long batch tasks need. What is missing is everything that would use them: nothing produces to or consumes from them, no worker is pointed at them, and /ingest has no way to say which lane a request belongs to.

At 12 documents per second

96 containers × 3 threads drains about 1M docs/day, which is 12 docs/s. Every shared dependency at that pace, against production specs:

DependencyActual specLoad at 12 docs/sVerdict
The prd ingestion OpenSearch domainone r7g.large.search node (2 vCPU / 16 GB), no replicas, 500 GB gp3, and the matter-file index already holds 12.84M chunks / 393 GB (79% of disk)migration adds ~5–20M chunks (~150–300 GB) plus kNN graph build at speedBreaks first. On disk alone the migration does not fit the current node, before even discussing kNN memory or bulk throughput. With zero replicas, tipping it over takes live AI search down. Resizing is a prerequisite, not an optimisation, though the storage spike may shrink how much has to be bought. The target — 3 × r7g.xlarge.search data nodes, dedicated masters, 1 TB gp3 per node — runs ≈ $1.7k/month against today's ≈ $220/month, a ≈ $1.5k/month delta; if E5 halves the index, 2 data nodes bring the delta to ≈ $1k/month.
The prd ingestion databasedb.t4g.medium (burstable, 4 GB), 20 GB allocated with storage autoscaling to 80 GB~70–100 writes/s sustained for days (asset rows plus ~4 status updates per doc)Burst credits drain over multi-day runs and it throttles to baseline. A small m-class covers the migration window at ~$100/mo delta.
Management service3 × 2 vCPU web, uvicorn concurrency 150~70–100 req/s of worker callbacks, plus /ingest stream-hashes every object (1.16 TB through the web tier at enqueue time)OK at 3 instances for callbacks. The sha256 streaming makes enqueue its own throughput stage: ~5 instances during enqueue, or a slower fill.
OpenAI, summariesgpt-5-mini at low; the org is Tier 5: 180M TPM / 30k RPM for gpt-5-mini~25 calls/s ≈ 1.5k RPM, ~0.2M TPMTrivial: ~5% of RPM, under 1% of TPM.
OpenAI, embeddingstext-embedding-3-small; Tier 5: 10M TPM / 10k RPM~120 calls/s ≈ 7.2k RPM, ~7.4M TPM~72–74% of both limits at 1-day pace, shared with production AI features. The embeddings API takes input arrays, so batching ~16 chunks per call cuts RPM 16× (a small change in embed_chunks). Fine at 2-day pace even without it. Worker backoff on 429 regardless.
KMS request signingRSA cryptographic ops, 1,000/s~70–150 signed req/sFine, at most 15% of quota.
SQS / S3 / Lambdatrivial at this scaleFine.
Inngestthe per-file activation event is ≈ 1.08M eventschain-paced, not drain-pacedFine, given the consumer carries explicit concurrency so it does not become the enqueue bottleneck.
SNS → lawrence-api webhook → matter-serviceone status write per doc~12 writes/sFine.
DLQ configmaxReceiveCount 3 on every queue in prd, default and backfill alikeany transient 429 or timeout at burstThree receives is workable, but a worker that retries a 429 immediately burns all three in seconds. The fix is worker backoff on 429, so each receive is a real attempt. Raising maxReceiveCount to ≥ 5 is a one-line tweak on top, not a prerequisite.

The dial's top is not money or workers. It is one undersized OpenSearch node, then the burstable database, then embedding rate limits.

The shared platform database

is the constraint the case-context backfill runs into

prd-db is a burstable db.t4g.small with a hard 100 GB storage ceiling, 38 GB of it already used, and it hosts not just every lawrence-engine module but the whole platform: every v3 service database lives on this instance, so degrading it degrades all of production, not just AI features.

The ceiling is consumed twice by a migration. The import writes the firm's own rows first (matters, files, messages, notes: roughly 5–10 GB for the Lawhive Legal live book, bounded by v2's measured 15 GB whole-book Postgres). Activation then adds the statement and case-context rows: ~40–60 GB of statements (~17k matters × ~2.5k statements) plus 5–20 GB of context JSON versions, with Woodstock adding about 60% more, so ~50–90 GB. Together they blow through 100 GB with room to spare. It does not fit.

The statement figure assumes 10–30 statements per asset, the one unmeasured link in the chain. processedasset.statement_count already carries the real distribution for 2.49M v2-era assets.

Because the import's own rows land on the same instance, this resize gates the import itself, not only activation. The move itself is cheap relative to what it protects: db.m7g.large with a 500 GB ceiling runs ≈ $210/month against today's ≈ $40/month.

How fast the engine can build context

lawrence-engine runs 2 × 1 vCPU / 3 GB, fixed, and its throughput is governed by code-level Inngest constants: 15 concurrent cases, 5 extractions per round. A mean live matter (~115 assets, 4–6 rounds) takes ~10–15 minutes, so today's limits drain about 1,700 matters/day: Lawhive Legal live ≈ 10 days, both books ≈ 2.5–3 weeks. Raising the limits to ~45 cases and 10 per round triples that, putting both books at ≈ 6 days. OpenAI headroom is ample even then (~7M of 180M TPM on mini, ~2M of 40M on gpt-5), and the engine is LLM-wait-bound, so 4 × 1 vCPU containers suffice.

Efficiency findings

Neither ingestion nor case-context is egregiously wasteful, but both leave money and storage on the table, and a migration multiplies exactly those inefficiencies. Tiered by leverage.

E1 — one summary call instead of two (ingestion). Every document gets two separate LLM calls, a full summary and a concise one, even a three-line email (0.4s of extraction against ~15s of summarisation). Summaries are 49% of all processing thread-time. One structured-output call returning both fields halves that, and skipping summarisation for tiny documents cuts further. It pays back on live traffic forever, and on ~1.6M migrated docs it is the largest processing saving available.

E2 — cap the context echo in extraction (case-context). The extraction prompt carries the entire current context JSON on every asset call, uncapped, so on a 115-asset matter the late-round calls each haul 10–40k tokens. Capping to a compact entity digest (~2k tokens) plausibly halves the v2 case-context bill: $25–45k → ~$12–22k.

E3 — backfill round idempotency (case-context, correctness). A failed round re-extracts its whole bucket and duplicates statement rows, because extraction mints fresh uuid4 ids and the inline path lacks the already-processed guard the event-driven path has.

E4 — embedding batching. Arrays of ~16 chunks per call, which is the same change the embeddings rate-limit line above needs.

E5 — OpenSearch storage. The index runs ~31 KB per chunk for ~10 KB of payload (4 KB of text plus a 6 KB float32 vector), which suggests _source text duplication (the same text already lives in the S3 content tree) and unquantized vectors. int8 or byte quantization gives 4× smaller vectors, and together with a mapping and _source review it could halve the 393 GB index and shrink the resize target permanently, possibly from 3 data nodes to 2.

E6 and E7 — open questions rather than commitments. Content-hash chunk and embedding reuse (asset_hash is stored but never consulted, and law-firm corpora are duplicate-heavy, so this extends the v2-reuse machinery naturally). And merging extraction and factuality into one call, about 40% of the case-context mini spend, but a prompt-architecture change the case-context quality owner has to weigh.

Deliberately skipped: context version compaction (audit value, single-digit GB), moving the /ingest hashing out of the web tier (real but small), and cross-matter dedupe semantics.

Ingestion also has no generation traces today. Langfuse there does prompt management only, and model calls appear in Axiom as anonymous HTTP spans, so per-document cost cannot be read until tracing lands. The case-context side is already fully traced: every LLM call goes through one client that opens a Langfuse generation with token usage, a backfilled matter is one trace named incremental-backfill-case carrying all its generations, and StatementBasedCCO.workflow_id joins database rows to traces. The known undercount there is litellm-internal retries and fallbacks, which burn tokens invisibly.

What can be reused

Ingestion reuse is available today, and it is probably the biggest lever in the programme. v2 files were processed by the same ingestion-management-service into the same OpenSearch index the v3 search-service reads. They are invisible to v3 matters only because matterId, which is both a field and the shard routing key, still says the v2 case id.

The cache exists at full scale

The prd ingestion asset table holds 2,493,789 cas_ (v2) assets, of which 2,393,025 (96%) are INDEXED (2% UNSUPPORTED, 0.2% FAILED, the rest in flight), against 43,154 mat_ assets. The row count exceeds the 1.6M file count because PDF batch children are assets too.

Per v2 file there is extracted plain text and summaries in the content S3 tree (the path embeds the matter id), plus a per-asset parquet artefact holding every chunk's text and embedding. Reuse is not an UPDATE; it is a copy of the content tree under the v3 matter prefix plus a re-index of chunks under the new routing. But no extraction, no summarisation, and, if the re-index reads the parquet, no re-embedding. That skips the Textract bill entirely ($50k–130k) and, more importantly, the pipeline: a re-key sweep is S3 copies and OpenSearch writes, not months of reprocessing.

Two gaps stand between here and there. ingestion-management has no re-key or clone endpoint (PUT /assets can only touch name and status), and no code path indexes from parquet without re-embedding. Both are new but well bounded, and the join keys exist on both sides (FilePlacement.expectedSha256asset.asset_hash, unindexed today, or the v2 file ref ↔ external_asset_id).

Case-context reuse is a scoped spike, and the default stays regenerate. The obstacle was never the case_id rewrite, since v2 and v3 rows share one database. It is that every statement and processed-asset row carries the v2 asset id, which a mat_ matter's asset inventory never names. The translation map does exist: the import registry (ImportedRecord) records the v2 ref → v3 entity id mapping for files, notes, messages and calls. The shape of the reuse:

  1. A one-shot re-key script per firm, transactional per case: case_id via the matter registry, asset_id via the per-entity registries (statements, processed-assets, MCC evaluation arrays). Safe-degradation rule: an id that does not translate means deleting that asset's old statements and letting it re-extract fresh. Degraded, never corrupt.
  2. Preconditions: the locale fix has to land first, because v2 contexts are UK-generated and reconciliation under mat_ ids has to keep UK prompts. Plus a check that the context JSON embeds no asset ids (supporting facts are statement uuids, which are stable).
  3. After the re-key, the normal backfill coordinator sees processed assets matching, extracts only post-migration deltas, and reconciles the carried context. Per-matter cost drops from ~$2 to ~$0.1–0.3.
  4. The prize is ~$20–40k of the v2 case-context bill, plus about 10 days of engine wall-clock.
  5. The deciding unknown is coverage: how many live cas_ cases actually hold statement and context rows, given the v2 pipeline only ran where events fired. One query on lawrence_case_context settles it, and that database is VPC-only.

LEAP gets none of this, because nothing is precomputed there: it was never ingested. So the likely landing point is reuse for v2 plus fresh ingest for LEAP, which is a reconciliation the file-migration plan needs, since it was settled before reuse was on the table and says imported files get a deliberate full re-ingest.

Reuse work also surfaces a live bug worth fixing on its own: deletes were dual-written to both ingestion systems, but reassign (a file moved between matters) only ever calls the legacy service, so a v3 file moved between matters keeps its stale matterId in the ingestion-management row and in the OpenSearch routing. A migration re-key endpoint would fix it as a side effect.

What is still unmeasured

Four pieces of work convert the ranges on this page into numbers. They are scheduled in the activation plan's Stage 2.

MeasurementWhat it settles
Dev cost, extractor and capacity experimentPer-document ingestion cost (once tracing lands) and per-matter context cost from the existing incremental-backfill-case traces; the extractor verdict, running the same documents through Textract and Unstructured for text and table fidelity (tables are where Textract earns its price, and legal documents care), downstream search quality, and Unstructured docs/hour per vCPU; and maximum capacity by the burst method, which also says which shared ceiling binds first (embedding TPM, OpenSearch bulk indexing, or Textract TPS while it lasts). One experiment, four answers: cost model, extractor verdict, capacity number, lane sizing.
v2-reuse spike (ingestion and case context)conditional: runs only if the full v2 book is committed; Woodstock is the committed migration and v2 may stay a proof-of-concept burst of ~1,000 filesThe ingestion half prototypes the re-key: a content-tree copy plus a chunk re-index from the parquet artefacts. The case-context half is the coverage query on lawrence_case_context, which needs a tunnel because the database is VPC-only, plus the content-JSON asset-id check and a one-case re-key prototype through the registry translation. It ends in a decision doc with measured cost and time against regenerate. Two more queries ride the same tunnel session: the real statement-per-asset distribution from processedasset.statement_count, and per-database sizes for what prd-db's 38 GB actually contains.
Woodstock document censusThe live document estate, scoped to open matters only. Required before quoting Woodstock cost or timeline at all, and it is what makes the ~520k–1.5M range collapse.
OpenSearch storage spike (E5)Whether quantization and a mapping review halve the 393 GB index, which sets the resize target before anything is bought. About a week-shaped spike, and it runs alongside the reuse one.

Two more things need checking before the dev context run specifically. Jurisdiction is inferred from the case-id prefix, so the prefix migrated v3 matters actually carry decides whether UK firms get US prompt variants. And chain-of-key is on in dev and production but off by code default, so a local measurement silently prices the wrong reconciliation path: measure in dev, not locally.

Constraints any activation plan must satisfy

Each of these follows directly from a number above, and each is executed in the imported file activation tech plan.

Sources and measurement dates

SourcePulled
v2 production database: live and full case, file and byte counts by status2026-08-11, 2026-08-21
v2 production database: top-20 extension mix over live cases2026-08-29
LEAP / Woodstock open-matter counts2026-08-11
Production Axiom spans, ingestion worker stages (7-day window)to 2026-08-29
SSM: WORKER__COUNT per stage, summarisation model config2026-08-29
Langfuse production prompt configs: case-context models and fallbacks2026-08-29
prd ingestion database: asset census by id prefix and status2026-08-29
prd ingestion OpenSearch domain and database instance specs2026-08-29
Ingestion terraform stack (stacks/ingestion/main.tf): queue list and visibility timeouts, queue-depth autoscaling config, OpenSearch and RDS modules2026-08-30
Live AWS: prd SQS queue listing and per-queue redrive policies2026-08-30
Shared platform database (prd-db): instance class, ceiling, usage2026-08-29
AWS price list API: eu-west-2 on-demand rates for OpenSearch and RDS2026-08-31
OpenAI org tier limits; KMS RSA signing quota2026-08-29
eu-west-2 Fargate rates and current ingestion fleet counts2026-08-29
Capacity audit: observed live throughput and its one-time LLM cost guessprior work, superseded by the model above

Read directly from source: IngestionStatusCell.tsx, catalogue/matter.ts, packages/id-schemas/src/matter-service/matter-id.ts, core/utils/locale.py, statement_pipeline/main.py, ingestion worker.py.

Companion pages: imported file activation tech plan · files migration tech plan · imported firm-member activation plan.