Strict structured-output schemas¶
Adapters parse model-emitted structured output and merge it into stored artefacts. A lenient parse opens two failure modes:
- Hallucinated keys land in storage and break downstream consumers that did not expect them.
- AI updates overwrite operator-owned fields (notes, tags,
id).
The strict contract closes both. The shared primitives live in bernstein.adapters.strict_schema.
The strict contract¶
Every schema that parses AI output forbids extra keys.
- JSON Schema artefacts (the per-phase schemas in
bernstein.core.orchestration.phase_schemas) declareadditionalProperties: falseon every object node. Useseal_schemato produce a sealed copy andassert_schema_sealedto prove a schema cannot accept undeclared keys. - Dataclass-backed payloads (the refinement
Critiqueinbernstein.core.orchestration.refinement_schemas) expose afrom_dict_strictparse path. A hallucinated top-level key, or a hallucinated key inside any nested issue, raisesSchemaViolationrather than being silently dropped.
A SchemaViolation is deterministic, so the retry path treats it as non-transient and bounds its attempts.
User-owned field registry¶
Some fields are managed by operators or by the store, never by AI output. USER_OWNED_FIELDS is the global floor:
| Field | Owner |
|---|---|
user_notes | operator |
operator_overrides | operator |
id | store |
created_at | store |
A schema may add to this floor through UserOwnedFieldRegistry.register. Per-schema entries are unioned with the floor, so a registration can never weaken the global protection.
Stripping AI writes¶
Before an AI update reaches the merge layer, pass it through strip_user_owned_fields:
from bernstein.adapters.strict_schema import strip_user_owned_fields
safe_update, rejected = strip_user_owned_fields(schema_id, ai_update)
if rejected is not None:
# rejected names the schema and the stripped operator-owned fields.
audit_log.record(rejected)
merge(safe_update)
Any blacklisted key is removed from a copy of the update. When at least one field is stripped, an AIWriteRejected{schema, fields} event is logged and returned so the audit surface can attribute the attempt.
Provider error classification¶
A provider may reject a structured-output request because the response carried undeclared keys. classify_schema_violation maps those messages to a SchemaViolation carrying any field names lifted from the message:
from bernstein.adapters.strict_schema import classify_schema_violation
violation = classify_schema_violation(provider_error)
if violation is not None:
# Deterministic schema fault: do not retry without changing the payload.
raise violation
Unrelated errors return None so the caller falls back to its general error path.
Fixtures¶
tests/fixtures/adapter_outputs/ holds strict-clean fixtures that must parse under the strict contract. Payloads that carried previously-tolerated extras live under tests/fixtures/adapter_outputs/legacy/; the migration test asserts they are rejected under strict mode.