Security review follow-ups. The 200-character registry-name cap the UI
forms promise now holds in domain.Validate for categories, tags,
merchants and instruments, so a non-browser client cannot persist an
unbounded name that every subsequent state response would carry. And a
model-supplied merchant or taxonomy name containing control or format
code points — bidi overrides, zero-width characters — is dropped like
an identifier-shaped one: React escaping already prevented injection,
but such names could visually spoof or reorder the review UI the
operator approves from.
Azure is the only zero-data-retention route for the gpt-5.6 family, so
its capacity 429s arrive frequently and OpenRouter forwards them inside
an HTTP 200 envelope. Those bypassed the rate controller entirely: a
paced run kept sending a request every three seconds into a throttled
endpoint, failing row by row. An in-envelope 429 now records the same
escalating cooldown as a transport 429, so later acquisitions fail fast
until the deadline passes.
Strict structured-output mode rejects uniqueItems, so every request to a
gpt-5.6-family zero-data-retention endpoint failed with HTTP 400 behind
a generic error; duplicates were already rejected server-side, so the
keyword leaves the wire schemas, pinned by a strict-keyword allowlist
test built from the ledger that hit this.
The bare-BIC redaction pattern deleted every 8- and 11-letter word —
Openbank, BAUMARKT, RACETRACKER — blinding the model to the payee it
was asked to classify and tripping the unsafe-merchant check on honest
answers. BICs now die only labeled or attached to their IBAN, account
labels join the redaction secrets, an identifier-shaped merchant name
degrades to a merchant-less proposal instead of failing the row, and a
provider error inside an HTTP 200 envelope is reported as such (numeric
code only) instead of as envelope corruption.
The gpt-5.6 family's zero-data-retention endpoints declare
max_completion_tokens, so sending max_tokens under require_parameters
excluded every ZDR route and returned HTTP 404 for the whole family.
The cap is retired: the strict schema, the finish_reason check and the
64 KiB read cap already bound the response.
The model fields now offer the provider's public ZDR catalog filtered
by the exact conditions completions are routed under (live endpoint,
strict structured outputs), fetched server-side, cached for an hour,
and served at GET /api/models; the inputs stay free text so an unlisted
model remains usable when the catalog is unreachable.
A low-confidence answer was discarded inside Classify, so Analyse showed
the row as unchanged instead of a reviewable suggestion; the fallback
decision moves to the import path, which keeps the merchant link and the
recorded confidence. ApplyPreview now rebases onto the current journal:
unrelated commits during a minutes-long paced run no longer invalidate
the review, only an edit to a selected transaction itself conflicts, and
applied changes are pruned so the rest stay appliable.