# The extraction playground as a product surface

## What this builds

The playground becomes part of the package and gains the three things the approved design adds: a document library to enter through, a batch mode that answers many questions in one run, and a chat pane that collapses so the document can take the full width.

The retrieval path, the answer shapes, streaming and cell-level grounding all stay as they are. The response contract already carries what the design needs — `shape`, `value`, `references`, `retrieved` — so no existing endpoint changes its meaning.

## Where the code lands

`src/quber/playground/`, started by `quber playground`, joining `document`, `ade`, `figure`, `table`, `parse`, `extract` and `validate` on the CLI group. The name drops `ade_rag`: ADE is one of several ingest paths into this and naming the package after one of them mis-describes it.

Two consequences of being inside the package rather than beside it:

**Linting and type checking.** `pyproject.toml` sets `[tool.ruff] exclude = ["experiments/**"]` and `.pre-commit-config.yaml` excludes `^experiments/`. About 2,900 lines have never passed either gate and will have to. Expect mechanical fixes, and expect pyright to find loose typing in the older ingest modules.

**Packaging.** `[tool.setuptools.package-data]` ships only `quber = ["prompts/*.toml"]`. The static assets — the page, the design system stylesheets under `static/tokens/`, the images, the component files — are not Python and will be absent from an installed package until they are listed.

The Postgres schema keeps the name `ade_playground`. It holds 5,159 chunks across 8 documents and renaming it means re-ingesting all of them for cosmetics.

## The view layer

The design is expressed as templates with conditionals, loops and interpolation over a props object. That maps onto JSX directly, so the view becomes React components: one file per region — library, document pane, chat, composer, answer, references, provenance, review drawer, batch.

Styling in the design is inline because its format requires it. In the package the same declarations become a stylesheet per component, drawing every colour, size and radius from the design system variables the page already links (`--brand`, `--surface-alt`, `--text-strong`, `--sheet-green`, `--radius`, `--shadow-sm`). No component declares a colour of its own.

React and ReactDOM load as production UMD builds. The JSX is compiled before it ships rather than in the browser, so a page load carries no compiler and what runs is what was checked.

**The page fits the document to whatever width it has.** Collapsing the chat pane or dragging the divider re-fits, and so does resizing the browser window. The prototype re-fits on the first two only, which is a difference worth carrying over rather than inheriting: a window that changes size and leaves the page at its old scale looks broken in the one moment a reader is most likely to be adjusting the layout.

**Scrolling follows the reader, not the run.** The pane goes to the bottom when a message arrives. It does not when an entry already on screen updates in place, which is what a batch row landing and a streaming delta both are, unless the reader is at the bottom already. Otherwise scrolling up to check row nine of forty is fought by every row that lands after it.

**The keyboard behaviour is built once, in the components.** A modal closes on Escape and holds focus while it is open. The expectation toggle is a radio group and moves between its three options with the arrow keys. These are absent from the design, which is a prototype and is the right place for them to be absent; they are the kind of thing that is cheap in a component and expensive to retrofit across the places one gets used.

## Compliance and the build

Code under `src/` is formatted, linted and type-checked with no exception, and that rule decides the tooling. The repo has no JavaScript toolchain today — no package manifest, no linter, no formatter, and every pre-commit hook is Python or generic file hygiene. One has to be added for this code to be admissible at all.

**The design system supplies the lint rules.** `_adherence.oxlintrc.json`, which ships with it, rejects a raw hex colour, a raw px value, any font outside the three IBM Plex families, an undeclared prop on any of its twelve components, an out-of-range value for a declared prop, and an import that reaches into a component's internals rather than the published entry point. Adopting it makes brand adherence a build failure instead of a review comment — the class of defect that put an off-palette grey into the value card and was caught by eye rather than by a check.

**The stylesheets need a check of their own.** Every colour and size rule in that configuration matches a string literal in JavaScript, and oxlint reads JavaScript. The declarations this plan moves out of the markup and into stylesheets are therefore not covered by it, so a raw hex in a `.css` file would pass the very gate that exists to reject raw hex. The CSS side gets its own check, rejecting a hex colour, a raw px length and an off-family font in any stylesheet under the package. The two checks together are what makes the claim above true; either alone leaves half the declarations unguarded.

**Four colours the shared palette does not have.** The page today declares its own error red, a green border and wash to pair with the palette's green, and a pale navy selection wash, because the design system carries no red at all and no border-weight companion to its green. A check that rejects raw hex turns those four into build failures, and the fix is not an exemption for the page. They are what an answering surface needs in order to show a reconciliation status, so they belong in the design system's token set, added there and consumed here like every other colour.

