For a B2B SaaS system that scores candidates against a job rubric, keep the structured score as the system of record and treat every generated scorecard image as a replaceable projection. Provider adapters can live in the Node.js application or behind an internal compatibility boundary; either placement must validate the requested model and failure class before routing.
Short answer: an OpenAI-compatible image generation contract can normalize transport across multiple providers and one API key, but it cannot define safe fallback model routing; the application owner must separately govern the routing ledger, idempotency key, fallback policy, and output validation.
This distinction matters in hiring software. A polished image can conceal a malformed score, a stale rubric version, or a retry that quietly selected another model. The image must never become evidence that the underlying structured output was correct.
1. Failure mode: a scorecard image outlives its rubric
The pipeline begins after candidate scoring has produced schema-valid JSON. That record needs stable identifiers for the candidate, rubric version, scoring run, and renderer request; the image prompt should be derived from that immutable record rather than assembled again from mutable application state. In practical terms, a reviewer may see a generated visual summary, but reconciliation compares the visual job with the original score record, never with a later copy of the prompt.
Keep the boundary strict. The renderer can lay out criteria, scores, and permitted explanatory text, but it shouldn't infer a missing score or repair an invalid rubric. If a required field is absent, stop before image generation. A second model call is not validation.
A compact envelope makes the invariants visible. The application may run on Node.js, while this Go contract can serve as an executable specification for a routing service or a cross-language contract test:
package imagejob
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
)
type Job struct {
CandidateID string
RubricVersion string
ScoringRunID string
Prompt string
Model string
IdempotencyKey string
}
type Asset struct {
URI string
ContentSHA256 string
Provider string
Model string
}
type Generator interface {
Generate(context.Context, Job) (Asset, error)
}
func NewJob(candidateID, rubricVersion, runID, prompt, model string) (Job, error) {
if candidateID == "" || rubricVersion == "" || runID == "" || prompt == "" || model == "" {
return Job{}, errors.New("incomplete image job")
}
sum := sha256.Sum256([]byte(candidateID + "\x00" + rubricVersion + "\x00" + runID + "\x00" + prompt + "\x00" + model))
return Job{
CandidateID: candidateID, RubricVersion: rubricVersion, ScoringRunID: runID,
Prompt: prompt, Model: model, IdempotencyKey: hex.EncodeToString(sum[:]),
}, nil
}
The hash is a deduplication identity, not an encryption scheme and not permission to place sensitive candidate data in logs. Store the minimum audit fields allowed by the organization's retention policy; access controls, deletion obligations, and review requirements remain deployment-specific, and counsel must determine the applicable compliance boundary.
2. What must a Node SDK record before compatible text-to-image fallback?
Route by declared capability first, then by an explicit policy version. An OpenAI-compatible client shape can make the initial request familiar, and one internal API key can simplify credential distribution to the Node.js service, but neither property establishes that two providers accept the same model name, dimensions, output representation, moderation behavior, or request limit. The gateway therefore needs a catalog that maps a logical model class to eligible provider-model pairs without leaking those pairs throughout application code.
Seven checks belong before dispatch:
- Confirm that the rubric JSON passed its schema and domain validation.
- Confirm that the prompt was derived from the recorded scoring run and rubric version.
- Resolve the logical model through a versioned catalog rather than accepting an arbitrary provider model from a user.
- Bind the idempotency key to the semantic request, not to a single network attempt.
- Record the chosen route and policy version before dispatch.
- Permit fallback only for failure classes named in policy.
- Verify the returned media type, content digest, size limit, and provenance fields before publication.
That sequence provides exactly-once effects without pretending the network offers exactly-once delivery. Attempts may occur more than once; the durable publication record must occur once for one idempotency key. This is the same distinction that matters in a payment ledger: retries are normal, duplicate business effects are not.
Here is the routing core, deliberately independent of any commercial SDK or endpoint:
package imagejob
import (
"context"
"errors"
)
var ErrTemporarilyUnavailable = errors.New("temporarily unavailable")
type Route struct {
Name string
Model string
Client Generator
}
type Audit interface {
Attempt(ctx context.Context, idempotencyKey, policyVersion, route, model string) error
Publish(ctx context.Context, idempotencyKey string, asset Asset) error
}
func GenerateWithPolicy(ctx context.Context, job Job, policyVersion string, routes []Route, audit Audit) (Asset, error) {
for _, route := range routes {
attempt := job
attempt.Model = route.Model
if err := audit.Attempt(ctx, job.IdempotencyKey, policyVersion, route.Name, route.Model); err != nil {
return Asset{}, err
}
asset, err := route.Client.Generate(ctx, attempt)
if err == nil {
if asset.URI == "" || asset.ContentSHA256 == "" || asset.Provider == "" || asset.Model == "" {
return Asset{}, errors.New("invalid asset envelope")
}
if err := audit.Publish(ctx, job.IdempotencyKey, asset); err != nil {
return Asset{}, err
}
return asset, nil
}
if !errors.Is(err, ErrTemporarilyUnavailable) {
return Asset{}, err
}
}
return Asset{}, ErrTemporarilyUnavailable
}
The important line is the error classification. A timeout-like, policy-approved transient condition may justify trying the next eligible route; an invalid prompt, rejected content, unknown model, authentication failure, or malformed response should normally stop, because changing providers could evade a control or turn a deterministic defect into an expensive sequence of calls. HTTP status alone is insufficient unless the contract defines its meaning, so normalize transport outcomes at each adapter and test that mapping.
I'm not sure a static route order is ever sufficient for a long-lived production system. Provider capabilities and organizational approvals change; what resolves that uncertainty is a versioned catalog, conformance tests against every enabled adapter, and a reviewable policy change, not a heuristic embedded in application code.
3. Prove the routing state machine under interruption
Most happy-path tests prove only that bytes came back. For candidate scorecards, the harder assertions concern causality: the published asset corresponds to the same candidate, scoring run, rubric version, prompt digest, routing policy, provider, and model recorded in the audit trail. A retry after a process restart must find the prior publication by idempotency key and return it instead of publishing a second asset.
Test it harshly.
A useful test matrix separates contract, policy, and effect. Contract tests feed every adapter the same valid and invalid envelopes, then verify normalized outcomes. Policy tests establish which normalized outcomes may advance to a fallback route. Effect tests interrupt execution after the attempt record, after generation, and during publication; each replay must converge on one visible asset record, even when several physical calls occurred. Use synthetic candidates and fictional rubric text in these tests so diagnostic artifacts do not become a shadow store of hiring data.
Observability should follow the same model. Count requests by logical model, policy version, normalized outcome, and selected route; measure latency per attempt and end to end; alert on catalog misses, publication conflicts, and unexpected fallback-rate changes. Don't put prompts, candidate names, free-form recruiter notes, raw credentials, or generated image bytes into general-purpose telemetry. Correlation identifiers are enough to join authorized records during an investigation.
This is also where structured output correctness returns as the primary decision axis. A route that produces attractive images but cannot preserve the validated rubric fields is ineligible. Visual quality is secondary to faithful rendering, accessibility, and a reversible link to the authoritative JSON.
4. Migrate by reconciliation, with explicit stop conditions
Before migration, select one of three defensible ownership boundaries; none wins universally:
| Boundary | Useful when | The catch |
|---|---|---|
| Direct provider adapters in the Node.js service | The approved catalog is small and the team wants explicit control | Credential rotation, response normalization, and policy logic live in every service instance |
| An internal compatibility gateway | Several applications need one contract, one key boundary, and centralized audit policy | The organization owns a critical control plane and must operate its catalog and adapters |
| A self-hosted job worker behind a queue | Rendering is asynchronous and backpressure matters more than request latency | More infrastructure and reconciliation work are required |
The principal limitation of an internal gateway is operational ownership: it is not suitable when a single provider is contractually mandated and no second route is approved; direct integration is then easier to audit. A queued worker is a poor fit when the product genuinely requires an immediate image in the request path. Conversely, direct adapters become difficult to justify when many services would independently reproduce the same credential, catalog, fallback, and audit logic. This trade-off must be decided from the approved provider set, latency objective, and team's capacity to operate a control plane.
Rollout should be compact: shadow the decision logic without sending duplicate generation calls, compare the selected logical route with the current route, enable one non-sensitive scorecard class, and reconcile every published asset against its source scoring run. Expansion follows only after catalog misses, duplicate-publication conflicts, and invalid asset envelopes remain within limits set by the owning team. Your mileage may vary — especially where retention rules prohibit generated candidate artifacts entirely.
No image belongs in the hiring decision record unless a reviewer can trace it back to validated structured data and reproduce the routing decision under the recorded policy version.
References
- OpenAI, “Embeddings guide”: https://platform.openai.com/docs/guides/embeddings
- pgvector, “Open-source vector similarity search for Postgres”: https://github.com/pgvector/pgvector
These sources describe adjacent retrieval components, not evidence for an image-generation endpoint. They are relevant only if rubric criteria or approved prompt fragments are retrieved before the validated scoring record is created; keep that retrieval stage outside the rendering contract.
Top comments (0)