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:
.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 matchingstandards/providers/<slug>.yamlrecord.standards/providers/<slug>.yaml, one per required provider (slugis the lowercase provider name) — a flat key/value record of the same provider'stransport(httporsdk-call), wire endpoint/method/ content-type, auth scheme/header/binding/format (or, forsdk-call,wire_call), error envelope/taxonomy, and acapability_<name>: supported|unsupportedentry for every capability intests/specs/AUR-019/capabilities.txt.tests/specs/AUR-019/fixtures/<slug>/<capability>.json— a recorded, non-secret fixture for everycapability_<name>: supportedentry, bound to its file by matching"capability"/"provider"JSON fields and by matching the fields its own provider's declaredtransportrequires (request.method/request.path/request.headersfortransport: http;request.call/request.paramsfortransport: sdk-call) against that same provider'swire_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.txtandtests/specs/AUR-019/allowed-source-domains.txtare the single source of truth for, respectively, the seven required provider names, the tracked capability keys, and the hostnames asource:/matrixSourcelink may cite. Both the shell acceptance test andtests/integration/AUR-019_test.goread these three files instead of duplicating the lists.provider-names.txtmust declare exactly 7 entries andcapabilities.txtexactly 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, duplicatedprovider-names.txtfails typedAUR-019/AC-001/undecided-providerbefore either reader drives a single downstream check off it. Both readers additionally tally the distinctstandards/providers/<slug>.yamlpaths 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
versionmust match a semantic-version-like pattern (v2.53.0) or a dated API-version string (2024-10-21, the shape Azure's ownapi-versionquery parameter uses);dateis 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
vfor release tags); mutable/latest, branch,main,master,trunk, andHEADreferences are rejected. - Every
capability_<name>: supportedentry in astandards/providersfile must have a matchingfixture_<name>:entry pointing at an existing, non-empty, JSON-valid fixture whose owncapabilityandproviderfields match the declaration; a mismatch or absence fails with the typed codeAUR-019/AC-001/unfixtured-capability. "JSON-valid" is enforced, not assumed: see "Fixture canonical-form invariant" below for how the sealedacceptgate actually parses every fixture as JSON instead of trusting two grepped substrings. - The
fixture_<name>value must be exactlytests/specs/AUR-019/fixtures/<provider-slug>/<name>.json. Absolute paths,.., decoy descendants,.flatsidecars, final symlinks, and symlinked ancestors are rejected by both readers. The fixture's top-levelsourcemust 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/providersfile missing a required flat key fails withAUR-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/providersfile — fails withAUR-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.Provideragainst 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:
- A file that is not JSON at all — free prose containing nothing but the
two magic lines
"capability": "tool_use"/"provider": "openai"— satisfied the oldgrep -m1check whileencoding/json.Unmarshalcorrectly 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. - A file that is syntactically valid JSON with the
"provider"key declared twice (RFC 8259 permits this) —grep -m1takes 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 whilego testread 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.methodmust be present and equal the provider'swire_methodexactly.request.pathmust be present and match a pattern derived from the provider'swire_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 aswire_action_<capability>and are compared exactly, so Gemini's{model}:generateContentfortool_use/structured_outputand{model}:streamGenerateContentforstreamingcannot 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 thestreamingcapability's own fixture, and only when the provider'sstandards/providers/<slug>.yamldeclares awire_streaming_suffix(Bedrock:-stream, for its/conversevs/converse-streampair) — that exact literal suffix is required./v1/chat/completionstherefore does not match/v1/chat/completions-but-actually-something-else: neither terminator branch matches at the position-but-actually-something-elsestarts. 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.headersis required, and must contain a key equal to the provider'sauth_header, only when that same provider'sstandards/providers/<slug>.yamldeclaresauth_binding: header(OpenAI, Anthropic, Azure), and every such value must be a JSON string.query-paramrequires the declaredauth_query_paramin the request path (Gemini's?key=...);signed-requestrequires the declared non-secretauth_evidencestring inrequest.auth(Bedrock'sAWS SigV4); andnoneis Ollama's explicit no-auth default.auth_bindingis restricted to exactly these four values, checked the same waytransportis.request.headers: nullfails the same "not a real recording" way as an absent header block:tests/integration/AUR-019_test.gore-decodes the field into amap[string]stringbefore checking for theauth_headerkey, and decoding a JSONnullinto that map yieldsnilwith 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.callmust be present and equal the provider's ownwire_callvalue (litellm.completion) exactly.request.paramsmust be present and be a JSON object (even an empty{}, if a future fixture ever needed it empty). A JSONnullis 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 forrequest.paramsspecifically:tests/acceptance/AUR-019.shmatches only a literal"params": {opener (so"params": nullnever matches and fails typedunfixtured-capability), andtests/integration/AUR-019_test.gore-decodes the field into a real Go map rather than only checking it against Go's zero value — decodingnullinto a map yieldsnilwith 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 literalnull) 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>— astandards/providers/<slug>.yamlfile or.board/research/providers.mdcontains 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 astandards/providers/<slug>.yamlfile 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— astandards/providers/<slug>.yamlline ends in a space. Trim it; a trailing space on an unquoted value used to be silently accepted bygo 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> ...— atests/specs/AUR-019/fixtures/<slug>/<capability>.jsonfixture is not in the canonical formcheck_fixture_canonical_formenforces (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.mdis 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 novalue") — restore or complete standards/providers/<slug>.yamlfor that provider.AUR-019/AC-001/undecided-provider: provider-names fixture declares N names but only M are distinct(or the same forcapabilities.txt, or "validated N distinct standards files but required exactly M") —tests/specs/AUR-019/provider-names.txt(orcapabilities.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 intests/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 markedsupportedwith no matching, valid, correctly bound fixture. Either add the fixture undertests/specs/AUR-019/fixtures/<slug>/or mark the capabilityunsupporteduntil one is recorded. This code also covers a fixture that is missing, or has a mismatching value for, whichever fields its own provider's declaredtransportrequires: fortransport: http,request.method/request.path/request.headersagainstwire_method/wire_endpoint/auth_header; fortransport: sdk-call,request.call/request.paramsagainstwire_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") — astandards/providers/<slug>.yamlfile declares atransportoutsidehttp/sdk-call, anauth_bindingoutsideheader/query-param/signed-request/nonefor anhttpprovider, or omitswire_callfor ansdk-callprovider. 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.