**Compiled output is committed.** Running from a fresh checkout then needs no build, which keeps the server's static path honest and defers a CI pipeline until there is somewhere to run one. The cost is that a stale artifact is possible, so a pre-commit hook recompiles and fails if the committed output differs from what the sources produce. Forgetting to build becomes a blocked commit rather than a page that silently serves last week's interface.

**What gets added**: a package manifest and lockfile, the compile step, the oxlint configuration, a formatter, and pre-commit hooks that run all three over the JavaScript sources the way ruff and pyright already run over the Python.

## Batch answering

The hardest of the three.

**What a run is.** A set of questions asked against one document, entered after that document is open. Batch has its own screen, and the questions are typed or pasted into the prompt window there, one per line. There is no file to upload and no other way in. The run carries one expectation — auto, value or text — for all of them. A run never spans documents and a question never names its own document; asking the same set of four filings is four runs.

**Each question is an ordinary answer.** Retrieval runs for it, then the answer step fills the same `Answer` a single question produces. Batch introduces no second answer format and no second code path; a row in a batch result is the same object the chat pane renders.

**A run is one entry in the transcript, not forty.** Forty answers rendered as messages is unreadable, and each carries a reference list and a retrieved-chunk list that together bury the pane. The shape that fits is a table: the run appears as a single card carrying its counts, and opens a result grid of one row per question — the question, the figure, its unit and period, the page, and whether it succeeded. Rows land as each question resolves rather than all at once when the run ends, so a long run is legible while it is still going.

A row expands to show that answer in full, with its references and its retrieved chunks. Only one row's detail is ever on screen, because a reference list is something you consult for the row you are checking, not for forty at once. Selecting a row drives the document pane and draws that answer's cited cells on the page, which is what makes forty answers worth having: the work is not reading them, it is spot-checking them against the filing.

Forty rows needs no pagination and no virtualization. Building either would be solving a problem this does not have.

**A run is a server-side job.** `POST /api/batch` takes the document, the questions and the expectation, and returns a job id. `GET /api/batch/{id}` returns the run's state and the rows finished so far. The work continues if the page is closed, which matters because a hundred questions is minutes of model time.

**Five questions run at a time**, held by a semaphore. The provider returns 529 under load — observed on 2026-07-29 during a sweep — so each attempt retries with backoff, and a question that exhausts its retries becomes a row carrying its error rather than vanishing. A run reports how many rows failed and never reports success for a run that lost questions.

**Progress is events, not a log.** The job emits one event per question completing, carrying the index, the total and the outcome, so the page shows a count filling in and a terminal can follow the same run without scraping text.

**A run lasts the browser session.** Rows are held per job in the server's memory, capped at the twenty most recent runs so a long-lived process does not accumulate them, and the page keeps its job ids in `sessionStorage`. That gives exactly the intended lifetime: leaving a document and coming back finds the run, a reload finds it, and a new session starts empty. Nothing is written to disk and no table is added.

**A recovered run needs somewhere to appear.** The transcript is not stored — messages live as long as the page does — so re-attaching cannot put a run back where it was by reloading a conversation. What re-attaching does is rebuild the run card from what the route returns, its counts and its finished rows, and put it in the transcript as the entry it was. A run recovered mid-flight keeps filling in from that point. Without this the job is found and has nowhere to be drawn.

An id the server cannot answer for is a case to handle rather than discover, and there are two of them. The process restarts and every run goes with it. Or a session outlives the cap and its oldest run is evicted while its id is still held. Both arrive at the page identically, as an id it kept and a run the server does not have, and both report that run as gone. Neither is an error and neither is silence, because a batch of forty figures quietly becoming thirty-nine is the failure this feature exists to prevent. Twenty is chosen to put eviction out of reach of a working session rather than to be relied on.

## What a batch exports

One row per question, in two formats. **xlsx is the default**, and CSV is offered beside it.

**xlsx is the default because a cell carries its own type and format.** A printed figure is written as text and stays `$ (10,550)`. A percentage is written as the fraction under a percent format, so it arrives as a percentage at the printed scale and displays as `6.7%`. Nothing is inferred from a neighbouring column and nothing depends on how the file is opened. The percent format takes its decimal places from the printed figure, so a page printing `6.7 %` gets one and a page printing `7.84%` gets two rather than both being padded to a fixed width.

