Pular para conteúdo

AUR-019: Fixar contratos oficiais de providers

Documento histórico do board. Pode descrever componentes removidos; para uso atual, consulte o guia do produto.

This is a research/decision card. It does not ship an executable client; it fixes a design contract that later O0x provider-adapter cards implement against. The observable behavior is the acceptance lint itself: tests/acceptance/AUR-019.sh deterministically resolves the artifacts below and either passes or names exactly which one is incomplete.

Behavior

tests/acceptance/AUR-019.sh (selector AC-001) reads the artifacts this card owns and checks that they resolve to one consistent, versioned, fixture-backed set of provider contracts, without making any network request:

  1. .board/research/providers.md — a "Provider contracts matrix" table that names all seven required providers (OpenAI, LiteLLM, Anthropic, Ollama, Azure, Gemini, Bedrock), each with a [label](url) source, a version, an ISO-8601 date, and exactly four canonical criterion cells (Wire format, Auth, Error taxonomy, Capabilities). Each cell is compared byte-for-byte with the corresponding projection of the matching standards/providers/<slug>.yaml record.
  2. standards/providers/<slug>.yaml, one per required provider (slug is the lowercase provider name) — a flat key/value record of the same provider's transport (http or sdk-call), wire endpoint/method/ content-type, auth scheme/header/binding/format (or, for sdk-call, wire_call), error envelope/taxonomy, and a capability_<name>: supported|unsupported entry for every capability in tests/specs/AUR-019/capabilities.txt.
  3. tests/specs/AUR-019/fixtures/<slug>/<capability>.json — a recorded, non-secret fixture for every capability_<name>: supported entry, bound to its file by matching "capability"/"provider" JSON fields and by matching the fields its own provider's declared transport requires (request.method/request.path/request.headers for transport: http; request.call/request.params for transport: sdk-call) against that same provider's wire_method/wire_endpoint/auth_header/wire_call (see "Fixture wire-binding invariant" below).

