diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d80d80d0..a59bac47 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -298,7 +298,10 @@ jobs: - name: Run zizmor (SARIF) # zizmor exits non-zero on findings at/above its default (high) threshold, # so this step fails the job — and thus checks-complete — on a finding. - run: uvx zizmor@latest --format=sarif .github/workflows/ > results.sarif + # Pinned (not @latest) so the gate is reproducible: a scanner release must + # never be able to flip a previously-green PR red on its own schedule. + # Bump deliberately, like every other pinned tool here. + run: uvx zizmor@1.28.0 --format=sarif .github/workflows/ > results.sarif env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Upload SARIF to GitHub code scanning @@ -312,12 +315,39 @@ jobs: sarif_file: results.sarif category: zizmor + docs: + # Docs are a load-bearing product surface (the spec and the app-dev guides + # are the contract's public face), and doc/code drift has repeatedly lived + # under a green CI — mkdocstrings warnings and stale API examples are + # invisible to lint/test. A strict build turns that whole drift class into + # a failing PR check. + name: Docs build (strict) + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.13" + - name: Install uv + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + version: ${{ env.UV_VERSION }} + enable-cache: true + cache-dependency-glob: uv.lock + - name: Install dependencies (locked) + run: uv sync --locked --all-extras --all-groups + - name: Build docs (strict) + run: uv run --no-sync mkdocs build --strict + checks-complete: # Single aggregate status required by branch protection (ADR D8). Adding a # new job above automatically tightens the gate via `needs`. name: checks-complete if: always() - needs: [lock-check, lint, test, lower-bounds, numpy-floor, build, actionlint, coverage, zizmor] + needs: [lock-check, lint, test, lower-bounds, numpy-floor, build, actionlint, coverage, zizmor, docs] runs-on: ubuntu-latest timeout-minutes: 5 steps: diff --git a/.github/workflows/mkdocs.yml b/.github/workflows/mkdocs.yml index 26a166e5..deaab857 100644 --- a/.github/workflows/mkdocs.yml +++ b/.github/workflows/mkdocs.yml @@ -8,6 +8,14 @@ on: - 'docs/**' - 'mkdocs.yml' - 'src/standard_asr/**' + # The deploy builds from the LOCKED environment, so its output depends on + # these too: a mkdocs-material/mkdocstrings bump in uv.lock (or a docs + # dependency change in pyproject.toml, or an edit to this workflow, e.g. + # the UV_VERSION pin) must redeploy, or the published site silently + # diverges from the environment the CI docs gate validated. + - 'uv.lock' + - 'pyproject.toml' + - '.github/workflows/mkdocs.yml' workflow_dispatch: # Least-privilege at the top level; the deploy job opts up to contents: write @@ -19,6 +27,11 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +env: + # Same pin as ci.yml so the deploy environment is byte-for-byte the one the + # PR-blocking "Docs build (strict)" gate validated. Bump alongside ci.yml. + UV_VERSION: "0.11.21" + jobs: deploy: name: Build & deploy docs @@ -37,14 +50,21 @@ jobs: git config user.email 41898282+github-actions[bot]@users.noreply.github.com - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: - python-version: 3.x - - run: echo "cache_id=$(date --utc '+%V')" >> "$GITHUB_ENV" - - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + python-version: "3.13" + # Deploy from the SAME locked environment the CI docs gate builds with + # (uv.lock, all extras/groups, Python 3.13). Previously this job floated + # `pip install mkdocs-material mkdocstrings` on Python 3.x, so a green PR + # gate did not guarantee a green (or identically rendered) deploy — + # dependency drift could change the site or block publishing. + - name: Install uv + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: - key: mkdocs-material-${{ env.cache_id }} - path: ~/.cache - restore-keys: | - mkdocs-material- - - run: pip install mkdocs-material "mkdocstrings[python]>=0.29" - - run: pip install -e . - - run: mkdocs gh-deploy --force + version: ${{ env.UV_VERSION }} + enable-cache: true + cache-dependency-glob: uv.lock + - name: Install dependencies (locked) + run: uv sync --locked --all-extras --all-groups + # --strict mirrors the CI "Docs build (strict)" PR gate: main should + # always be strict-clean, so this is a backstop that refuses to publish a + # site with doc/code drift rather than publishing it with warnings. + - run: uv run --no-sync mkdocs gh-deploy --force --strict diff --git a/AGENTS.md b/AGENTS.md index 64ab535a..cdd21ced 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,7 @@ Standard ASR is a **Python library that defines and enforces a universal interfa - **Explicit > implicit.** Silent wrong results are the cardinal sin. When in doubt, fail loudly or emit a structured diagnostic — never silently degrade. When DX convenience and explicitness conflict, **correctness wins** (a loud error the developer can fix beats a silent wrong transcript). - **Standard-library rigor.** This is infrastructure others build on for 10 years. Types complete, boundaries sharp, error paths explicit, no implicit behavior. - **Security by default.** Credentials use `SecretStr`. URLs validated (HTTPS, no SSRF). Unsafe options require explicit opt-in. +- **Trust model: plugins are trusted code. The security layer defends against ACCIDENTS, not adversaries.** An installed engine plugin runs arbitrary in-process code — it can already read `os.environ` and open sockets, so no in-library boundary can contain a malicious plugin, and we do not build one. What we DO defend against is the honest mistake: a mis-pasted credential echoed back by pydantic's `input_value=...`, a secret riding inside an exception message into a log or a wire response. The whole defense is three cheap rules — scrub validation-error echoes, wrap credentials in `SecretStr`, never format a raw exception chain into operator logs or client responses. **Hard budget:** no proofs about third-party code, no introspection of pydantic/CPython internals, no machinery against hostile metaclasses / forged markings / subverted C slots — those attackers don't need our log path. A defense that needs its own review rounds to stay correct is a defect source, not a defense. Reviewers: do not file findings that assume an adversarial plugin author; they are out of scope by this definition. ## Rules diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f0c4ff1..4168edf5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,783 @@ releases may include breaking changes. ## [Unreleased] +### Changed (breaking — pre-1.0 policy: long-term design over compatibility) + +- **Credential safety is redesigned around a pinned trust model: plugins are + trusted code, and the security layer defends against ACCIDENTS, not + adversaries** (AGENTS.md). What ships is a set of cheap, total rules + (`runtime/redaction.py`): validation-error detail is rebuilt from + `type`/`loc`/`msg` only (the `input` echo, `ctx`, and `url` are dropped; + credential-named fields and input-echoing validator messages are redacted; + a `loc` component not shaped like a field name — pasted key material is + long or punctuated — is masked as `[redacted-key]`); operator-bound + exception text goes through a bounded, cycle-safe cause/context chain + summary (`safe_exception_summary`) that renders `ValidationError` links + through the sanitized entries and withholds a wrapper message that + byte-for-byte interpolated one; and `log_exception_safely` logs a full + traceback only when the active chain carries no `ValidationError`. + `BaseConfig`'s definition-time guards keep the honest-author dump rules + as plain enumerations (undeclared value shapes, `SerializeAsAny`, nested + submodel hooks, `exclude=True`). Documented accepted limits: a + paraphrased or re-encoded echo, an author who copies an error's text and + then discards the chain, an identifier-shaped sensitive value in a `loc`. + (During pre-release review rounds this layer briefly grew into a + 1,600-line exception-provenance prover with core-schema closure proofs; + it is removed under the AGENTS.md hard budget — its own complexity + produced more confirmed defects than the residual risks it closed.) +- **`BaseConfig`'s serialization surface is closed by DISPATCH as well as by + schema.** `public_dump()`'s "safe for `/v1/models`" contract rested on the + definition-time serializer guards (rejecting `@computed_field` / `@model_serializer` + / `@field_serializer` / `Annotated` serializers / `SerializeAsAny`), but an + ordinary Python override of `model_dump` / `model_dump_json` / `__iter__` — + or `public_dump` / `reveal_dump` themselves — never enters the decorator + registry or the annotations, so no decorator or annotation scan can + see it. A plugin adding + `dumped["authorization"] = "Bearer " + self.api_key.get_secret_value()` in a + `model_dump` override leaked the plaintext through `public_dump` under a key + the by-name mask never checks (confirmed across all five entry points, + including a mixin-carried override). These methods are now refused at class + definition (statically over the whole MRO, so a mixin/intermediate-base + override is caught too), AND `public_dump` / `reveal_dump` call the base + implementations unbound at runtime (`BaseModel.model_dump(self, …)`; + `reveal_dump` reads declared fields from instance state, not `dict(self)`), + so a bypass of the definition-time gate still cannot dispatch to a plugin + override — defense in depth. +- **Wire-visible JSON slots enforce a string key domain at construction** + (spec §TR.1 + `contract.results.require_json_string_keys`). Every `extra` + mapping (`Word` / `Segment` / `TranscriptionResult` / `TranscriptionEvent`) + and `Diagnostic.provided` / `effective` now rejects any non-string object + key — at every nesting depth — instead of letting pydantic's lax + `dict[str, JsonValue]` silently coerce a `bytes` key to `str`. That + coercion admitted keys no JSON document can express (a Python-only key), + and `{"x": 1, b"x": 2}` COLLAPSED to a single `"x"` — two distinct keys + silently becoming one, a silent wrong result on a wire-visible slot and a + break of the Python/JSON two-layer isomorphism (G.5.2). An exact `str` is + required (a `str` subclass is refused too: a hostile `__eq__`/`__hash__` + could keep two keys that both serialize to `"x"` distinct in the input + mapping, reintroducing the wire collision). A wire document's object keys + are always strings, so the rule only ever fires for a Python caller reaching + past the key domain; the fixed `standard_asr_json_object_key` error carries + no input echo. (Capability nodes enforce the same rule through their extras + validator — see the capability key-domain entry.) +- **Type names in protocol/compliance error surfaces are canonicalized** + (`runtime.protocol_boundary`). `safe_type_name` / `_qualified_type_name` + read a type's `__name__` / `__module__` / `__qualname__` through the + interpreter's own getset descriptors (past a metaclass hijack) and require + an exact-`str` identifier within a length bound; a non-conforming name + (a newline from `type("A\nB", (), {})`, control characters, payload text) + renders as a fixed `` placeholder instead of forging a + second log/report record on the `EngineContractError` surface. A new + `safe_class_name` names a class directly, and the compliance suite's + remaining direct type-name reads route through both. +- **The delivered event stream reduces to `session.result()` — the write + side is transactional, and delivery preserves declarations** (spec §6.4 + + `StreamReducer` + `TranscriptionSession`). Four repairs close every found + divergence between the delivered stream and its reduction: (1) the + reducer commits the sticky `detected_language` only on ADMITTED events — + a suppressed supersede/final/partial no longer rewrites the result's + language while its content is refused (the same admitted-only commit + discipline the guard applies to its audio cursor; the retired-partial + case now carries the guard's `lifecycle_after_terminal` diagnostic); (2) + the producer commits to the buffer FIRST and reduces the CANONICAL + committed event, so a buffer-overflow refusal refuses the event + *entirely* — the old reduce-then-buffer order left an event the consumer + could never see inside `result()`; (3) a pending partial that is the + consumer's ONLY mention of its segment is its reading-order DECLARATION + and is delivered ahead of the invalidating final/supersede instead of + dropped — dropping it silently reordered the delivered transcript + relative to `result()` for untimestamped streams and starved a delivered + supersede of its `old_ids` declaration (suppressed consumer-side, spliced + session-side: textual drift); (4) the terminal event is stamped with the + session's reduced `detected_language` when it carries none — sticky + language is order-sensitive while coalescing is not, so no per-event + carry-forward converges in every interleaving, and the terminal is the + one event always delivered last. A backwards event span (`end < start`) + is now rejected at event construction (completing the `Segment` mirror + the model already claimed), which is what makes the reducer total on + admitted events and ordering (2) sound. +- **`EngineBase.transcribe_async` enforces the sync-call boundary** on the + `transcribe` it consumes: an `async def` override (or a sync override + returning a coroutine or wrong-typed value) now raises + `EngineContractError` with the stray coroutine closed, instead of handing + the caller a coroutine object as the "result". +- **`BaseConfig`'s input domain is closed to mappings.** + `model_validate(obj, from_attributes=True)` extracted raw attribute + strings behind the whitespace-preserving secret wrap and silently trimmed + a padded credential; it now fails loudly + (`standard_asr_config_mapping_required`). `model_validate_json` — which + pydantic's native JSON pipeline broke for every secret-carrying document + (the wrap's `SecretStr` instance is not valid JSON-mode input) — now + validates the parsed document in python mode, preserving credential + whitespace end-to-end with pinned grammar parity. +- **`supersede` places its replacements IN PLACE, on a reading-order + ledger** (spec §5.2 + `reduce_event` + `StreamReducer` + + `_LifecycleGuard`). The spec's ordering rule for untimestamped segments + is "list order IS reading order", but both canonical reduces appended + replacements at the end: `final(a,"hi") final(b,"world") + supersede([a],[a2]) final(a2,"HI")` reduced to `"world HI"` — a + syntactically valid, silently word-reordered transcript (the cardinal + sin), and the dict-shaped core reduce could not even express placement. + The spec now pins the missing semantics: `old_ids` MUST form a + contiguous block of the LIVE reading order, in matching order (the + invariant the frozen-prefix concatenation rule already presumed — the + retired texts are adjacent), and `new_ids` take over the block's + position in place; every id claims its position at first declaration + (`partial`/`final`, or introduction via a supersede's `new_ids`, so a + chained `A→B`,`B→C` with a contentless `B` still anchors correctly). A + gapped or reordered block has no defined placement and is SUPPRESSED + with the new `supersede_noncontiguous_old_ids` diagnostic (strict + raises) under the same suppressed-supersede semantics as the + cross-speaker ban. `reduce_event` is reshaped to the spec snippet's + `(order, texts)` state and fails loudly on order-impossible shapes; + `StreamReducer` orders by the shared ledger, tracks retired ids (no + resurrection), and mirrors the guard's order-integrity rejections as + result diagnostics for standalone (guardless) use; compliance replay + inherits the placement check through the shared guard. Golden + wire-shaped traces drive both reduces to identical results. +- **An empty reduced result carries `segments=[]`, never `null`** + (`StreamReducer.result()` / `session.result()`). The spec's null rule + separates `None` ("not requested / not applicable" — renderers may + synthesize the whole-text fallback cue) from `[]` + ("requested-and-performed but empty" — zero cues, never fabricated); + the reducer performs the segment lifecycle, so a fresh reducer, a + silence-only session, and a delete-everything supersede are the second + state. The batch side's `None` ("segments not requested") is untouched. +- **A config's input surface must stay closed (`extra="forbid"`).** The + same contract from the key side: a subclass reopening `extra` breaks the + flat input-key vocabulary, the absent-vs-invalid classifier, the + typo-names-the-key DX, and `public_dump`'s mask at once — `allow` stores + undeclared caller data (a misplaced credential included) on the instance + and emits it verbatim, where a by-name mask that knows only declared + fields cannot reach it, while `ignore` silently swallows a mistyped + credential key so it reads as an absent credential instead of a loud + error. Both are refused at class definition, and a nested submodel with + `extra="allow"` — which carries undeclared keys into the parent's dump + just the same — is refused by the schema proof. The three + decorator checks remain as a fast, precisely-worded front end; the proof + is the backstop. `Field(exclude=True)` is refused for the round-trip half + of the same contract: a dump documented for persistence must not silently + drop a declared input. Derived values belong on a plain `@property` or in + the engine. +- **The input surface is closed at EVERY depth, not only at the root** + (`BaseConfig`). The previous rule stopped one level down: a nested + options submodel left on pydantic's default (`extra="ignore"` — and the + same silent-drop default for `TypedDict` and dataclasses) accepted a + typo'd nested key and dropped it with no diagnostic, so + `{"decode": {"baem": 8}}` read as applied while the engine ran on the + field's default — the silent wrong result the top-level rule exists to + prevent, one level down, and the same hole swallowed a credential key + misplaced into a submodel. A config class is now refused at definition + unless every nested input container its core schema reaches (submodel, + `TypedDict`, pydantic dataclass — through containers, unions, and depth + alike) forbids undeclared keys. The check reads the EFFECTIVE policy + from the schema artifact (`config.extra_fields_behavior`, the same key + on the 2.5 floor and current pydantic), so pydantic's config-propagation + rules are honored rather than re-derived: a bare `TypedDict` and a bare + stdlib dataclass inherit the config's `extra="forbid"` and stay closed + for free (pinned by test), while a pydantic dataclass owns its config + and must declare `config=ConfigDict(extra="forbid")` itself. A policy + the walk cannot read as `forbid` — including a future pydantic that + stops emitting the key — fails closed at class definition. +- **The env codec is read off the core schema, not an annotation-origin + whitelist** (`BaseConfig.from_env`). The whitelist matched `list`/`dict`/ + `set`/`tuple`/`frozenset` origins and bare `BaseModel` subclasses — and + missed `Mapping[str, int]`, `Sequence[str]`, a `TypedDict` and a + dataclass, all of which pass every class-definition guard (they are + ordinary, fully-declared config shapes) yet were unreachable through + their own documented `STANDARD_ASR___` convention: the + bare string hit the container validator and raised. Widening the list + would only postpone the next miss, so the classification now walks the + field's schema and stops at the first structured kind. The bias is + deliberate and asymmetric: guessing "raw" for something structured fails + LOUDLY at construction, while guessing "json" for a scalar silently + reinterprets it (`"123"` → the integer 123), so "json" is returned only + on positive evidence and anything unrecognized stays raw. A field + reaching BOTH (`str | list[str]`) has no defined reading — either choice + disagrees with the explicit constructor, which always takes the string — + and is now refused at class definition instead of silently decoded when + some env var happens to be set. +- **Every wire-visible slot is declared in the JSON value space** + (`Diagnostic.provided`/`effective`, `Word`/`Segment`/`TranscriptionResult`/ + `TranscriptionEvent` `extra`, `emit_diagnostic`). Declared `Any`, these + admitted Python objects with **no JSON representation at all** while the + server spec simultaneously promised to forward diagnostics and non-error + `extra` verbatim — two promises that cannot both hold. A `socket`, a numpy + array, or a bare `object()` parked in `extra` constructed happily and then + failed during the wire projection, *after* an endpoint had committed to a + response. That is not two-layer isomorphism (G5.2), and the hidden + "JSON-safe Any" precondition appeared in no type, validator, or document. + The slots are now `JsonValue`, so the failure lands where an engine author + can act on it: at construction, naming the field. Non-finite floats are + excluded for the same reason (`NaN`/`Infinity` are Python floats but not + JSON — a conforming parser rejects the whole document). Three concrete + failures close with it: an `error` event now **drops `extra` before** + serializing rather than overwriting it after (the old order made the + payload's fate depend on the very field being discarded, so an + unserializable `extra` cost the client `code`, `recoverable`, + `retriable_after` and the gap/reconnect fields); the WS initial + diagnostics frame is projected inside its own fault boundary (it ran + between two boundaries — establishment had returned, the forward loop's + catch had not started — so it killed the route unhandled, bypassing the + operator-log redaction); and the REST response's projection is completed + inside `_run_transcription`'s fault-mapping region rather than left to the + ASGI encoder after the endpoint returned. `to_json_value` is the one + helper for handing a structured value or a typed container to such a slot. +- **The metadata endpoints wrap the whole operation, not just class + resolution** (`/v1/capabilities`, `/v1/params-schema`, + `/v1/config-schema`). Resolution was mapped (404 / scrubbed 500) but + everything after it ran outside any boundary — and all of it is + third-party code: reading `declared_capabilities` / + `provider_params_type` / `config_type` dispatches whatever descriptor or + metaclass property the plugin installed, `canonical_json()` and + `model_json_schema()` are the plugin's methods, and a custom + `__get_pydantic_json_schema__` runs inside the latter. A raise anywhere + in that stretch left the endpoint through Starlette's unhandled path: + the client got an undocumented plain 500 instead of the documented + scrubbed body, and `log_exception_safely` never ran — so the ASGI + server's own traceback logger rendered the chain natively and a + `ValidationError` in it printed its input echo into the operator log. + These are unauthenticated discovery surfaces, so "a plugin descriptor + does not usually fail" was never a boundary. One shared helper now spans + resolution → descriptor read → projection, passing the endpoints' own + deliberate verdicts (the "no capabilities declared" 404) through + unchanged and mapping every other `Exception` to a safe-logged scrubbed + 500. The JSON projection is finished inside the boundary too (encoded + eagerly, non-finite numbers rejected — they are not JSON), so an + unencodable payload is a scrubbed 500 rather than a crash after the + endpoint returned. +- **One model's fault can no longer deny every other model its verdict** + (`compliance run`). The command's whole point is a verdict for the + installed plugin set in one invocation (G2.1), but the construction arm + caught a NAMED type list (`DiscoveryError`, `FactoryLoadError`, + `ValueError`) while `registry.create` wraps only a construction-time + `ValidationError`. A factory's own `RuntimeError`/`TypeError`/`OSError` + — an SDK failing to initialize, a missing native library, an unreadable + model directory — therefore escaped `for name in names` and aborted the + run: every LATER model produced no report at all, and + `check_entrypoints`'s earlier isolated call could not compensate + (`_run_instance_checks` constructs a second, independent time). The arm + now catches `Exception`, and a second per-model envelope wraps + `_run_instance_checks` itself so even a check implementation crashing on + an unanticipated shape stays one model's verdict, reported under its own + `compliance_check_crashed` code so it remains distinguishable from a + plugin's `engine_construction_failed`. `BaseException` is deliberately + not caught: `KeyboardInterrupt` and `SystemExit` are the operator's + control flow, not a plugin verdict. +- **A secret carrier requires the secret marker, top-level and nested** + (`BaseConfig`). An UNMARKED `SecretStr`/`SecretBytes` field is + half-protected: pydantic masks its dumps, but the whitespace-preserving + pre-validator skips it — its raw string input is silently stripped by + `str_strip_whitespace`, the exact credential rewrite the pipeline + forbids — and the schema never renders it as a password/write-only + input. A carrier annotation without `secret_field(...)` is now a + definition-time `TypeError`; the nested-submodel rejection widens from + marked secrets to bare carriers (a nested carrier is additionally + unreachable by `reveal_dump` and unwrappable by the submodel's own + hooks). + +- **The sync-call boundary is total: plugin cleanup cannot displace the + verdict.** Closing a stray coroutine throws `GeneratorExit` into a + SUSPENDED coroutine, running the author's `finally` blocks on the calling + thread — and an escaping cleanup error replaced the `EngineContractError` + the boundary exists to produce, handing fault ownership back to whatever + caught it next. The diagnosis is now formed before any cleanup runs, + cleanup is contained at `BaseException`, and its failure is reported in + the clause with fixed text (never the cleanup exception's own message). + `safe_type_name` joins the module as the shared, total namer for the + consumers that report a protocol violation. +- **The sync-call boundary's CLASSIFICATION is total too, and now + returns a structured verdict.** `inspect.isawaitable`/`isinstance` + read the value's type metadata, and a hostile metaclass `__mro__` + property — or an ordinary broken `__class__` property — made the read + raise OUTSIDE containment: the raw plugin exception escaped every + consumer in place of the stable verdict. `sync_result_defect` now + returns a `SyncResultDefect` (kind `awaitable` / `wrong_type` / + `unclassifiable`; `str()` of it is the clause, so embedding messages + are unchanged) and every metadata read is contained — an + unclassifiable value IS a defect (fail-closed), surfaced by compliance + as the new `protocol_member_unclassifiable_result` code. Consumers + pick their surface from the verdict instead of re-inspecting the + value: the CLI sync-bridge no longer re-runs `inspect.isawaitable` or + reads `type(value).__name__`, and the CLI error path's last-resort + rendering uses the shared `safe_type_name` instead of a raw + `type(exc).__qualname__` (a hostile metaclass made error reporting + itself crash). A bare-name collision whose qualification reads fail + now renders `(module/qualname unreadable)` rather than the + self-contradictory "returned bool, not bool". +- **The sync boundary starts at the `EngineBase` author hooks.** The + template dereferences `_transcribe()`'s result and `_start_transcription()`'s + session before either public method returns, so an `async def` hook + surfaced as a secondary `AttributeError` on a coroutine (plus a + never-awaited warning) before any consumer's boundary could classify it. + A synchronous public `transcribe()` says nothing about the hook it + delegates to, so no surface-level modality check could see this. +- **The config input-key vocabulary is validation-only.** `_flat_input_keys` + included `field.alias` unconditionally, but pydantic treats `alias` as + bidirectional only until a `validation_alias` overrides it — after which + `alias` is serialization-only and REJECTED on input. All three consumers + ask "can this key populate this field?", so a key that cannot was wrong + for each; the visible damage was the definition-time collision guard + rejecting a legal declaration whose serialization alias merely spelled + another field's name. + +### Fixed + +- **Security: a WS error event's detail is never repr'd into the operator + log without shape vetting** (`toolchain.server`). The bridge logged + `extra["detail"]` with `%r` *before* the client-side scrub — and a value + installed past the `JsonValue` declaration (a mutated `extra` dict is + plain Python; `frozen` guards the field, not the dict) reaches exactly + that line: a held `ValidationError`'s `repr` echoes its input into the + log the scrub exists to close. The bridge now logs only exact-`str`/ + `None` values (which dispatch no author code — `type is str`, never + `isinstance`) and withholds everything else by shape, with a marker + naming why. +- **Security: the metadata fault boundary starts at the model key, not at + the resolved class** (`toolchain.server`).`_ensure_engine_class`'s + `_is_protocol`/`transcribe` reads are metaclass/descriptor dispatch, and + a failure there (e.g. a metaclass property carrying a `ValidationError`) + escaped *before* the Round-15 boundary ever began — producing an + undocumented plain-text 500 instead of the documented scrubbed body, and + the ASGI server's native traceback logger rendering the raw chain + (input echo included) because `log_exception_safely` never ran. + Resolution is now inside the boundary try on `/v1/capabilities`, + `/v1/params-schema`, and `/v1/config-schema`. +- **The env codec reads `Json[T]` as terminal-raw** (`BaseConfig.from_env`, + spec IC.4). `Json[list[str]]`'s contract is that the input IS the JSON + document text — pydantic's own `json` validator decodes it, and the + explicit constructor REJECTS the decoded value (`json_type`). The codec + descended into the annotation's inner document schema instead, saw its + `list`, and pre-decoded the env string — so the field passed every + class-definition guard and the constructor, yet failed construction from + its own documented env convention (the decoded list hitting the `json` + validator). The `json` core-schema kind now terminates the walk as + terminal-raw, matching the constructor's own semantics on the 2.5 floor + and current pydantic alike; `json-or-python` keeps descending (its two + halves genuinely describe Python-side input). +- **Capability extension extras are closed to the JSON value space at + construction** (`contract.capabilities`). Capability nodes parse unknown + keys tolerantly (`extra="allow"`) for forward compatibility — but the + VALUES were untyped, so any Python object (`object()`, `Path`, NaN/Inf) + constructed happily through Python-land and only failed the wire + projection at the metadata endpoint: a state the in-process tree accepts + but the wire document cannot carry, breaking G.5.2's two-layer promise + exactly where the wire-visible slots entry closed it for + results/events/diagnostics. Every extra value is now validated into + `dict[str, JsonValue]` (with `allow_inf_nan=False`) by a before-validator + shared by `_CapNode`, `_Container`, and the constraint submodels — the + canonicalized output is stored, so the tree and any later wire + `model_validate` agree. The mechanism is a validator plus one module-level + adapter rather than the `__pydantic_extra__` typed annotation because the + native mechanism cannot express the floor contract: pydantic 2.5 builds + typed extras only from an EAGER annotation and the module (project-wide) + annotates lazily via `from __future__ import annotations`, which the + floor rejects at class creation; the adapter was profiled to + accept/reject identically on the floor and current pydantic. The + tolerant-KEYS behavior is unchanged for JSON-string keys: unknown keys + whose values are JSON still parse, and non-extension keys still answer + fail-closed on probes (the key domain itself is pinned by the next entry). +- **Capability extension extras enforce the same string key domain as the + wire slots** (`contract.capabilities` + spec R2). The extras value adapter + above validates through pydantic's lax `dict[str, JsonValue]`, which + DECODES a `bytes` key into its `str` spelling — and the canonicalizing + merge then re-homed the laundered key: `{b"supported": True}` silently + overrode a declared `supported=False` (in either insertion order: the + bytes key never equals the declared str key at bucketing time, yet + collides with it after coercion), and `b"x_vendor"` minted a canonical + extension key the input never spelled — silent capability corruption on + the exact surface gating decisions read. Extras now run + `require_json_string_keys` (the same walker, rule, and + `standard_asr_json_object_key` error as the results-layer wire slots) + BEFORE the value adapter: every extra key must be an exact `str` at every + depth, so no key can change spelling across canonicalization, and no + order-sensitive collision with a declared field is possible. "Tolerant + keys" tolerates UNKNOWN keys, not un-JSON ones. +- **The WS diagnostics delta sees an in-place overflow summary.** Past the + channel cap the guard keeps a single aggregated `diagnostics_truncated` + entry and rewrites its per-code tally in place, so a length-only cursor + reported "nothing new" forever: the WS client kept the counts from the + first overflow while the in-process and REST views converged on the final + ones (a two-layer drift G.5.2 forbids). The summary is now a singleton on + the wire — a later occurrence supersedes the delivered one — and the + final tally always lands. +- **An empty segment is skipped by the subtitle renderers, not treated as + unrenderable.** Both renderers already skip a payload-less segment, so an + empty one produces no cue whether or not it was measured; judging + renderability first made the same segment silently skipped when it had + timestamps and a hard `SubtitleRenderingError` when it did not. Empty + segments are filtered before the policy runs and excluded from its counts. +- **Server docstring: `FactoryLoadError` maps to a scrubbed 500, not 404.** + The code was already right (a resolved key whose plugin fails to load is a + deployment fault); the docstring described the mapping this PR replaced. +- **`standard-asr show` reports a broken plugin as an engine fault.** It was + the one consumer that caught `FactoryLoadError`, printed + `Capabilities: `, and returned **0** — telling a script + the model was usable when nothing about the engine could be read. It now + prints the same diagnostics and exits **1**. The class-level + `declared_capabilities` read runs through the engine-fault seam as well + (it can be a metaclass property). +- **The `prepare` attribute LOOKUP runs inside the engine-fault seam.** The + round-8 envelope wrapped the call but not the binding, and `prepare` may + be a property whose body is plugin code — a descriptor raising + `ValueError` was reported as the invoker's usage error (exit 2) instead of + an engine fault (exit 1). + +- **Engine DECLARATION defects raise `EngineContractError`, not + `ConfigError`.** A malformed declared `selectable_languages` / + `detectable_languages` tag, a language axis without the IC.6 + `default_language` obligation, and an unsatisfiable `prepare` shape + (coroutine function / non-callable / parameter-requiring) are the engine + author's contract violations — no configuration value fixes them. + `ConfigError` now means what its name says: the supplied or ambient + configuration is invalid, fixable by whoever supplies it. Update + `except ConfigError` handlers that relied on catching declaration bugs. +- **CLI exit codes classify fault at the seam, not by exception class.** + A registered model whose plugin fails to import (`FactoryLoadError`), the + `ValueError` family escaping the engine execution seam (`transcribe()` / + `prepare()` — a bare SDK `ValueError`, a raw engine-internal + `ValidationError`, `InvalidProviderParamError`), and every + `EngineContractError` now exit **1** (engine/deployment fault, the CLI + twin of the server's scrubbed 500). Every `ConfigError` — including + `ConfigurationRequiredError` surfacing lazily at first transcribe — stays + exit **2**: at the CLI the invoker owns the flags AND the env, so invalid + configuration is caller-actionable there (`docs/spec/cli.md` states the + contract). + +- **`TranscriptionResult.metadata` removed.** The free-form "standardized + metadata" dict had no standardized keys, no writer, and no reader — the same + blanket-metadata channel the spec already removed from Properties and + Capabilities. Engine-specific data belongs in `extra`; future standardized + result data will land as named fields. A plugin still populating `metadata=` + now fails loudly: the engine template wraps the resulting `ValidationError` + as a `TranscriptionError` naming the plugin/core version mismatch (HTTP 5xx + through the server — never a client-blaming 422). +- **Strict-mode candidate-language rejections raise `UnsupportedFeatureError`** + (`param="candidate_languages"`, with `mode`) instead of a bare `ValueError`, + matching every other strict-gate rejection (spec §RT R2) so all transports + map it to a client-error verdict (REST 422 / WS `unsupported` / CLI exit 2). + Malformed or `"auto"` candidate entries still raise `ValueError` + unconditionally (caller code bugs). Update `except ValueError` handlers + written against the old contract. +- **CLI discovery flag renamed `--strict` → `--strict-discovery`** on `list` / + `show` / `prepare` / `transcribe` / `compliance`, and argparse prefix + abbreviation is disabled: bare `--strict` is now a loud usage error instead + of silently meaning discovery strictness while `strict` (the engine's + parameter-gating policy, `--set strict=...`) means something else on the + same command line. +- **`Segment.start`/`end` are nullable — the placeholder-marker design is + replaced.** `None` now means "the engine measured no such time" and is + stored verbatim by the streaming reducer (nothing fabricates `0.0` spans + anymore); the legal shapes are `(float, float)` (measured, `end >= start`), + `(float, None)` (start-only — the real onset survives on the model), and + `(None, None)` (unavailable); `(None, float)` is construction-rejected, on + `TranscriptionEvent` `partial`/`final` too (a previously silently-mangled + adapter shape). The derived read-only `Segment.timestamp_status` + (`"measured"` / `"start_only"` / `"unavailable"`) can never disagree with + the values. **Removed**: the reserved `SEGMENT_EXTRA_TIMESTAMP_PLACEHOLDER` + key, its construction validator, and its exports — timing truth lived in a + mutable side-channel dict that `frozen=True` never protected and the wire + spec never documented; `Segment.extra` is engine-owned again with no + reserved keys. The wire schema now renders `start`/`end` as number-or-null + (two-layer isomorphism: the schema is the documentation). The result-level + `segment_timestamps_unavailable` diagnostic remains as the aggregate + disclosure, derived from the values; the renderers no longer read it. +- **`to_srt`/`to_vtt` never silently drop text: missing timing is the + caller's decision.** The renderers previously omitted unmeasured segments' + text from the file by default — with the only disclosure living on the + result object, not in the returned string (a marker-only result had NO + disclosure at all). New keyword `on_unrenderable: "error" | "omit" | + "collapse"` (type `UnrenderablePolicy`), default `"error"`: any segment + that cannot render as a **visible** cue — no measured span, or a measured + span that quantizes to zero milliseconds on the output grid (players + silently drop a `T --> T` cue, so the old "render zero-length cues + faithfully" contract produced successful files whose text never appeared) + — raises the new `SubtitleRenderingError` (with `.unrenderable`/`.total`), + and the caller explicitly chooses `"omit"` (renderable cues only; possibly + zero) or `"collapse"` (one synthetic whole-text cue — the previous + all-placeholder behavior, now opt-in). The renderer never widens a span on + its own (no fabricated 1 ms). A result whose every span survives the grid + renders identically to before under every policy, and a stale diagnostic + can no longer collapse a real timeline (values are the only signal). Code + that rendered timestamp-less streaming results must now pass a policy. +- **`start_transcription` is unconditionally required by compliance.** The + `StandardASR` protocol has always pinned the member as present on every + engine (batch-only raises `UnsupportedFeatureError` from it); the compliance + surface check now enforces that instead of waiving it for batch-only + engines. A structural plugin that omitted the method must add it (raise + `UnsupportedFeatureError`); `EngineBase` subclasses are unaffected (the + template provides it). +- **Engine-side `ValidationError` is an engine fault on every transport.** A + bare pydantic `ValidationError` escaping `transcribe()` / + `start_transcription()` after the request's options were validated maps to a + scrubbed HTTP 500 (was: 422 blaming the client's `options`) and a scrubbed + WS `internal_error` frame (was: `unsupported` with `str(exc)` echoing + pydantic's `input_value`). Fault ownership no longer depends on whether the + engine inherits `EngineBase`. API clients matching on the old 422 for this + case must update. +- **`StandardASR.config` is a read-only protocol property.** Mutable protocol + members are invariant under strict typing, so a real plugin annotating its + own config subtype could not be typed as `StandardASR` without a cast — + defeating the protocol's no-cast promise. Reading `engine.config` is + unchanged; assigning it *through the protocol type* is no longer legal + (config is constructor-injected). +- **`ConfigurationRequiredError` narrows the compliance credential skip.** The + new `ConfigError` subtype means exactly "required configuration absent from + this environment"; `BaseConfig.from_env` raises it automatically when + construction fails solely on missing required fields. Compliance skips + ONLY that state — a plain `ConfigError` or a raw `ValidationError` from a + zero-arg factory is now a compliance **failure** (`factory_config_invalid` + / `engine_construction_failed`), not a "needs credentials" warning: waiving + every config failure let a broken plugin read as green-with-warning. An + engine that raises `ConfigError` by hand for its missing-credential state + must switch to `ConfigurationRequiredError` (engines using `from_env` need + no change). The absence classification is deliberately narrow: only + top-level `missing` failures on environment-fillable own fields qualify — + a subclass that forgot to pin its `engine` discriminator default (an + env-excluded field no environment variable can supply) or a + supplied-but-incomplete nested value stays a plain `ConfigError` and fails + compliance as the declaration bug it is. Aliased fields resolve correctly: + pydantic keys its errors by alias, so a missing + `Field(alias="xi-api-key")` credential is mapped back to its own-field + name (unique field-name / `alias` / string `validation_alias` match; + ambiguity stays fail-closed) and classified as absence — not rejected as + an unknown name, which would have re-created the env-dependent verdict. +- **Compliance verifies the batch-only refusal behaviorally.** For an engine + declaring no streaming axis, `check_entrypoints` now CALLS + `start_transcription()` (construct-not-enter envelope; a compliant engine + raises at the capability gate) and requires the protocol-pinned + `UnsupportedFeatureError`: returning a session + (`batch_only_streaming_not_refused`) or raising another type + (`batch_only_streaming_refusal_wrong_error`) now fails — method presence + alone certified engines that violate the promise callers rely on. +- **Synchronous protocol modality is enforced across the whole surface.** + Every `StandardASR` member except `transcribe_async` is synchronous (async + behavior lives in `transcribe_async` and inside the returned session). + Detection lives in one shared, public home — + `standard_asr.runtime.protocol_boundary.sync_result_defect` (a stray + coroutine is CLOSED rather than leaked as a never-awaited + `RuntimeWarning`; messages carry type names only, never the value) — with + a raising adapter `require_sync_result` that surfaces a violation as the + new `EngineContractError` (an engine fault, deliberately not a + `ValueError`). Every consumer call site guards its result: + - *compliance probes*: swap-safety's `transcribe()`, the streaming gating + check's every `supports()` query plus its wire-format synthesis, the + wire-format round-trip, the batch-only refusal probe, and the + sync-bridge classification probe AND its `session_factory()` + establishment boundary (`sync_bridge_invalid_session`); + - *CLI*: the capability pre-gate and bridge setup, `transcribe` (a + coroutine/wrong-typed result is an engine fault, exit 1, instead of a + secondary `AttributeError`), and `prepare` (a sync wrapper delegating to + an `async def` used to print a false "prepare complete" — the returned + value must now be strictly `None`); + - *server*: the REST `transcribe` path (boundary check AND + `TranscribeResponse` construction now live inside the fault-mapping + region, so a malformed engine result maps to the scrubbed 500 instead of + a raw `ValidationError` escaping the route) and the WebSocket + establishment path (a non-session return maps to the scrubbed + `internal_error` frame instead of an `AttributeError` after the + error-mapping block). +- **`supports()` MUST return a real `bool` — truthiness no longer negotiates.** + Every capability consumer used to coerce `supports()` by truthiness, so a + non-compliant `return "false"` read as "everything supported" — a silent + wrong capability negotiation. Compliance now verifies the return type at + the entrypoint layer and at every gating/bridge query + (`protocol_member_wrong_return_type`; strict `isinstance` — a + `numpy.bool_` is not a `bool`), and the CLI's pre-gate counts only a + literal `True` as supported (fail-closed on any malformed answer). + +- **Non-string validation aliases are rejected at config-class definition.** + `BaseConfig`'s absent-vs-invalid classifier (the machinery behind + `ConfigurationRequiredError`'s compliance *skip*) resolves every pydantic + error `loc` to a single string token, and the env convention (IC.4) is + flat — but a field declared with `AliasPath` reports a nested `loc` when + it is simply ABSENT, so a pure missing-credential state was misclassified + as a plugin defect (an environment-dependent compliance verdict: fail on a + clean CI, pass on a credentialed machine). `__pydantic_init_subclass__` + now rejects `AliasPath` (and any `AliasChoices` carrying one) loudly at + class definition with the flat-mapping rationale; all-string + `AliasChoices` is now genuinely supported (each choice resolves like a + string alias, so pure absence classifies as `ConfigurationRequiredError`). +- **`doctor` never convicts on an approximation: satisfiability is + three-state.** The emptiness probe collapsed "no witness found" into + "unsatisfiable" — a logic error a finite search cannot back (and its + release-derived candidates dropped the edge's epoch, so every epoch range + over final releases, even the trivially satisfiable `>1!1.0`, was branded + an internally-unsatisfiable hard conflict with a non-zero exit; on + `packaging <= 25.0` an `===` specifier crashed the unguarded membership + loop outright). Verdicts are now SAT / UNSAT / UNKNOWN: UNSAT comes only + from `packaging`'s exact algebra (`SpecifierSet.is_unsatisfiable()`, + `>= 26.2`) and is reported as an absolute verdict; the fallback witness + search (older `packaging`) proves SAT — with epoch-preserving candidates — + or answers UNKNOWN, which feeds the existing `analysis_unavailable` + non-clean state with one note naming every undecided relation and the + upgrade path. Honesty trade-off on legacy `packaging`: doctor no longer + *convicts* range conflicts it cannot prove there (including the 1.x/2.x + split), it discloses them as undecidable — still a non-zero exit, never a + false clean. `packaging` remains optional, not a core dependency. +- **Server construction faults are never the caller's: 422 → 503/500.** + Engine construction is `registry.create(model)` — zero-arg; the client + chooses the model key and nothing else, so a construction failure is never + a request error. `ConfigurationRequiredError` (required config absent from + the server environment — the state compliance SKIPS) now maps to a **503** + (REST) / `service_unavailable` frame (WS) with a stable generic detail — + the absent field names are deployment detail, safe-logged for the operator, + never sent. Every other construction failure (plain `ConfigError`, + `InvalidProviderParamError`, a raw `ValidationError` — the state compliance + FAILS as `engine_construction_failed`) maps to the scrubbed **500** (REST) + / `internal_error` frame (WS). The old 422/`bad_request` mapping blamed the + caller for faults it cannot see, reach, or fix — and surfaced server-side + config field names to unauthenticated clients. +- **Pydantic input echoes no longer reach operator/CI logs.** The redaction + boundary scrubbed every CLIENT-facing surface but the server's + `logger.exception` calls still wrote raw `ValidationError`s — `input_value=` + echo included, directly or via the `__cause__` chain of the standard + layer's `raise TranscriptionError(...) from exc` wrap — into server logs, + and the compliance suite embedded `repr(exc)` / `str(exc)` of raw + validation errors into issue messages printed to terminals and CI logs. + New `standard_asr.runtime.redaction.log_exception_safely` (used at every + server exception-log site) logs the scrubbed one-line chain summary when + the active chain carries a `ValidationError` — fault structure and stack + context survive, the echoed input never does; generic exception text + still reaches the operator log unchanged (the spec's deliberate channel, + stated normatively in server.md §3.7 and IC.3). The compliance messages + (`factory_config_invalid`, `properties_revalidation_failed`, + `language_config_invalid`) use `sanitized_validation_message` for the + same reason. +- **The CLI's normal error line goes through the same safe boundary.** Every + `main()` catch arm previously printed bare `str(exc)` before any `--debug` + logic ran, so a wrapper that copied a chained `ValidationError`'s + (truncated) input echo into its own message leaked it to stderr with no + flags at all. All arms now report via one helper: untainted chains keep + their authored message, tainted chains render the input-echo-free summary. + Discovery's entry-point `load()` wrapper builds its message with + `safe_exception_summary` for the same reason (plugin module code can raise + a `ValidationError` whose `repr` echoes input). +- **WS establishment maps a bare `ValueError` to `internal_error`, not + `unsupported`.** By establishment every client input is already validated + and a compliant engine signals unsupported features with + `UnsupportedFeatureError`, so a surviving bare `ValueError` is an + engine/adapter fault: the old arm blamed the caller and sent `str(exc)` — + engine-internal, possibly credential-bearing text — to an unauthenticated + client (REST's fault-ownership rule, now mirrored; spec §4.2 updated). +- **Secret fields resolve to exactly one carrier, and defaults are vetted.** + The definition-time guard accepted plaintext unions (`SecretStr | int` + left a constructed `int` unmasked in `repr`/`model_dump` while the schema + advertised a password) and dual carriers (`SecretStr | SecretBytes`); + `_secret_carrier` now requires exactly `SecretStr`/`SecretBytes`, + optionally with `None`. Defaults are vetted at class definition (pydantic + never validates them): a plain-string default leaked plaintext and crashed + `model_dump_json`/`public_dump` inside the secret serializer; + `default_factory` is rejected as unvettable. The whitespace-preserving + pre-validator wraps raw strings into the field's *own* carrier — wrapping + into `SecretStr` unconditionally made every env/alias string construction + of a `SecretBytes` field fail where plain pydantic would have coerced it. +- **`from_env`'s explicit-wins is alias-aware.** An explicit value supplied + under a field's alias / `validation_alias` / `AliasChoices` choice now + suppresses that field's canonical env fallback; the old blind merge kept + both keys and `extra="forbid"` loudly rejected the env key as extra where + the documented contract says the explicit value wins (IC.4 updated). + Passing two explicit keys for one field is still loudly rejected. +- **Synthetic-cue visibility is decided on the output millisecond grid.** A + sub-millisecond `duration` (e.g. `0.0005`) passed the raw `> 0` float + check yet formatted to `00:00:00,000 --> 00:00:00,000` — the invisible cue + players silently drop. The fallback now fires whenever the duration + quantizes to zero on the same `int(round(s * 1000))` grid the timestamp + formatter renders. (Generalized to EVERY cue by the renderability + redesign above: `on_unrenderable` covers measured spans that quantize to + zero too.) +- **Compliance renders every embedded exception through the total boundary.** + The remaining raw-`repr` sites (factory classification, sync-bridge + establishment, strict discovery, class metadata) now use + `safe_exception_summary`, and the bridge worker stores the exception + OBJECT instead of freezing `repr(exc)` in-thread — a hostile `__repr__` + previously crashed the worker before the error was recorded and the main + thread mis-reported the crash as `sync_bridge_no_terminal`. +- **The WS config handshake is a closed request model.** + `StreamConfigRequest` (frozen, `extra="forbid"`) replaces the ad-hoc + parse: an unknown top-level key (a client typo like `"optinos"`) used to + vanish silently and the session started on defaults; it is now a loud + `bad_request` naming the key. The handshake catch-all no longer maps every + exception to `bad_request` with raw `str(exc)`: only caller-fixable + failures do; anything else is a scrubbed, safe-logged `internal_error`. +- **Bytes-input credentials keep exact contents; flat input keys have one + owner.** pydantic's lax `bytes -> str` coercion for a `SecretStr` field + ran the decoded text through `str_strip_whitespace` (silently trimming a + padded credential); the pre-validator now wraps bytes/bytearray per + carrier (UTF-8 for `SecretStr`, loud rejection for non-UTF-8; verbatim for + `SecretBytes`). A field's alias colliding with another field's name or + alias let ONE caller key silently populate TWO settings + (`populate_by_name` fills both); every flat input key now has exactly one + owning field, enforced at class definition. The pre-validator also + covers **every `Mapping` input** (`MappingProxyType` and other read-only + mappings previously bypassed the wrap and were silently stripped). +- **A rejected unknown key is masked in `loc` when shaped like key + material.** `extra_forbidden`'s last `loc` component is the caller's own + key text (the one structural path by which request input enters a + `loc`), and FastAPI returns `loc` in the 422 body. A field-name-shaped + key (identifier-ish, ≤ 32 chars) stays named — pointing at a typo'd + `optinos` or a mis-placed `api_key` is deliberate DX — while a long or + non-identifier-shaped key (the structural signature of pasted credential + material) becomes `[redacted-key]`. +- **A registered model whose plugin fails to load is an engine fault, not + an unknown model.** `FactoryLoadError` (the key resolved; a + server-installed plugin failed to import/resolve/validate) mapped to + 404 / WS `unknown_model` with raw plugin-fault text — blaming the caller + for a fault it cannot fix and crossing the trust boundary with + import/annotation internals. All resolution surfaces (REST construction, + WS, capabilities/params-schema/config-schema) now split the arms: + `EntrypointValidationError` keeps its authored 404; `FactoryLoadError` + maps to a scrubbed, safe-logged 500 / `internal_error`. +- **The CLI treats a raw `ValidationError` as an engine fault (exit 1).** + Caller-originating pydantic failures are all classified upstream, so a raw + one at the top level is a structural engine constructing an invalid + internal model — the seam the server maps to a scrubbed 500. It was + reported as usage exit 2 with a caller-audience rendering, which also + split the trust boundary (the normal line used caller policy while + `--debug` used the operator policy for the same error). +- **Every caller-authored `loc` component is filtered by shape.** + `extra_forbidden` is not the only channel by which request text enters a + `loc`: a `dict[str, T]` field contributes the caller's mapping KEY, and a + non-string key contributes its `repr`. Both reached `ConfigError.details`, + operator logs, and compliance output. Every string component is now + filtered on every surface: field-name-shaped tokens and pydantic's + `[key]` marker survive (naming a rejected key is deliberate typo DX — the + sender already holds any key they placed in their own request), pasted + key material becomes `[redacted-key]`. +- **The `supports()` classification probe contains `BaseException`.** It runs + inside the establish worker's `except UnsupportedFeatureError` block, where + Python does not route a raised exception to that try's sibling + `BaseException` arm — the worker died unclassified and the run reported + `sync_bridge_did_not_terminate` for what was really a broken `supports()`. +- **The sync-bridge drive worker contains `BaseException`.** A + `CancelledError` (re-raised by `future.result()` in the drive thread) + killed the daemon worker without recording the error, and the main + thread mis-read the silent death as `sync_bridge_no_terminal` — a false + verdict about the wrong defect. The drive worker now matches + `_establish`'s deliberate `BaseException` containment. +- `check_streaming_param_gating` no longer false-fails a protocol-complete + structural engine: the sub-constraint probe reads the non-protocol + `effective_capabilities` defensively and falls back to the protocol's + `declared_capabilities` (what `EngineBase` defaults to) instead of turning + the missing convenience attribute into `gating_probe_selection_raised`. + `check_provider_params_swap_safety` / `check_streaming_param_gating` are + now typed against `StandardASR` (no `EngineBase` casts in the CLI). + +- `check_recommended_wire_format` no longer requires `EngineBase`: the + session-establishment format rule is the pure + `ensure_wire_format_supported(properties, format)` shared with + `EngineBase.ensure_stream_format_supported`, so a fully-compliant + structural engine is no longer false-failed on an `AttributeError`. +- `compliance run` gives a credentialed engine one verdict: a zero-arg + factory raising `ConfigurationRequiredError` (required configuration + absent from the environment) is the same skipped-not-failed state + `compliance entrypoints` already reports, instead of a contradictory + per-model error in the same command. Any other `ConfigError` remains a + failure (see the breaking `factory_config_invalid` entry above). +- `standard-asr doctor` no longer reports satisfiable pre/dev/post version + windows (e.g. `>2.0rc1,<2.0rc3`) as hard conflicts: the emptiness probe now + derives within-segment neighbor and edge-`.dev0` witnesses. + ## [0.1.1] - 2026-06-16 Initial public release: a universal, plug-and-play interface protocol for ASR diff --git a/README.md b/README.md index d47b4f25..f7ead020 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,8 @@ _Apps integrate speech-to-text once and gain every engine. Engines implement onc > [!WARNING] > **Alpha — the core protocol works, but major pieces are still missing.** The standard -> interface is functional and validated by real engine plugins, but features like +> interface is functional and exercised by real engine plugins (interface-level +> compliance; end-to-end runtime verification is still being built), but features like > hardware metadata and model cards are not yet part of the protocol. > Developer documentation and tooling are also incomplete. Breaking changes will happen. > For production use, wait for a stable release. We follow semantic versioning. @@ -93,9 +94,12 @@ the people who know each engine best, and the core never becomes the bottleneck. - **Audio negotiation, batteries included.** Hand over what you have — a file path, raw bytes, a NumPy array, a URL — and the framework negotiates and converts to whatever form the engine accepts, loudly reporting anything lossy. No more sample-rate guesswork. -- **No dependency hell, no licensing traps.** Each engine is an isolated, pip-installable - plugin, so conflicting dependencies and restrictive licenses stay contained in the - packages that carry them. +- **No dependency hell, no licensing traps.** Each engine is its own pip-installable + plugin, so restrictive licenses and heavy dependencies stay in the packages that carry + them. Hard dependency conflicts (e.g. numpy 1.x vs 2.x) cannot share one environment — + `standard-asr doctor` surfaces them instead of letting them hide. Process isolation is + the escape hatch for plugin-vs-plugin conflicts; a plugin incompatible with the core's + own numpy floor cannot run anywhere, and doctor reports that as its own conflict. - **The choice goes to the user.** End users — especially for under-served languages and domains — install the engine that serves them best and use it immediately, without waiting for the app author to add support. @@ -183,19 +187,26 @@ audio_format = engine.recommended_wire_format() async with engine.start_transcription(audio_format=audio_format) as session: session.feed(microphone()) # any (async) iterable of PCM byte chunks - segments: dict[str, str] = {} + order: list[str] = [] # reading order of live segment ids + texts: dict[str, str] = {} async for event in session: if event.type in ("partial", "final"): - segments[event.segment_id] = event.text # partial: may change; final: settled - elif event.type == "supersede": - for old_id in event.old_ids: # engine re-segmented (e.g. two-pass - del segments[old_id] # rescoring); replacements follow - render(segments) + if event.segment_id not in order: + order.append(event.segment_id) # first mention claims a position + texts[event.segment_id] = event.text # partial: may change; final: settled + elif event.type == "supersede": # engine re-segmented (two-pass rescoring) + pos = order.index(event.old_ids[0]) + for old_id in event.old_ids: + order.remove(old_id) + texts.pop(old_id, None) + order[pos:pos] = event.new_ids # replacements take the block's place + render(order, texts) print(session.result().text) # collapse the session into a TranscriptionResult ``` -Those three branches are the **complete core reduce** — handle them and your app is safe on +Those branches (packaged as `standard_asr.runtime.streaming.reduce_event`) are the +**complete core reduce** — handle them and your app is safe on every compliant engine, including ones that rewrite interim text or merge segments after the fact. Engines that never do these things simply never emit those events. Voice agents can go further and act on `event.stable_until`, the engine's guarantee of how much of the text is @@ -324,9 +335,10 @@ standard-asr compliance entrypoints ## Project status & design **Alpha.** The core protocol — engine interface, audio negotiation, capability discovery, -streaming events, plugin system — is shipped and validated by four engine plugins. The -toolchain (CLI, FastAPI server, compliance suite) works. What's missing: features like -hardware metadata and model cards are not yet part of the protocol. +streaming events, plugin system — is shipped and exercised by four engine plugins; the +compliance suite verifies interface-level conformance (runtime inference verification is +tracked separately). The toolchain (CLI, FastAPI server, compliance suite) works. What's +missing: features like hardware metadata and model cards are not yet part of the protocol. Developer documentation, a richer CLI, and a plugin starter template are also not done yet. See the open issues for what's planned. diff --git a/docs/for_app_dev/discover_and_use.md b/docs/for_app_dev/discover_and_use.md index 95cf5fe6..5672baab 100644 --- a/docs/for_app_dev/discover_and_use.md +++ b/docs/for_app_dev/discover_and_use.md @@ -79,6 +79,14 @@ from standard_asr import to_srt, to_vtt open("out.srt", "w").write(to_srt(result)) ``` +If any segment cannot render as a *visible* cue — it lacks a measured +`start`/`end` span (some engines omit timestamps — check +`seg.timestamp_status`), or its span quantizes to zero milliseconds on the +output grid (players silently drop `T --> T` cues) — the renderers raise +`SubtitleRenderingError` rather than silently dropping or hiding text or +fabricating timing; pass `on_unrenderable="omit"` (keep only renderable +cues) or `"collapse"` (one whole-text cue) to choose the loss explicitly. + ## 7. Streaming ```python diff --git a/docs/for_app_dev/errors.md b/docs/for_app_dev/errors.md index 40bf3017..b76a75c8 100644 --- a/docs/for_app_dev/errors.md +++ b/docs/for_app_dev/errors.md @@ -10,8 +10,9 @@ Every exception inherits from `StandardASRError`, so a single ``` StandardASRError -+-- StructuredError (adds .param / .detail / .engine_id) -| +-- ConfigError invalid config (bad language, missing credential, ...) ++-- StructuredError (adds .param / .hint / .details) +| +-- ConfigError invalid config (bad language, bad value, ...) +| | +-- ConfigurationRequiredError required config ABSENT (e.g. credential not set) | +-- TranscriptionError engine failed during transcription | +-- UnsupportedFeatureError unsupported parameter in strict mode | +-- InvalidProviderParamError wrong engine's provider_params passed @@ -19,8 +20,10 @@ StandardASRError | +-- IncompatibleAudioInputError no conversion path exists | +-- FFmpegNotFoundError FFmpeg needed but not on PATH | +-- FFprobeNotFoundError FFprobe needed but not on PATH ++-- EngineContractError engine broke the protocol contract (async transcribe, bad declaration) ++-- SubtitleRenderingError to_srt/to_vtt: segments lack measured timing (choose a policy) +-- StreamClosedError audio delivered to a closed session -+-- InvalidSessionUseError session driven incorrectly (e.g. double-end) ++-- InvalidSessionUseError session driven incorrectly (e.g. mixing feed() with send_audio()) +-- DiscoveryError plugin discovery problem +-- EntrypointValidationError bad entry-point name or metadata +-- FactoryLoadError entry point failed to import / not callable @@ -30,14 +33,18 @@ StandardASRError | Exception | When | Typical cause | | --------- | ---- | ------------- | -| `ConfigError` | `create()` or `start_transcription()` | Missing API key, invalid language, bad pydantic validation. | +| `ConfigError` | `create()` or `start_transcription()` | Invalid config value — bad pydantic validation, or a `default_language` that is malformed / not selectable. Fixable by whoever supplies the config. | +| `ConfigurationRequiredError` | `create()` / `from_env()` | A required field (e.g. an API key) is absent from both explicit config and the environment — set it and retry; compliance treats this as a skip, not a failure. | | `TranscriptionError` | `transcribe()` | Engine crashed or returned an invalid result. | | `UnsupportedFeatureError` | `start_transcription()` or `transcribe()` (strict mode) | Requested word timestamps on an engine that does not support them. | | `InvalidProviderParamError` | `transcribe()` or `start_transcription()` | Passed faster-whisper's `provider_params` to an OpenAI engine (swap-safety). | | `AudioProcessingError` | `transcribe()` | Corrupt audio file, missing sample rate, unsupported format without `[audio]` extra. | | `IncompatibleAudioInputError` | `transcribe()` | Passed a URL to an engine that only accepts arrays, and no conversion path exists. | | `UnsafeAudioUrlError` | `transcribe()` | An `AudioUrl` failed the SSRF policy (non-HTTPS, private IP, etc.). | -| `StreamClosedError` | `session.feed()` / `session.send_audio()` | Sending audio after the session ended. | +| `SubtitleRenderingError` | `to_srt()` / `to_vtt()` | A segment cannot render as a visible cue — no measured `start`/`end` span, or a span that quantizes to zero milliseconds on the output grid (players silently drop `T --> T` cues) — and `on_unrenderable` is the default `"error"`. Choose the loss explicitly: `"omit"` (renderable cues only) or `"collapse"` (one whole-text cue). Carries `.unrenderable` / `.total` counts. | +| `EngineContractError` | any synchronous protocol member, or `transcribe()` / `start_transcription()` on a language-declaration defect | The engine returned an awaitable (an `async def` implementation) or a wrong-typed value from a sync member (`transcribe()`, `start_transcription()`, `supports()`, ...), declared a language axis without a `default_language` (IC.6), or declared a malformed selectable/detectable tag. An engine/plugin bug — report it to the engine's author; nothing in your code is wrong. | +| `StreamClosedError` | `session.send_audio()` | Sending audio manually after `end_audio()` or after the session delivered a terminal event. (`feed()` never raises it: a managed source's post-terminal chunks are discarded by design.) | +| `InvalidSessionUseError` | `session.feed()` / `session.send_audio()` / iterating the session | Driving a still-live session incorrectly: mixing managed `feed()` with manual `send_audio()`/`end_audio()`, calling `feed()` twice, or iterating the event stream twice. The session is NOT closed — fix the calling code; do not rebuild the session. | | `EntrypointValidationError` | `discover_models()` (strict mode) | A plugin's entry-point name is malformed. | | `FactoryLoadError` | `registry.engine_class()` / `registry.create()` | Plugin's entry point cannot be imported or the factory is misconfigured. | @@ -49,9 +56,15 @@ StandardASRError try: engine.transcribe("audio.wav", RuntimeParams(word_timestamps="word")) except UnsupportedFeatureError as exc: - print(exc.param) # "word_timestamps" - print(exc.engine_id) # "faster-whisper" - print(exc.detail) # human-readable explanation + print(exc.param) # "word_timestamps" — the offending parameter + print(exc.mode) # "batch" — where the rejection happened + print(exc.hint) # actionable guidance, or None + +try: + registry.create("acme/model") +except ConfigError as exc: + print(exc.param) # the offending field, if a single one is implicated + print(exc.details) # sanitized [{"type", "loc", "msg"}, ...] entries ``` These fields let you build programmatic error handling (e.g. fall back to another diff --git a/docs/for_app_dev/streaming.md b/docs/for_app_dev/streaming.md index c6ecce8e..73fb58ea 100644 --- a/docs/for_app_dev/streaming.md +++ b/docs/for_app_dev/streaming.md @@ -16,9 +16,22 @@ async with engine.start_transcription(audio_format=audio_format) as session: ``` `recommended_wire_format()` returns the engine's preferred sample rate and -encoding as an `AudioFormat`. If you need a specific format (e.g. 8 kHz for -telephony), construct one yourself -- the engine will raise -`UnsupportedFeatureError` if it cannot accept it. +encoding as an `AudioFormat`, or `None` when the engine declares no usable +positive sample rate (no bare-frame session can be opened then). If you need a +specific format (e.g. 8 kHz for telephony), construct one yourself -- the +engine will raise `UnsupportedFeatureError` if it cannot accept it. The +recommendation is derived from the engine's static Properties; whether a +bare-frame session can be opened at all is a capability question -- gate on +`engine.supports("streaming_input")` first. + +> **Known pre-1.0 limitation.** The recommendation is a format the engine's +> session-establishment *validator* accepts -- for the rare self-managed-wire +> adapter (an engine that manages its own wire format and opens sessions with +> a bare `start_transcription()`, taking no `audio_format` at all), that is +> not the same thing as the right way to open the session. How such engines +> declare their transport is being settled in the capability-ontology ADR +> ([#45](https://github.com/standard-voice/standard_asr/issues/45)); until +> then, follow the engine's own documentation for the no-argument open path. For whole-input streaming (the engine streams *output* over a complete audio file), pass `audio=` instead of `audio_format=`: @@ -31,15 +44,26 @@ async with engine.start_transcription(audio="meeting.wav") as session: ## Feeding audio -For live-input streaming, feed PCM byte chunks as an iterable: +For live-input streaming there are two mutually exclusive input modes: + +**Managed mode** -- hand the session an iterable of PCM byte chunks and let it +drive the input side for you: ```python -session.feed(microphone) # any (async) iterable of bytes +session.feed(microphone) # any sync or async iterable of bytes chunks ``` -When the audio source is exhausted, call `session.end_audio()` to signal -end-of-input. If you feed an iterable, the session calls `end_audio()` -automatically when the iterable finishes. +`feed()` consumes the source and signals end-of-input automatically when the +iterable finishes. Do **not** call `end_audio()` yourself in this mode -- a +session is owned by exactly one input mode, and mixing them raises +`InvalidSessionUseError`. + +**Manual mode** -- push chunks yourself and signal the end explicitly: + +```python +await session.send_audio(chunk) # repeat per chunk +await session.end_audio() # signal end-of-input +``` ## The event protocol @@ -53,7 +77,7 @@ Every streaming session emits a sequence of `TranscriptionEvent` objects. The | `supersede` | The engine re-segmented: one or more previously-emitted segments are **replaced**. The replacement events follow immediately. | `None` | `None` (check `old_ids`). | | `progress` | A progress heartbeat (e.g. audio position). No transcript content. | `None` | `None` | | `done` | The session is complete. No more events will follow. | `None` | `None` | -| `error` | An engine error mid-stream. | Error description. | `None` | +| `error` | An engine error mid-stream. Machine-readable code in `event.code`; human detail in `event.extra["detail"]`; `event.recoverable` says whether the session may continue. | `None` | `None` | ## The core reduce @@ -62,18 +86,31 @@ compliant engine -- including ones that rewrite interim text or merge segments after the fact: ```python -segments: dict[str, str] = {} +order: list[str] = [] # reading order of live segment ids +texts: dict[str, str] = {} async for event in session: - if event.type == "partial": - segments[event.segment_id] = event.text - elif event.type == "final": - segments[event.segment_id] = event.text + if event.type in ("partial", "final"): + if event.segment_id not in order: + order.append(event.segment_id) # first mention claims a position + texts[event.segment_id] = event.text elif event.type == "supersede": + pos = order.index(event.old_ids[0]) # the retired block's position for old_id in event.old_ids: - del segments[old_id] + order.remove(old_id) + texts.pop(old_id, None) + order[pos:pos] = event.new_ids # replacements take its place ``` +Display text is `texts` joined in `order`. The state is a reading-order list +plus a text map -- not a bare map -- because for engines that emit no +timestamps, **list order is the reading order**, and a mid-stream `supersede` +must splice its replacements into the retired block's position (a bare dict +can only append, which would silently reorder the transcript). This exact +reduce ships as `standard_asr.runtime.streaming.reduce_event`, and +`StreamReducer` / `session.result()` build a full `TranscriptionResult` the +same way. + Engines that never revise or re-segment simply never emit `supersede`. Your code does not need to know which engine is running. @@ -105,6 +142,23 @@ This gives you the same constant-shape result you get from `engine.transcribe()` so your downstream code (subtitle rendering, search, etc.) works identically whether the input was batch or streamed. +One honesty note: some engines omit timestamps (or one of the two bounds) +while streaming. The reducer stores the engine's measurement verbatim -- +`Segment.start`/`end` are `float | None`, and `None` means "not measured" +(check `segment.timestamp_status`: `"measured"`, `"start_only"`, or +`"unavailable"`). Nothing is fabricated: a result with any unmeasured span +also carries a `segment_timestamps_unavailable` warning diagnostic as the +aggregate disclosure. The renderers read the values themselves: a result +whose every segment is *renderable* (a measured span that survives the +output's millisecond grid) renders per-segment faithfully, while an +unmeasured span -- or a measured span that quantizes to zero milliseconds +(players silently drop a `T --> T` cue) -- makes `to_srt`/`to_vtt` raise +`SubtitleRenderingError` by default: rendering it would mean silently +dropping, hiding, or fabricating timing, and that trade-off is yours to +make. Pass `on_unrenderable="omit"` to keep only the renderable cues (the +other segments' text stays in `result.text` but not in the file), or +`"collapse"` to render one whole-text cue with no per-segment timeline. + ## Synchronous bridge If you cannot use `async`, wrap the session in `SyncSession`: @@ -116,12 +170,16 @@ audio_format = engine.recommended_wire_format() sync = SyncSession(engine.start_transcription(audio_format=audio_format)) with sync: - sync.feed_bytes(pcm_chunk) - sync.end_audio() + sync.feed(pcm_chunks) # an iterable of bytes chunks (or one bytes chunk) for event in sync: print(event.type, event.text) ``` +`SyncSession` mirrors the async session's input modes: `feed(...)` for managed +input (end-of-input is signalled automatically), or `send_audio(chunk)` + +`end_audio()` for manual input. As with the async session, the two modes must +not be mixed. + `SyncSession` runs the async session on a background thread and exposes a blocking iterator. See the [API reference](../reference/streaming.md) for the full interface. @@ -135,12 +193,19 @@ from standard_asr import StreamDeadlines async with engine.start_transcription( audio_format=audio_format, - deadlines=StreamDeadlines(max_idle_seconds=5.0, max_session_seconds=60.0), + deadlines=StreamDeadlines(max_idle=5.0, max_session_seconds=60.0), ) as session: ... ``` -When a deadline fires, the session emits a `done` event and closes cleanly. +The three deadlines are `done_timeout` (pipeline-inactivity hang backstop), +`max_idle` (content-stall detector), and `max_session_seconds` (absolute +wall-clock cap); each accepts `None` to disable it. + +When a deadline fires, the session terminates with a terminal **`error`** event +(`code` = `done_timeout` / `stream_stalled` / `session_timeout`) -- not a +`done` event -- so a deadline-killed session is never mistaken for normal +completion. Handle the `error` event's `code` to tell the cases apart. ## Diagnostics mid-stream diff --git a/docs/for_asr_dev/adapting_engine.md b/docs/for_asr_dev/adapting_engine.md index 25ccd905..b80ccc90 100644 --- a/docs/for_asr_dev/adapting_engine.md +++ b/docs/for_asr_dev/adapting_engine.md @@ -166,17 +166,118 @@ Build your config with `Config.from_env(engine_id, **explicit)` instead of the bare constructor. Unset fields fall back to `STANDARD_ASR___` environment variables (note the **double underscore** separating the engine and field segments; explicit args win), and -credentials are wrapped in `SecretStr` by construction — never passed around as -plaintext. Put secrets (`api_key`, tokens) in `SecretStr` fields via -`secret_field()`; keep non-secret routing (`base_url`, `region`) plain. A -composite field (e.g. `default_candidate_languages: list[str]`) takes its env -value as JSON (`'["en","ja"]'`). +credentials are wrapped in their masking carrier by construction — never passed +around as plaintext. Put secrets (`api_key`, tokens) in `SecretStr` fields — +or `SecretBytes` for byte credentials; exactly one carrier per field, +optionally with `None` — via `secret_field()`; keep non-secret routing +(`base_url`, `region`) plain. A +structured field (a list, a mapping, a submodel, a `TypedDict`, a dataclass) +takes its env value as JSON (`'["en","ja"]'`); a scalar one — including +`SecretStr` and `Path` — takes the raw string, byte for byte. Which of the +two applies is read off the field's own schema, so any shape the config +guards accept is reachable through the env convention. One shape is refused +at class definition: a field accepting BOTH (`str | list[str]`) has no +defined reading — `"123"` is either that string or that JSON number, and +either choice would disagree with the explicit constructor, which always +takes the string. Declare one shape, or model the alternatives as a named +submodel. + +The config's serialization surface is **closed**, and the closure is proved +by sweeping the model's actual core schema rather than by listing forbidden +decorators. Anything that would make `model_dump` run author code — or emit +something other than your declared inputs — is rejected at class definition: +`@computed_field`, `@model_serializer`, `@field_serializer`, +`PlainSerializer`/`WrapSerializer` metadata, a `SerializeAsAny[...]` field +(its dump follows the *runtime* object, so the declared type proves nothing), +an **undeclared value shape** (`Any`, `object`, an unparametrized container, +`dict[str, Any]` — same duck-typing, reached from the type instead of a +marker; spell a heterogeneous mapping as `dict[str, str | int | bool | None]` +or a named submodel), a **nested submodel** carrying any of those, a +serializer installed through a custom `__get_pydantic_core_schema__`, and +`Field(exclude=True)`. Keep `extra="forbid"` too (BaseConfig's default): +`extra="allow"` stores undeclared caller data and dumps it verbatim past the +secret mask, and `extra="ignore"` silently swallows a mistyped credential key +so it reads as an absent credential rather than a loud error. The reason +is one sentence: `public_dump()` is documented safe for `/v1/models`, +persistence, and telemetry, which holds only while nothing author-defined can +rematerialize a credential inside it — and its output must stay the declared +input surface, so persisting and reloading a config round-trips. + +The input surface stays closed **at every depth**, not only on the config +itself: every nested input container your schema reaches — an options +submodel, a `TypedDict`, a dataclass — must forbid undeclared keys, and one +that does not is rejected at class definition. pydantic's default for all +three silently *drops* an unknown key, so a user's typo'd nested option +(`{"decode": {"baem": 8}}`) would read as applied while your engine runs on +the field's default — a silent wrong result. The rule reads the *effective* +policy from the core schema, so pydantic's config propagation is honored: a +bare `TypedDict` or stdlib dataclass inherits the config's `extra="forbid"` +and is closed for free; a nested `BaseModel` needs +`model_config = ConfigDict(extra="forbid")`, and a *pydantic* dataclass +(which owns its config) needs +`@pydantic.dataclasses.dataclass(config=ConfigDict(extra="forbid"))`. + +Use a plain `@property` for derived in-process values (an `authorization` +header belongs in your engine code, not in the config dump), and keep config +fields to plain typed inputs. + +One boundary is yours to keep, because no serialization proof can hold it +for you: **never copy a secret out of its carrier**. The closure proof +bounds what the *schema* installs in the dump, not the contents of values +your own code builds — a validator that writes `get_secret_value()` into a +plain field or onto an object's display state (say, a `Path` subclass whose +`__str__` embeds the token) emits that plaintext through `public_dump()`, +and would through any dump mechanism. Read the credential with +`reveal_dump()` at the point of use in your engine code and let it live +nowhere else. + +Provider-native wire names map onto standard fields with **plain string +aliases** (`Field(alias="xi-api-key")`, or an all-string `AliasChoices`); +`AliasPath` — and any `AliasChoices` carrying one — is rejected at class +definition: the flat env convention and the absent-vs-invalid config +classifier (what makes a missing credential a compliance *skip* instead of a +fail) both resolve fields by single string tokens, which a nested path alias +cannot provide. If a value is genuinely nested, declare it as a submodel +field (its env value arrives as JSON). ```python def __init__(self, **kwargs): self.config = MyConfig.from_env("my-engine", **kwargs) # IC.4 ``` +## Wire-visible values: `extra`, diagnostics + +Every slot the wire can see — a `TranscriptionResult` / `Segment` / `Word` / +`TranscriptionEvent` `extra`, and `emit_diagnostic`'s `provided` / `effective` +— holds **JSON values only** (`JsonValue`: null, bool, int, finite float, +str, and lists/str-keyed dicts of those). The Python objects and the JSON +documents are the same protocol seen twice, so a value with no JSON form is +rejected at construction, naming the field, instead of failing later in the +transport — after the server has already committed to a response. Non-finite +floats (`NaN`, `Infinity`) are excluded for the same reason: they are Python +floats but not JSON, and a conforming parser rejects the whole document. + +`emit_diagnostic` projects a **structured** value (a pydantic submodel) into +its JSON form itself, so `provided=my_request_model` just works. For any +other wire-visible slot — or to absorb the `list`-invariance complaint a +type checker raises when a `list[str]` variable meets a `list[JsonValue]` +parameter (a static-analysis artifact, not a real mismatch) — use +`to_json_value` from the engine surface: + +```python +from standard_asr.engine import to_json_value + +hints: list[str] = [...] +event = TranscriptionEvent.final("s1", text, extra={"hints": to_json_value(hints)}) +``` + +Runtime validation is unchanged either way: a value that is genuinely not +JSON is rejected loudly at construction, naming the field. + +If you genuinely need an arbitrary in-process object, keep it in your own +engine/session state: a standard protocol object's whole contract is that +both layers can express it. + ## Streaming responsibilities (what the base does vs you) The base `TranscriptionSession` owns the pump, backpressure (bounded buffers), @@ -217,7 +318,9 @@ conditions. > Never put a credential, API key, auth'd URL, or raw exception text in a > diagnostic — route sensitive operator detail to `logging` instead. (The server > *does* scrub an `error` event's `extra`, because that is auto-captured -> `str(exc)`; a diagnostic is content you chose, so its safety is yours.) +> exception detail — pre-summarized input-echo-free by the standard layer, but +> still operator-only content; a diagnostic is content you chose, so its safety +> is yours.) ### Sequence invariants the guard enforces for free @@ -256,6 +359,10 @@ the session with `session.diagnostics()`): never-announced segment was suppressed. - `supersede_reintroduces_segment` — a `supersede` whose `new_ids` reuse an already-known id was suppressed. +- `supersede_noncontiguous_old_ids` — a `supersede` whose `old_ids` do not + form a contiguous block of the live reading order (in reading order) was + suppressed: the replacements would have no defined placement, and an + untimestamped transcript's word order would silently change. - `supersede_cross_speaker_merge` — a `supersede` that would merge segments carrying distinct non-null speakers into fewer segments was suppressed (someone's words would be silently mis-attributed). diff --git a/docs/for_asr_dev/plugin_entrypoints.md b/docs/for_asr_dev/plugin_entrypoints.md index 502b8d96..466fbb57 100644 --- a/docs/for_asr_dev/plugin_entrypoints.md +++ b/docs/for_asr_dev/plugin_entrypoints.md @@ -33,8 +33,11 @@ A plugin **key** must contain the `/`: only `/` and the explicit default `/` are valid declaration forms. A slash-less key (e.g. `faster-whisper` instead of `faster-whisper/`) is **not** a third valid form — it is almost always a typo that dropped `/`. Discovery -rejects it: `discover_models(strict=True)` (and `standard-asr compliance -entrypoints --strict`) raise, while default discovery logs a warning naming the +rejects it: the library call `discover_models(strict=True)` **raises** +`EntrypointValidationError`, while `standard-asr compliance entrypoints +--strict-discovery` **reports** it as an `entrypoint_invalid` compliance error +and exits non-zero (a compliance check always returns a report, never raises); +default discovery logs a warning naming the fix and skips the key. The trailing slash is required only on the *declaration* side; the *lookup* helpers below accept the bare engine id as a convenience alias for its default model. @@ -176,7 +179,8 @@ standard-asr compliance entrypoints Flags of interest: -- `--strict` rejects malformed entry points immediately. +- `--strict-discovery` reports malformed entry points as `entrypoint_invalid` + errors (non-zero exit; the report still covers the valid engines). - `--no-instantiate` skips smoke-instantiation (useful when a model needs mandatory credentials at runtime). - `--on-conflict replace` helps debug when multiple packages expose the same model id. @@ -214,16 +218,17 @@ also importable from `standard_asr.compliance`: | `check_entrypoints` | Entry-point metadata, capability declarations, the optional `prepare()` contract | `standard-asr compliance entrypoints` / `compliance run` | | `check_provider_params_swap_safety(engine)` | An engine rejects another engine's `provider_params` rather than silently misreading them (spec Runtime R3 / §5.4) | `standard-asr compliance run` (per zero-arg engine) | | `check_streaming_param_gating(engine)` | A streaming engine gates an unsupported standard parameter per its strict/best_effort policy | `standard-asr compliance run` (per zero-arg streaming engine) | -| `check_recommended_wire_format(engine)` | A streaming engine's `recommended_wire_format()` is internally consistent with its declared sample rate / wire encoding | `standard-asr compliance run` (per zero-arg streaming engine) | +| `check_recommended_wire_format(engine)` | `recommended_wire_format()` returns `AudioFormat \| None` and any returned format passes the engine's own session-establishment rule — the member is unconditional (spec §3.1: Properties-pure, capability-blind), so this holds for **every** engine, batch-only included | `standard-asr compliance run` (per zero-arg engine, inside the entrypoint instance checks) | | `check_sync_bridge(session_factory)` | The async→sync bridge terminates without deadlock or a leaked thread | `standard-asr compliance run --include-bridge` (opens a session) | | `check_event_sequence(events)` | A recorded streaming event stream obeys the segment/event-order contract | library API only — drive it from your own tests with recorded events | | `check_transcription_result(result, capabilities=...)` | A recorded batch result carries no speaker labels beyond the declared `batch.diarization` capability (code `result_exceeds_diarization`) | library API only — drive it from your own tests with a recorded result | `standard-asr compliance run` orchestrates every check except -`check_event_sequence` and `check_transcription_result` for you: +`check_event_sequence` and `check_transcription_result` for you: the +entrypoint instance checks (including the wire-format round-trip) and `check_provider_params_swap_safety` for each zero-arg engine, then -`check_streaming_param_gating` and `check_recommended_wire_format` for each -streaming engine (both no-billing probes), plus `check_sync_bridge` when opted +`check_streaming_param_gating` for each +streaming engine (no-billing probes), plus `check_sync_bridge` when opted in via `--include-bridge` (it opens a session). `check_event_sequence` needs an author-recorded event stream — and `check_transcription_result` an author-recorded batch result — that the CLI cannot synthesize, so wire them diff --git a/docs/mission.md b/docs/mission.md index 76a65b40..e332c320 100644 --- a/docs/mission.md +++ b/docs/mission.md @@ -70,8 +70,11 @@ sharp, error paths explicit, no implicit behavior. ### Security by default -Credentials use `SecretStr`. URLs are validated (HTTPS-only, no SSRF). Unsafe -options require explicit opt-in. +Credentials use `SecretStr`. URLs are validated against the SSRF target set +(HTTPS-only; private, loopback, and link-local addresses rejected) before being +forwarded — in v1 this is a resolve-time check, honest about its limits: it is +advisory against DNS rebinding, since the engine re-resolves at fetch time (see +the spec's AudioUrl security policy). Unsafe options require explicit opt-in. ## Stakeholders diff --git a/docs/spec/cli.md b/docs/spec/cli.md index 50925f01..a7c0a291 100644 --- a/docs/spec/cli.md +++ b/docs/spec/cli.md @@ -9,13 +9,18 @@ quick transcription. List all discovered models. Flags: -- `--strict`: fail on invalid entry points during discovery (default: keep - going, skipping invalid ones). +- `--strict-discovery`: fail on invalid plugin entry points during discovery + (default: keep going, skipping invalid ones). Deliberately NOT named + `--strict`: bare `strict` is the engine's strict/best_effort *parameter-gating* + policy (an init-config field, `--set strict=...`), a different knob. - `--on-conflict {warn_keep_first,replace}`: strategy for duplicate model keys (default: `warn_keep_first`). ### `standard-asr show ` -Show metadata about a specific model entry point. The declared capabilities are +Show metadata about a specific model entry point. If the model is registered but +its plugin cannot be imported, `show` prints everything it could read plus a +sanitized `Capabilities: ` line and exits **1** (the +installation is broken; the caller's key was fine, so it is neither 0 nor 2). The declared capabilities are rendered as **canonical JSON** — the same serialization the REST `GET /v1/.../capabilities` endpoint returns, with a derived `supported` boolean at every node — so CLI and wire output can be compared field-for-field (spec §C @@ -24,26 +29,46 @@ R6; the two layers share one capability model). If an engine mis-declares its problem and the rest of the metadata still renders. Flags: -- `--strict`: fail on invalid entry points during discovery. +- `--strict-discovery`: fail on invalid plugin entry points during discovery. ### `standard-asr cache [--ensure]` Display (and optionally create, `--ensure`) the Standard ASR model cache directory. ### `standard-asr prepare ` +Flags: +- `--strict-discovery`: fail on invalid plugin entry points during discovery + (the same flag as `list` / `show` / `transcribe` / `compliance`; `serve` + deliberately has no discovery flags -- the server always discovers leniently + so one broken co-installed plugin cannot take every other engine's endpoint + down with it). On this command the name matters doubly: `--set strict=...` + configures the engine's parameter-gating policy on the same command line. + Warm up a model by loading or downloading weights. `prepare` is best-effort and maps onto the optional `prepare()` hook (spec IC.11): an engine that does not override the `EngineBase` default no-op is a reported no-op ("nothing to warm up") and never transcribes, so a cloud engine is never billed for a stand-in request. The hook MUST be a synchronous, zero-argument method; a coroutine -`prepare` (or a non-callable `prepare` attribute) is rejected as a usage error -(it would otherwise be called but never awaited and falsely reported complete). +`prepare` (or a non-callable / parameter-requiring `prepare` attribute) is +rejected as an ENGINE fault — `EngineContractError`, exit 1 — because no flag +or env var the invoker controls can fix a declaration (it would otherwise be +called but never awaited and falsely reported complete). The attribute LOOKUP +is engine code too: `prepare` may be a property, so a descriptor that raises is +classified at the same seam as the call itself. ### `standard-asr compliance entrypoints` -Validate entry points and factories (entry-point metadata + class-level -capability declarations). Flags: -- `--strict`: fail on invalid entry points at discovery time. -- `--no-instantiate`: skip instantiation attempts (avoids loading models). +Validate entry points and factories: entry-point metadata, class-level +capability declarations, and — by default — instantiation of each zero-arg +factory to verify the instance surface. Instantiation includes one +**behavioral probe**: an engine declaring no streaming axis has +`start_transcription()` called once with no arguments and MUST raise +`UnsupportedFeatureError` (a compliant engine refuses at the capability gate +before constructing anything; a returned session is never entered, but a +non-compliant implementation may still run arbitrary author code in the +method body). Flags: +- `--strict-discovery`: fail on invalid plugin entry points at discovery time. +- `--no-instantiate`: skip instantiation attempts (avoids loading models — + and skips the batch-only refusal probe with them). - `--quiet`: suppress warnings in the output. ### `standard-asr compliance run [engine/model ...]` @@ -53,12 +78,65 @@ constructs without arguments and declares a streaming axis, the streaming **parameter-gating** check — so a streaming engine that bypassed the gating template is caught here, not just at the entry-point level (delivers G.2.1's "one command validates compliance"). An engine that requires constructor -arguments (e.g. credentials) is reported as *skipped*, not failed. Flags: -- `--strict`: fail on invalid entry points at discovery time. +arguments (e.g. credentials) is reported as *skipped*, not failed — the same +verdict whether the requirement shows up in the factory signature or as a +`ConfigurationRequiredError` from a zero-arg factory whose credential is +absent from the environment (`BaseConfig.from_env` raises that subtype +automatically; one run never issues two contradictory verdicts for one +engine). Any **other** `ConfigError` — an invalid supplied value, an +inconsistent declaration — is a defect and **fails**: skipping it would let a +broken plugin read as green-with-warning. + +**Per-model containment (normative)**: no single model's fault may deny the +others their verdict — that aggregate IS the one-command guarantee. A +factory raising anything (`RuntimeError` from an SDK that failed to +initialize, `OSError` on an unreadable model directory, a plugin's own +exception type) is reported for THAT model as +`engine_construction_failed` and the run continues; a check implementation +that itself falls over is reported as `compliance_check_crashed`, kept +distinct so an author can tell "your plugin broke" from "the suite broke +on your plugin". Both fail the run's exit code. `KeyboardInterrupt` and +`SystemExit` are explicitly NOT contained: they are the operator's own +control flow. + +**Probe honesty**: the default run's behavioral checks call public engine +entry points with deliberately-rejectable inputs. The provider-params +swap-safety check invokes `transcribe()` with a one-sample silent probe and a +foreign engine's `provider_params`; the streaming gating check invokes +`start_transcription()` with an unsupported parameter (the session is +constructed but never entered, so the standard layer opens no wire +connection — and a best_effort output-only engine is reported as an +inconclusive skip rather than probed through real audio); the batch-only +refusal probe (part of the entrypoint checks above) invokes a no-argument +`start_transcription()` on engines declaring no streaming axis and requires +`UnsupportedFeatureError`. A **compliant** +engine rejects these at the gate, before any decode, connection, or +inference. A **non-compliant** engine — the very thing the probes exist to +catch — may execute its real pipeline on the probe (model load; for a cloud +engine, a billable call), or run arbitrary method-body code before the +missing refusal. Run compliance against staging credentials if that +risk matters to you. + +Flags: +- `--strict-discovery`: fail on invalid plugin entry points at discovery time. - `--quiet`: suppress warnings in the output. - `--include-bridge`: also run the sync-bridge check. This **opens a streaming session** and is therefore off by default — for a cloud engine that is a billable connection. +- `--bridge-timeout SECONDS` (default `5.0`): timeout granted to each phase + of the sync-bridge check -- session establishment, then the bridged drive + (open, end-of-audio, drain, close; it also caps each bridged lifecycle + call). Only meaningful + with `--include-bridge` -- passing it alone is a usage error (exit 2), never + a silent no-op. The check's remediation advice ("re-run with a larger + timeout") is actionable through this flag; library callers pass + `check_sync_bridge(..., timeout=...)` (finite, `> 0`; validated). An engine + that refuses session establishment as unsupported (e.g. output-only, no + `streaming_input`) is reported as a passing `sync_bridge_not_applicable` + warning in the report set -- structured and `--quiet`-respecting; there is + deliberately no CLI-side pre-gate, so machine consumers of the reports see + the verdict too. The flag's value is granted to each check phase + (establishment, then the bridged drive) independently. The streaming **event-sequence** check needs an author-recorded event stream the CLI cannot synthesize; it remains a library API @@ -66,6 +144,13 @@ CLI cannot synthesize; it remains a library API note naming it, so a green run is never mistaken for full coverage. ### `standard-asr transcribe