**CSV is offered for the tools that want it, with one limitation that belongs to the format.** The printed column is quoted and the numeric column is not, which is the asymmetry that makes both columns right: measured, a quoted `"6.7 %"` stays text and a bare `6.7%` beside it imports as 0.067 under a percent format. That holds when the reader has the importer treating quoted fields as text. With that off the printed column is converted — `6.7 %` arrives as 0.067 and `$ (10,650)` as a currency amount — so the audit column reads differently from the filing. It is a flag on the reader's side, and it is why the workbook is the default rather than the only option.

The numeric column differs between the two, because each format needs a different thing to reach the same cell. The workbook holds the fraction and names the format itself. The CSV holds the printed magnitude with its percent sign and lets the importer do the division, which is what makes `6.7%` in a text file and 0.067 under `0.0%` in a workbook land in the same place.

**A formula cannot survive either export.** A question is typed by a person and a note is written by a model, and in a text file a cell beginning with `=`, `+`, `-` or `@` is something a sheet will evaluate. In the workbook those cells are typed as text, so there is nothing to evaluate. In the CSV the free-text columns are prefixed, and only those: a printed figure is never prefixed, because that column has to match the filing byte for byte. Verified on both import settings — a question reading `=1+1` arrives as characters, and the figures beside it are untouched.

So each figure is written twice in both formats, **as printed** and **as a number**. The answer carries both already, `value` and `number`, so this is a column each rather than a conversion.

Every row also carries where the figure came from and whether it can be trusted: the cited ids, the pages they sit on, the text printed in the cell the value was read from, the reconciliation status that cell carries, the status note where the inspector left one, and whether that status is one registered for review.

That last group is the point of exporting at all. A sheet of figures is an assertion; a sheet of figures each carrying its source cell and that cell's reconciliation status is an audit trail. The review flag is part of it and not a convenience: a reader filtering a hundred rows for the ones that need a human cannot do it from seven status names they have to know by heart.

The columns, in order: the question, the figure as printed, the figure as a number, its unit, its period, the page, the cited ids, the source cell text, the status, the note, and the error. A question that failed carries its error and nothing else. A question that was answered leaves the error empty. Where a cited answer rests on more than one cell, the cell text, the status and the note each carry all of them rather than the first.

**A question with no answer is a third kind of row.** An `unanswerable` answer is not a failure — retrieval ran, the model answered, and the answer is that the document does not print the figure. So it exports as neither a figure nor an error: a sentinel in the as-printed column, an empty numeric column, and the model's explanation in the note. A reader sorting the sheet can then separate three things that are genuinely different, a figure, a figure the document does not contain, and a question that never got that far.

**One function reads a printed figure as a number, and it is not in the export.** `answers/figures.py` holds it. The answer's `number` is derived there from `value` rather than asked of the model, so the export reads a field and converts nothing, and so does the benchmark's scoring, which now calls the same function instead of keeping its own copy.

Being blunt about why, because this was got wrong twice before it was got right. A second implementation of one conversion disagrees with the first, and the way it disagrees is by sign. A figure printed `$(10,550)` is negative, and a reading that looks for parentheses before removing the currency sign sees a string beginning with `$`, finds no parentheses, and reports a positive number. That was written and caught in the benchmark's parser, then written again and caught in the design's export. What stops a third is that there is one function and nowhere else to put one.

**The model is not asked for the number, because it does not reliably give one.** Asked for the printed figure and its numeric form together, it returns the figure every time and the number most of the time. One question put four times came back with `65%` four times and with `65` three times, dropping the number once — measured, not supposed. A numeric column with holes that appear on some runs and not others is worse than no column, and the conversion is deterministic string work with nothing for a model to contribute. So the model's job is the printed figure, faithfully, and the number is computed from it.

That also removes a whole class of row from the export. A numeric column is now empty only when the printed figure genuinely is not a number, never because a model omitted a field.

**A percentage is recognised by its notation, not by its unit.** The `unit` field is still the model's to fill and still comes back absent sometimes — one of the four percentage cases returned no unit at all on one run. So the test for whether a figure carries a percent sign reads the printed figure, which is the field the model fills reliably. It is a question about notation and never about magnitude, so it does not reopen the rule above.

**A percentage is written as a percentage.** The numeric cell holds the fraction and carries a percent format, so it computes as a rate and displays as the document printed it. A bare number in a plain cell would leave the reader to work out whether 6.7 is a rate or 6.7 of something, and a bare fraction in a plain cell would show 0.067 where the filing says 6.7%. The intent is encoded in the cell rather than left to be inferred from a neighbouring column.

