Day 3 — Scripted First, Live Provider Second
Today Brandforge gains a replaceable producer for BusinessProfile. The required implementation reads
a saved model response. The optional implementation calls a live provider. Both must return the same
domain type, and neither gets permission to write campaign artifacts.
Why the fixture comes first
Section titled “Why the fixture comes first”A live request mixes several variables:
- network and authentication;
- provider and model release;
- prompt version;
- sampling variation;
- response decoding;
- your validation logic.
Start with fixtures/brandforge/business-profile.json so the last two can be tested independently.
Once those are correct, a provider failure cannot masquerade as a Rust bug.
Define the capability
Section titled “Define the capability”pub trait ProfileModel: Send + Sync { fn extract(&self, source: &str) -> Result<BusinessProfile, ModelError>;}The trait describes what Brandforge needs, not every feature a vendor offers. It does not expose image generation, web search, conversation IDs, or provider-specific response objects.
A fixture adapter can own saved JSON:
pub struct ScriptedProfileModel { response: Vec<u8>,}
impl ProfileModel for ScriptedProfileModel { fn extract(&self, _source: &str) -> Result<BusinessProfile, ModelError> { let profile = serde_json::from_slice(&self.response)?; Ok(profile) }}Notice the scripted adapter does not pretend to understand the source. Its job is to make the rest of the pipeline deterministic.
Compile a provider request at the edge
Section titled “Compile a provider request at the edge”For a live adapter, send:
- an instruction that defines the extraction task;
- the frozen source snapshot;
- a strict JSON schema matching
BusinessProfile; - an explicit model supplied by configuration;
- a timeout and bounded retry policy.
Do not hard-code an API key. Read it at startup, return a configuration error if it is absent, and redact it from traces.
The current OpenAI API supports structured output through the Responses API. The provider schema is a
wire contract: every field is required and objects disable additional properties. Keep the Rust domain
separate so a future provider adapter can produce the same BusinessProfile.
Handle more than HTTP status
Section titled “Handle more than HTTP status”A successful HTTP response is not necessarily a usable profile. The adapter must distinguish:
- missing credentials;
- transport failure;
- timeout;
- non-success HTTP status;
- refusal;
- incomplete output;
- malformed JSON;
- schema-valid but domain-invalid output.
These states deserve different error variants because retrying an invalid palette is not the same as retrying a connection reset.
Break it deliberately
Section titled “Break it deliberately”Build a FailingProfileModel whose extract returns ModelError::Timeout. Assert that Brandforge:
- creates no campaign artifact;
- does not silently fall back to invented data;
- reports the failure at the provider boundary;
- does not retry beyond the configured budget.
Then return valid JSON with an unsupported claim. That request succeeds at the provider boundary and must fail tomorrow at the evidence boundary.
What you built
Section titled “What you built”You used a trait to isolate model variance and a scripted adapter to make AI software testable. The important architecture is not “call an API.” It is probabilistic producer → typed domain → independent validator.
Deep references: Provider-neutral capability traits and Remote, embedded, or service inference.
Next: Day 4 — Ground Every Claim →.