Contract

  • tests/specs/AUR-019/provider-names.txt, tests/specs/AUR-019/capabilities.txt and tests/specs/AUR-019/allowed-source-domains.txt are the single source of truth for, respectively, the seven required provider names, the tracked capability keys, and the hostnames a source:/matrix Source link may cite. Both the shell acceptance test and tests/integration/AUR-019_test.go read these three files instead of duplicating the lists. provider-names.txt must declare exactly 7 entries and capabilities.txt exactly 3 (streaming, tool_use, structured_output), and in both files every entry must be distinct in both readers: a count check alone does not prove a required provider (or capability) was actually validated, only that the right number of lines exist. A padded, duplicated provider-names.txt fails typed AUR-019/AC-001/undecided-provider before either reader drives a single downstream check off it. Both readers additionally tally the distinct standards/providers/<slug>.yaml paths they actually opened during the provider loop and require that tally to equal the required provider count, as a second, independent proof that every required provider's own file -- not the same file revisited -- was the one validated.
  • A matrix or standards-file version must match a semantic-version-like pattern (v2.53.0) or a dated API-version string (2024-10-21, the shape Azure's own api-version query parameter uses); date is always a separate ISO-8601 calendar date for when the cited source was read.
  • The URL inside every source link must itself contain the declared version (with an optional leading v for release tags); mutable /latest, branch, main, master, trunk, and HEAD references are rejected.
  • Every capability_<name>: supported entry in a standards/providers file must have a matching fixture_<name>: entry pointing at an existing, non-empty, JSON-valid fixture whose own capability and provider fields match the declaration; a mismatch or absence fails with the typed code AUR-019/AC-001/unfixtured-capability. "JSON-valid" is enforced, not assumed: see "Fixture canonical-form invariant" below for how the sealed accept gate actually parses every fixture as JSON instead of trusting two grepped substrings.
  • The fixture_<name> value must be exactly tests/specs/AUR-019/fixtures/<provider-slug>/<name>.json. Absolute paths, .., decoy descendants, .flat sidecars, final symlinks, and symlinked ancestors are rejected by both readers. The fixture's top-level source must equal the provider YAML source and use the same versioned URL.
  • A missing provider, a matrix row cited twice, an empty criterion cell, or a standards/providers file missing a required flat key fails with AUR-019/AC-001/undecided-provider.
  • A non-allowlisted source host, an unversioned source, or a non-ISO date — in either the matrix or a standards/providers file — fails with AUR-019/AC-001/unsourced-provider.
  • This card produces no HTTP client, no SDK dependency, and no network call. The seven provider adapters that implement pkg/types.Provider against this contract are separate, dependent cards.

Fixtures

tests/specs/AUR-019/fixtures/<slug>/<capability>.json records, for every provider/capability pair marked supported, the documented request and response shape transcribed from the cited source — never a live call, never a credential. Currently every one of the 7 providers × 3 tracked capabilities (streaming, tool_use, structured_output) is marked supported, so all 21 fixtures exist. Every fixture is written by hand transcribing the cited source, then normalized into this card's canonical JSON form described in "Fixture canonical-form invariant" below before it is committed; the acceptance gate's check_fixture_canonical_form is the only authority that enforces that form, and it runs inside the sealed container.

Example (no secret)

Running the acceptance test directly, with no network access and no secret material of any kind:

$ ./.board/bin/oci-run --profile bootstrap-readonly-v1 --card AUR-019
{"card":"AUR-019","scenario":"AC-001","selector":"AC-001","required_providers":7,"tracked_capabilities":3,"result":"pass"}

A failing example — structured_output marked supported for OpenAI with its fixture entry removed (MUT-001):

$ tests/acceptance/AUR-019.sh
AUR-019/AC-001/unfixtured-capability: standards/providers/openai.yaml marks capability_structured_output supported with no fixture_structured_output entry

Byte-allowlist invariant

standards/providers/*.yaml and .board/research/providers.md are both machine-authored policy/data, not free-form prose: neither has any legitimate reason to contain any byte other than printable ASCII (0x20-0x7E) or a bare line feed (0x0A). tests/acceptance/AUR-019.sh enforces that as its own gate, check_allowed_bytes, which runs on every standards/providers/<slug>.yaml file before check_canonical_form or flat_value ever inspect it, and on .board/research/providers.md before the "## Provider contracts matrix" awk walk ever inspects it. It fails typed AUR-019/AC-001/malformed-entry: disallowed byte 0xNN at <path>:<line> the instant it finds a byte outside that allowlist — including 0x00 (NUL), every other C0 control byte (0x01-0x09, 0x0B-0x1F), 0x7F (DEL), and every byte >= 0x80.

This exists because the bash acceptance test and tests/integration/AUR-019_test.go are two independent readers of the same flat key: value files, and a byte one of them treats as ordinary line content while the other treats as a boundary is exactly where they diverge. The clearest instance is 0x00: this card's acceptance engine runs inside a BusyBox userland (the pinned bootstrap-readonly-v1 image, currently bash@sha256:ae4668c2...), and BusyBox's awk — which both check_canonical_form and flat_value are built on — treats an embedded NUL byte as if it split the record, not as ordinary data. A single physical line that appends \x00capability_structured_output: supported\x00fixture_structured_output: <path> onto the end of a real key's value (with the genuine capability_structured_output/fixture_structured_output lines deleted) is therefore not one line to BusyBox awk — it reads as three, and each of those three is a clean, canonical key: value declaration, so the awk-based readers would wave the poisoned line through as three legitimate ones. tests/integration/AUR-019_test.go's parseFlatYAMLAUR019 never makes that mistake: Go's strings.Split only ever breaks on a real \n, so the same bytes read as a single line whose value is never recognized as declaring capability_structured_output at all, and go test fails where the bash gate had passed. This is the same shape of bug the prior "ASCII-only" gate (check_ascii_only, [\x80-\xff] only) was built to close for NBSP/NEL — but that gate's own range never covered 0x00, so it could not see this byte at all.

The fix deliberately does not enumerate byte ranges to reject (chasing NUL, then VT, then CR, then the next byte some reader treats specially, one permutation at a time — the same chase that shipped the prior rounds' bypasses). It inverts the problem: only two things are ever allowed (0x0A, 0x20-0x7E), so no byte any reader could disagree about ever reaches check_canonical_form, flat_value, or the provider-matrix walk. Detection itself deliberately avoids awk and grep: under this BusyBox userland those are exactly the tools an embedded NUL can fool. tr, wc, and od were verified against the pinned image to treat NUL as ordinary data with no special splitting behavior, so check_allowed_bytes compares the file's total byte count (wc -c) against the count that survives deleting every allowed byte (tr -cd '\012\040-\176') to detect any disallowed byte, and od -An -v -tu1 -w1 (never eliding repeats, one byte per row) to recover the first offending byte's exact value and line.

A CRLF line ending (\r\n) in either file is directly covered by this gate: \r is 0x0D, which is not in the allowlist, so a CRLF-saved file now fails malformed-entry at check_allowed_bytes itself rather than surviving to fail later, differently, on a per-key value mismatch downstream.

Fixture canonical-form invariant

tests/specs/AUR-019/fixtures/<slug>/<capability>.json follows one strict canonical shape, enforced by the acceptance gate and never re-formatted after the fact: two-space indent per nesting level, one member or element per line, every object's keys in strictly ascending order, no non-ASCII byte (a non-ASCII source character comes out \uXXXX-escaped instead). This shape is checked at acceptance time only, inside the sealed bootstrap-readonly-v1 container, by check_fixture_canonical_form; there is no Python script and the container has no python3.

tests/acceptance/AUR-019.sh's check_fixture_canonical_form is what actually enforces that shape inside the sealed container, and it replaces a prior version of this gate that never did: that version pulled the fixture's "capability"/"provider" binding out with grep -m1 -E '"capability"[[:space:]]*:' | sed ... and the equivalent for "provider" — two greps for two substrings, never a JSON parse. Two concrete inputs exposed that as a real gap in the sealed accept gate itself (not just go test), both reproduced and closed by this canonical form:

  1. A file that is not JSON at all — free prose containing nothing but the two magic lines "capability": "tool_use" / "provider": "openai" — satisfied the old grep -m1 check while encoding/json.Unmarshal correctly failed it with "is not valid JSON". docs/specs/AUR-019.md's own Contract section already promised a "JSON-valid fixture"; the old gate did not check that promise, it assumed it.
  2. A file that is syntactically valid JSON with the "provider" key declared twice (RFC 8259 permits this) — grep -m1 takes the first occurrence, encoding/json.Unmarshal (like every conforming decoder) takes the last. A fixture whose first "provider" was correct and second was not passed the old sealed gate while go test read a different provider for the same file.

check_fixture_canonical_form closes both by validating the entire file, line by line, against the canonical grammar above instead of grepping two lines out of it: a strictly-ascending, duplicate-free key order at every nesting depth is required, so free-form prose fails on line 1 (not a bare {) and a duplicate key fails as soon as the second occurrence breaks the ascending-order invariant — the same check that enforces "a fixed key order" also is how a duplicate key is caught, with no second, separate pass to fall out of sync with it. The top-level "capability" and "provider" keys are additionally required to exist and to hold a plain JSON string, not an object, array, number, boolean, or null — the same requirement struct{ Capability, Provider string } plus json.Unmarshal imposes on the Go side. Because every fixture this gate accepts has exactly one legal parse, canonical_top_string_value (which replaces the old grep -m1/sed extraction) can safely read "capability"/"provider" with a single anchored match: there is no non-JSON or duplicate-key input left that could make its answer disagree with what encoding/json.Unmarshal decodes for the same field. check_allowed_bytes runs on every fixture before check_fixture_canonical_form, for the same reason it runs before check_canonical_form on a standards/providers/<slug>.yaml file: an embedded NUL is exactly the kind of byte this BusyBox awk could treat as a record boundary no other reader agrees with, and it must never reach an awk-based reader before that gate has ruled it out.

This does not attempt a general-purpose JSON parser in awk — the pinned container has no jq and no python3, and a hand-rolled full JSON parser in awk under an adversarial-review card is exactly the kind of "chasing the next permutation" this card's byte-allowlist section already explains the cost of. It instead narrows the accepted input to one generated, deterministic shape and refuses anything that is not exactly that shape, the same inversion check_allowed_bytes already applies to raw bytes.

The shell grammar also validates comma position for closing objects and arrays: an object/array close may carry a comma only when another sibling follows, and the root close may never carry one. Scalar values are restricted to JSON strings, the full JSON number grammar, true, false, or null; bare words such as BROKEN, NaN, and Infinity are rejected. encoding/json provides the same invalid-token and trailing-comma rejection in the Go reader.

Fixture wire-binding invariant

check_fixture_canonical_form (and encoding/json.Unmarshal on the Go side) proves a fixture is well-formed JSON with the required top-level "capability"/"provider" string fields. It does not, by itself, prove the fixture's content actually describes the provider it claims to: nothing before this invariant compared a fixture's recorded request against the same provider's own standards/providers/<slug>.yaml contract, so a fixture consisting of one real provider's entire recorded request, with only its "provider" string edited to name a different provider, passed both readers -- standards/providers/openai.yaml could go on declaring wire_endpoint: /v1/chat/completions and auth_header: Authorization while tests/specs/AUR-019/fixtures/openai/tool_use.json was, byte for byte, Anthropic's real /v1/messages request with x-api-key/anthropic-version headers, and nothing checked the two against each other. That falsified this card's own Outcome ("cada provider tem wire format, auth, erro e capabilities versionados", with the fixture as the proof) without touching any byte outside tests/specs/AUR-019/.

A fixture's own standards/providers/<slug>.yaml declares a transport, either http or sdk-call. transport decides which fields the fixture must carry — never the other way around. An earlier round of this card bound wire format "when the fixture's request object declares the field", which meant a fixture could opt itself out of the entire binding just by omitting request.method/request.path/request.headers: a review round reproduced that as a live bypass by deleting those three keys from an HTTP-shaped body borrowed whole from a different provider (Anthropic's real /v1/messages request, relabeled "provider": "openai") and watching both engines accept it. Deleting a field can no longer opt a fixture out of a check; only its own provider's declared transport decides what is required, and that declaration is itself restricted to a closed, undecided-provider-checked vocabulary (http/sdk-call), not free text a fixture author also controls.

transport: http (every provider except LiteLLM) requires, and cross-checks, three fields, all unconditionally present or the fixture fails typed unfixtured-capability:

  • request.method must be present and equal the provider's wire_method exactly.
  • request.path must be present and match a pattern derived from the provider's wire_endpoint: every {placeholder} segment (e.g. {model}, {deployment-id}) becomes a wildcard for one real value, every other character must match literally. Capability-specific action suffixes are declared as wire_action_<capability> and are compared exactly, so Gemini's {model}:generateContent for tool_use/structured_output and {model}:streamGenerateContent for streaming cannot be interchanged. The match is anchored at a real end boundary, not merely a prefix: after the resource segment (or the placeholder's greedy run), only end-of-string, a literal ? (a query string), or — only for the streaming capability's own fixture, and only when the provider's standards/providers/<slug>.yaml declares a wire_streaming_suffix (Bedrock: -stream, for its /converse vs /converse-stream pair) — that exact literal suffix is required. /v1/chat/completions therefore does not match /v1/chat/completions-but-actually-something-else: neither terminator branch matches at the position -but-actually-something-else starts. An earlier round anchored this pattern only at ^, which made "prefix" mean "starts with", not "is", and a review round reproduced that gap by appending arbitrary literal text directly onto an otherwise-correct literal endpoint.
  • request.headers is required, and must contain a key equal to the provider's auth_header, only when that same provider's standards/providers/<slug>.yaml declares auth_binding: header (OpenAI, Anthropic, Azure), and every such value must be a JSON string. query-param requires the declared auth_query_param in the request path (Gemini's ?key=...); signed-request requires the declared non-secret auth_evidence string in request.auth (Bedrock's AWS SigV4); and none is Ollama's explicit no-auth default. auth_binding is restricted to exactly these four values, checked the same way transport is. request.headers: null fails the same "not a real recording" way as an absent header block: tests/integration/AUR-019_test.go re-decodes the field into a map[string]string before checking for the auth_header key, and decoding a JSON null into that map yields nil with no error, so the membership check that follows fails exactly as it would for a genuinely absent header block.

transport: sdk-call (LiteLLM only, whose documented completion() call is not itself an HTTP request) requires, and cross-checks, the two fields that describe an SDK call instead of a wire request, with the same "absent = typed failure" rule:

  • request.call must be present and equal the provider's own wire_call value (litellm.completion) exactly.
  • request.params must be present and be a JSON object (even an empty {}, if a future fixture ever needed it empty). A JSON null is treated as absent, not present: it is functionally the same "no object recorded" case this field exists to catch, just spelled with the key kept and the value hollowed out instead of the key deleted. Both engines enforce this the same way for request.params specifically: tests/acceptance/AUR-019.sh matches only a literal "params": { opener (so "params": null never matches and fails typed unfixtured-capability), and tests/integration/AUR-019_test.go re-decodes the field into a real Go map rather than only checking it against Go's zero value — decoding null into a map yields nil with no error, so it is caught by the same "no request.params object" check an absent field hits, not treated as presence. A JSON string, number, boolean, or array in that slot (for example the quoted string "null", which is valid JSON and not the same value as the literal null) is a third, distinct case: present, but not an object, so it fails with a type error rather than an absence message.

Because both transports now require and cross-check their own fields unconditionally, a fixture can no longer hide a borrowed body by deleting the fields that would have exposed it: an http fixture with no request.method/path/headers fails before its request.body is ever compared to anything, and an sdk-call fixture with no request.call/ params fails the same way. This is not restricted to LiteLLM's slot or to any one provider — every provider's fixture is bound by whichever transport its own standards/providers/<slug>.yaml declares, verified the same way for all seven.

A method, path, header, call, or params mismatch (or absence) fails with the same typed AUR-019/AC-001/unfixtured-capability code the "capability"/"provider" field checks already use — it is the same class of proof (a fixture that does not actually attest to what it claims), not a new one. An unrecognized transport or auth_binding value, or a sdk-call provider missing wire_call, fails typed AUR-019/AC-001/undecided-provider — the same code an incomplete standards/providers/<slug>.yaml file already uses, since this is the same kind of defect: the provider's own contract is not fully decided.

Diagnosis

  • AUR-019/AC-001/malformed-entry: disallowed byte 0xNN at <path>:<line> — a standards/providers/<slug>.yaml file or .board/research/providers.md contains a byte outside the printable-ASCII-plus-LF allowlist (NUL, another C0 control byte, DEL, or any byte >= 0x80). Re-save the file as plain ASCII with LF line endings; see "Byte-allowlist invariant" above for why this is rejected before the file is otherwise parsed.
  • AUR-019/AC-001/malformed-entry: <file> line <n> declares key "<key>" outside its canonical "<key>: value" form — a known schema key in a standards/providers/<slug>.yaml file is written with leading whitespace, a space before the colon, a tab, or another non-canonical shape. Rewrite the line as <key>: <value> at column 0.
  • AUR-019/AC-001/malformed-entry: <file> line <n> has trailing whitespace — a standards/providers/<slug>.yaml line ends in a space. Trim it; a trailing space on an unquoted value used to be silently accepted by go test (which trims) while this gate rejected it, so this is now refused explicitly instead of surviving as an undocumented reader split.
  • AUR-019/AC-001/malformed-entry: <fixture path>: line <n> ... — a tests/specs/AUR-019/fixtures/<slug>/<capability>.json fixture is not in the canonical form check_fixture_canonical_form enforces (not valid JSON, a duplicate or out-of-order key, a non-string "capability"/"provider" value, or another shape violation). Fix the fixture's content so it matches the shape the gate verifies.
  • AUR-019/AC-001: provider baseline absent — .board/research/providers.md is missing, empty, or missing one of the seven literal provider names. Restore it from the card's owned paths. This is the card's documented RED message and is distinct from every other typed failure below.
  • AUR-019/AC-001/undecided-provider: required provider missing from matrix: <name> (or "cited twice", "has an empty criterion cell", "matrix row has N columns...") — fix the offending row in the "Provider contracts matrix" table.
  • AUR-019/AC-001/undecided-provider: standards file absent for provider <name>: <path> (or "has no value") — restore or complete standards/providers/<slug>.yaml for that provider.
  • AUR-019/AC-001/undecided-provider: provider-names fixture declares N names but only M are distinct (or the same for capabilities.txt, or "validated N distinct standards files but required exactly M") — tests/specs/AUR-019/provider-names.txt (or capabilities.txt) has a duplicated entry padding its line count, or a provider name and its lowercased slug collided with another provider's. De-duplicate the source list; every required provider (or capability) must be validated from its own distinct file, not revalidated from a stand-in.
  • AUR-019/AC-001/unsourced-provider: ... source host is not allowlisted (or "has no versioned source", "source date is not ISO 8601") — the cited source moved off the allowlist in tests/specs/AUR-019/allowed-source-domains.txt, or lost its version/date. Either fix the citation or extend the allowlist through review.
  • AUR-019/AC-001/unfixtured-capability: ... — a capability is marked supported with no matching, valid, correctly bound fixture. Either add the fixture under tests/specs/AUR-019/fixtures/<slug>/ or mark the capability unsupported until one is recorded. This code also covers a fixture that is missing, or has a mismatching value for, whichever fields its own provider's declared transport requires: for transport: http, request.method/request.path/request.headers against wire_method/wire_endpoint/auth_header; for transport: sdk-call, request.call/request.params against wire_call (see "Fixture wire-binding invariant" above) — the fixture is well-formed JSON naming the right capability and provider by label, but its content does not actually attest to that provider's own documented wire format, or omits the very fields that would let it attest to anything.
  • AUR-019/AC-001/undecided-provider: ... transport has an unrecognized value (or "auth_binding has an unrecognized value", or "has no wire_call value") — a standards/providers/<slug>.yaml file declares a transport outside http/sdk-call, an auth_binding outside header/query-param/signed-request/none for an http provider, or omits wire_call for an sdk-call provider. Fix the offending value; this is the same code an incomplete standards file already uses, because it is the same defect (the provider's own contract is not fully decided).
  • AUR-019/AC-001/infrastructure: ... — a required host tool is missing or the repository root could not be resolved; this is never a behavioral result and never counts as RED evidence for this card.

Integration selector

The card's frontmatter declares paths: [..., tests/integration/AUR-019_test.go] and TDD proof Integration: \tests/integration/AUR-019_test.go::TestAUR019Boundary`.Both the filename (AUR-019_test.go) and the function name (TestAUR019Boundary,Test-prefixed) follow the discoverablego test ./...conventiontests/integration/AUR-020_test.go::TestAUR020Boundary` already established, so this card's own regression check runs in the normal suite with no external dispatcher and no dead integration code.