The rescale that needs is one line, and it lives with the reader rather than in the writer. A percent-formatted cell showing `6.7%` holds 0.067, so something has to divide, and `answers/figures.py` is where a printed figure is turned into anything. The writer picks a value and a format and performs no arithmetic, which is the same rule as before rather than an exception to it.

The format mirrors the printed precision. A filing printing `6.7 %` gets one decimal place and a filing printing `7.84%` gets two, so the cell shows what the page shows rather than padding it to a fixed width. A figure printed `65%` shows as `65%`.

**The figure exactly as printed is a text cell.** The model's `value`, verbatim, in a cell typed as text, which is what stops a sheet from reading `$ (10,550)` as a currency amount or `(0.02)` as a negative and displaying either differently from the document. The audit trail is a reader being able to see what the filing said, so nothing is allowed to reformat that column.

Writing it as text also removes a class of risk rather than guarding against it. A question is typed by a person and could begin with `=`, and in a text file that is a formula the sheet will evaluate. In a cell typed as text it is the characters it is, so there is no injection to defend against and no prefix character added to anyone's question.

**This is checked by reading the file back.** The one part of an export a codebase does not decide is what a spreadsheet does with it, so the workbook is opened and each cell's stored value and number format are read and compared against what the document prints: a plain figure, an accounting negative, and a percentage at minimum. A format is not called correct because it was written correctly.

## How a question is submitted

The composer treats everything in it as one question, always. A newline never means a second question, and pasting several lines into it asks one question that happens to span lines.

Batch is reached deliberately, on its own screen, where one question per line is the stated format of that screen's prompt window. Nothing about a submission is inferred, and neither window changes meaning based on what is typed into it.

**Each window says what it takes, in its own placeholder.** The chat composer reads `Ask anything about this file…`. The batch window's is three lines, one question each, because the format is the thing that has to be obvious there: no newline anywhere else in the product means a second question, so the one place it does has to show it rather than explain it.

A placeholder is not a starter prompt. It is not clickable, it inserts nothing, and it disappears on the first keystroke. It is the label of the field, put where the field is, which is why the empty pane needs nothing else in it.

**Sending is closed while a run is going.** A single question asked mid-run would land in the transcript between a run card and its rows, which reads as part of the run without being part of it. So the composer's send is disabled until the run finishes.

The cost is real and worth naming: a hundred questions is minutes, and for those minutes one question cannot be asked. The run continues without the page, so nothing about the job requires this — it is the transcript that cannot hold both at once. If those minutes start to matter, the answer is to give a single question somewhere to land that is not between a run and its rows, not to allow the interleaving.

## The document library

The surface a user enters through, listing what is ingested: the document under a readable title, its page count, how it was ingested, and how many of its cells the ingest flagged for review. Selecting one opens the answering view on it.

**A readable title.** The documents table holds a slug, a filename, a page count and the parser version, and nothing a person would want read back to them. A filing whose file is named `vz_1q26_fs_042726rm.pdf` and whose slug is `vz-2026q1` is identified by neither. So that table gains one nullable title column, set at upload alongside the slug, and a row without one falls back to its slug. Nullable is what makes it cheap. The 8 ingested documents read as absent and keep working, so nothing is re-ingested, and `schema.sql` carries the column for a fresh database.

**A folder, assigned once.** The same shape of change as the title, and for the same reason: the library groups documents under folder headings, so the folder has to be stored somewhere. It is a second nullable column on that table, set from the upload form beside the title and the slug, and a row without one falls under an ungrouped heading. The ingested rows read as absent and keep working.

What makes this cheap is that a folder is assigned and never reassigned. There is no route to change one, no move, no rename, and no surface for either. Re-foldering is a later change, and when it comes it is a write against a column that already exists rather than a schema change.

**Document type is how it was ingested.** Nothing in the pipeline classifies a filing as a 10-K or a supplemental, and inventing that classification means reading the document to guess at it. The type a card can honestly carry is the one already served: whether the document came through the fusion pipeline or through ADE.

Almost everything else it shows is already served. The exception is the flag count, which no route returns today and which the document list grows a field for rather than being assembled from one call per document. The card does not print that count yet. The field lands because assembling it later from one call per document is the thing worth avoiding, and what the card does with it is a separate decision that has been deferred.

## Collapsible chat

The chat pane collapses so the document takes the full width, and expands again. Frontend state only; nothing behind it changes.

## What a session keeps

A conversation survives leaving a document and coming back, and survives nothing beyond that. Going to the library and reopening a filing finds the questions already asked and the answers already given. A new window starts empty, and so does a return after the browser has been closed.

That lifetime is not approximated, it is `sessionStorage`, which the browser already scopes to the tab. There is nothing to enforce and nothing to expire: a new window is a new store, and closing the tab is the end of it. The same mechanism already holds the batch job ids, so one rule covers both — nothing about a session reaches disk and nothing about it outlives the tab it was opened in.

A transcript is kept per document, so two filings each hold their own thread instead of sharing one. What is stored is the messages, except that a batch entry stores its job id rather than its rows. The rows are recoverable from the run already, and forty of them carrying their retrieved chunks is the one thing in a transcript large enough to press against a storage limit.

Analysts treat past questions as work product, and losing a morning's questions by clicking back to the library is the kind of small loss that makes a tool feel careless. This costs a serialize on change and a read on open.

## What the API gains

Five new routes, one existing route grows a field, and one existing route stops refusing work.

`POST /api/batch` starts a run from the document, the questions and the expectation, and returns a job id.

`GET /api/batch/{id}` returns the run's state and the rows finished so far. This is what the page calls when it comes back to a document and finds a job id in `sessionStorage`, and it is the route that reports a run as gone after a restart.

`GET /api/batch/{id}/events` streams one event per question completing, carrying the index, the total and the outcome. Server-sent events, the transport `/api/chat/stream` already uses. This is what makes progress a count filling in rather than a log being tailed; the two read routes are separate because watching a run and re-attaching to one are different needs.

`GET /api/batch/{id}/export.xlsx` writes the workbook described above, and `GET /api/batch/{id}/export.csv` writes the same rows as text. Two routes rather than one taking a format, because each writes a different thing: the workbook sets a type and a number format per cell and the text file encodes what it can in the characters. They share the row building and nothing else.

Writing xlsx needs `openpyxl`, which is already resolved in the lock as a dependency of something else. Using it here makes it a dependency of this package too, so it is declared in `pyproject.toml` rather than relied on for being present.

`GET /api/documents` returns the title and the folder alongside what it already serves, and `POST /api/upload` accepts both, which is the only place either is ever set.

`GET /api/documents` also gains a count of the cells flagged for review on each document. The library lists that count per document, and the only way to obtain it today is calling `/api/document/{slug}/flags` once per document, each call returning every flag object with its bounding box in order to take a length. A count belongs in the list.

Job status already exists unchanged, and the design simulates it client-side, so wiring the stepper and the log view to the real route is work rather than new API. What that route gives is more than a spinner, and its shape decides how an ingest is drawn. It returns the stage a job is in by name, and a tail of that stage's output, so the stepper and the log are both backed by real state rather than standing in for it. The names it emits are its own — `uploaded`, `extracting`, `reading figures`, `ingesting`, then `done` or `failed` — and the view shows those rather than a parallel set of labels that can drift from them. The stages differ by ingest path, because a fusion upload reads the figures and an ADE parse has no such step, so the steps are read off the job rather than fixed in the view. Only the fusion path is drawn to begin with. Reading the steps off the job is what makes that a starting point rather than a decision to undo: the ADE variant appears when the view is pointed at an ADE job, without the view learning anything new.

**Upload stops refusing a second document.** Today a second upload arriving while one runs is rejected outright, and that is a restriction of this one process rather than a property of the work. Ingest on a server runs documents concurrently, so this is built as though it will. The refusal goes: every upload is admitted, and a semaphore holds how many run at once. The number is preset and settable — two by default, one available for a machine that cannot spare the contention — and it bounds concurrency rather than precluding it.

The number is small because the contention is real. Every stage is a subprocess, so two extractions running together are two `quber fuse` processes on one CUDA device, each also making model calls. Two is chosen to overlap the parts of a run that are not the GPU, not to saturate it. It belongs in a settings group alongside the ones already there, so it is one configured value rather than a constant to go find.

A job that has been admitted and is waiting for a slot is a state the stepper has to draw, and it is not `extracting`. So the stage set gains a waiting state ahead of the ones the pipeline emits, and a queued document has something honest to show rather than appearing stalled on its first step.

**One consequence settled here rather than left to be discovered.** A page learns a job id by starting the job, which is enough to follow its own uploads and is the mechanism `sessionStorage` already provides for batch. It is not enough to show an ingest that a different session started, and no route lists jobs. Making in-flight ingests visible to whoever opens the library is therefore a fifth route, and it is worth adding when someone asks to see another session's work. This plan does not add it. The library shows the ingests started from the session it is in.

## Tests

The repo has 34 test files mirroring the package, and coverage is measured on every run — `addopts` carries `--cov=quber --cov-report=term-missing`. Onboarded code follows the same convention, under `tests/playground/`, and none of it arrives untested.

What a test needs decides how it is written, and there are three groups.

**Logic that needs neither a model nor a database** gets ordinary unit tests, and this is most of the code worth testing:

- `answers/shapes.py` — the payload union discriminates correctly, a scalar's derived `number` follows its `value`, and `unanswerable` stays distinguishable from a scalar of zero.
- `answers/figures.py` — the one figure reader, which is where the table of cases belongs: accounting parentheses as negatives, a currency sign preceding those parentheses, separators, percents keeping their magnitude, multiples, scale words, and a non-numeric printed form returning nothing rather than guessing.
- `answers/expectation.py` — the two mappers, including that a `not_found` from a forced shape becomes `unanswerable` rather than an empty figure.
- `answers/planned.py` — `compile_model`: each declared field type, required against optional, `cited_ids` and `not_found` appended to every model, and a plan that declared either of those names losing to the appended one.
- `benchmark/run_shapes.py` — the comparison built on that reader rather than the reading itself: exact-as-printed against numeric agreement, and finding a gold figure inside a prose answer, where a token's decimal part must not swallow the full stop ending a sentence.
- `benchmark/verify_gold.py` — cell extraction from chunk markup, and that a gold value disagreeing with its cell is dropped rather than corrected.
- `retrieval.py` — the per-parent cap on line records: capped tables keep their whole-table chunk, everything that is not a line record passes through.
- The batch input parser — how a pasted block becomes a list of questions, and what happens to blank lines and duplicates.
- The export writers, both of them. For the workbook: that the as-printed cell is typed as text and holds `value` unchanged, that the numeric cell holds the derived number and is empty rather than guessed when the printed form is not numeric, and that a percentage's cell carries a percent format whose decimals match the printed figure. These assert against the file read back, because a cell's type and format are the thing under test and neither is visible in the code that sets them. For the text file: that the printed column is quoted and the numeric column is not, that a free-text cell beginning with a formula character is prefixed, and that a printed figure never is.

**Logic that needs the database** gets integration tests against the development Postgres, not mocks. A mocked query proves the mock, not the SQL: reference resolution against known cited ids, the fused retrieval window over the real corpus, and the cell lookup `verify_gold` depends on.

**Logic that calls a model** is not unit tested. It is verified end to end against the running app on real ingested documents, which is how the answer shapes were established and how the batch run will be.

One thing to settle rather than inherit. `[tool.coverage.run]` omits `src/quber/agents/*` as pydantic-ai modules, and the playground's answer modules construct agents too. Extending that omission over them would make the coverage number describe less than it appears to. The alternative, and the one this plan takes: keep agent construction thin — a function that builds an agent and nothing else — so the mappers, the compiled models and the shapes are all ordinary testable code, and only the constructor sits outside.

## Non-goals

- No change to retrieval, the answer shapes, streaming or grounding.
- No CI pipeline for the build. The compile runs locally and its output is committed, guarded by a pre-commit check, until there is a server environment to build in.
- No rename of the `ade_playground` schema.
- No new database table, and nothing about a run written to disk. The schema change is two nullable columns, a title and a folder, on the existing documents table.
- No document-type classification, because nothing in the pipeline makes that judgement and guessing it means reading the document.
- No moving, renaming or deleting a folder. A folder is set at upload and the library reads it. Changing one afterwards is a later change against a column that will already be there.
- No flag count printed on a library card, and no ADE stage stepper drawn. Both are deferred rather than blocked: the field and the stages are both available to whatever draws them.
- No starter prompts in an empty chat pane — no suggested questions to click and nothing inserted into the composer. The composer's own placeholder is the invitation, and it stays.
- No conversation surviving the tab it was asked in. Leaving a document and coming back keeps the thread; a new window and a reopened browser both start empty, and neither is a case to work around.
- No run spanning documents, and no question naming its own document. Both are worth revisiting once the single-document case is in use.
- No inference from what a question looks like. A newline is not a batch, and a question is not routed by pattern-matching its text.
- No question file to upload. The batch screen's prompt window is the only way questions arrive.
- No route listing ingest jobs. A session follows the uploads it started, and showing another session's in-flight ingest is worth adding once it is asked for.
