Constraints
Retryable semantic assertions for generated output.
Constraints enforce semantic requirements after the model has produced an output candidate. Use a constraint when the output is structurally valid but needs a retry because it fails a business, quality, grounding, or style rule.
import { boundary, constraint } from "@use-crux/core/safety";
export const citeSources = constraint({
id: "cite-sources",
on: boundary.output.text(),
run: async (text) =>
text.includes("[1]")
? { pass: true }
: { pass: false, feedback: "Include at least one citation." },
});Attach constraints globally with createSafetyPlugin(), on prompts and
contexts, or per call with constraints: [...].
Authoring
Every constraint has a stable id, one output boundary, and a
run(subject, ctx) callback. The boundary drives the subject type:
type Answer = {
answer: string;
citations: { title: string; url: string }[];
};
const grounded = constraint({
id: "grounded-answer",
on: boundary.output.object<Answer>(),
maxRetries: 2,
run: async (object) =>
object.citations.length > 0
? { pass: true }
: { pass: false, feedback: "Return at least one source citation." },
});Use boundary.output.object<T>().path('field.path') when one structured field
owns the rule:
const titleLength = constraint({
id: "title-length",
on: boundary.output.object<Answer>().path("answer"),
run: async (answer) =>
answer.length <= 280
? { pass: true }
: { pass: false, feedback: "Keep the answer under 280 characters." },
});Result shape:
return { pass: true, metadata: { score: 0.96 } };
return { pass: false, feedback: "Explain what must change." };Malformed results fail closed with SafetyResultError, including JavaScript
callers that bypass the TypeScript discriminated union.
Execution
Constraints run through the same per-call Safety session as guardrails. The session applies output guardrails first, so constraints judge the protected candidate, not raw model text. If any assert constraint fails, Crux combines the feedback, asks the model to regenerate, guards the new candidate, and checks constraints again.
Attempt 1 -> canonical z.input -> output guardrails -> authored safeParse once
-> constraints fail
Retry feedback -> regenerate -> output guardrails -> authored safeParse once
-> constraints pass -> publish z.outputAll constraints in a round run concurrently. The model sees all failure feedback at once instead of one retry per constraint.
Semantic-cache candidates take the same release path: current output
guardrails run over a private canonical z.input, the authored schema parses
exactly once, then constraints run before the cached z.output can be
published. A rejected or legacy cache entry falls through to one live provider
call. Cache rejection itself never sends corrective feedback to the provider;
feedback is guarded and written back only after a live attempt is eligible to
retry.
Severity And Retry Budget
| Option | Default | Meaning |
|---|---|---|
severity: 'assert' | yes | Failure triggers retry and eventually ConstraintViolationError. |
severity: 'suggest' | no | Failure is recorded, but the final output can still return. |
maxRetries | 2 | Per-constraint retry budget. |
constraintMaxRetries | unset | Shared per-call cap across all constraints. |
const formalTone = constraint({
id: "formal-tone",
on: boundary.output.text(),
severity: "suggest",
run: async (text) =>
text.includes("gonna")
? { pass: false, feedback: "Use formal wording." }
: { pass: true },
});
await adapter.generate(prompt, {
input,
constraints: [citeSources, formalTone],
constraintMaxRetries: 3,
});Built-in Strategies
Use constraint.judge(...) to turn a scoring judge into a retryable Safety
constraint:
import { judge } from "@use-crux/core/scoring";
import { boundary, constraint } from "@use-crux/core/safety";
const brandJudge = judge({
id: "brand-voice",
criteria: "Does the answer match our direct, warm brand voice?",
scale: { min: 1, max: 10 },
generate: generateObjectFn,
model,
});
export const brandVoice = constraint({
id: "brand-voice",
on: boundary.output.text(),
run: constraint.judge({ judge: brandJudge, minScore: 7 }),
});Use constraint.citations(...) when the output must cite retrieved or injected
sources. Both helpers carry safe strategy metadata for observability, Devtools,
and Project Index.
Completed operations
transcribe() accepts constraints only at boundary.output.text(). They run
once against the guarded top-level transcript. An assert failure throws;
a suggest failure is retained in result.safety. The provider is not re-called
and constraintMaxRetries is intentionally absent from transcription options.
generateImage() and generateSpeech() do not expose constraints because
their required outputs have no constraint boundary. A typed image prompt that
resolves any constraints fails before provider I/O; remove those constraints
or move the semantic requirement to an ordinary language generation.
Transcription constraints inspect only the guarded top-level transcript text,
never timed details. An enforced rewrite clears segments and words in the
returned canonical result so callers cannot mistake stale timing for guarded
text. See the full
content-primitive matrix.
Report Mode And Tuning
Constraints do not have a mode field on their definition. Use per-call
safety.tune when you want to shadow a constraint:
await adapter.generate(prompt, {
input,
safety: {
tune: {
"brand-voice": { mode: "report" },
},
},
});Report-mode constraints run and audit decisions but do not retry, block, or change output. Unknown tune ids throw.
Streaming
An assert constraint is transactional on a stream: it commits the attempt.
While it is unresolved the stream withholds output, so nothing you would have to
take back is ever shown. When it passes, the buffered prefix releases and the rest
of that same response continues streaming. When it fails, the attempt is discarded
— the consumer saw none of it — and Crux re-streams with corrective feedback,
sharing the maxSteps budget with validation retry. If retries run out,
ConstraintViolationError is thrown having published nothing.
Release is held only as long as the gate needs, and the boundary decides how long that is:
| Boundary | Unlocks |
|---|---|
.object<T>().path('x') (scalar) | as soon as that value completes — the prefix flows well before the response ends |
.object<T>().path('xs').items() | as each array item closes |
.object<T>() (root) | at completion |
| a missing optional path | never holds — vacuously satisfied |
boundary.output.text() / .both<T>() | only at end-of-stream |
A text or composite assert can only be judged on the finished text, so while
one is attached nothing streams: every delta is withheld and the whole
response is released at once when the constraint passes. If you need tokens to
flow early, put the assert on a scalar path and use a suggest constraint
(or a guardrail) for whole-text rules.
suggest constraints never gate: they run report-only and audit their decision.
const title = constraint({
id: "title-nonempty",
on: boundary.output.object<Article>().path("title"),
// Holds the stream until `title` completes, then releases or retries.
run: (value) =>
value.length > 0
? { pass: true }
: { pass: false, feedback: "Title required." },
});When output is being withheld, the stream directive carries a content-free
bufferedBy reason so a pause can be explained without ever seeing the held bytes.
It is one of six values — 'boundary', 'guardrail', 'serialization',
'constraint', 'validation-retry', 'adapter' — and when several gates are active
the highest-precedence one is reported, in that order, so an attempt-level gate never
hides behind a local one. It is absent when a hold names no reason.
This travels on the openStream().feed() directive, which is the adapter-author
protocol; it is not yet surfaced as an application-level stream event.
Crux never puts model output into telemetry, but your feedback string is
recorded on the constraint's span — it is the policy's own prose and is needed
to explain a retry. Write feedback as instructions ("Title must not be
empty"), and do not interpolate the model's output into it.
A constraint the stream already settled is not re-run at completion, so a
constraint.judge() costs exactly one call — unless a later rewrite changed the
value it checked, in which case it is re-evaluated on the new value.
Errors And Audit
ConstraintViolationError is policy-terminal. It carries the failing
constraints, total attempts, last guarded output, and safe Safety decision
metadata. Raw failed output is not exposed by default.
import { ConstraintViolationError } from "@use-crux/core/safety";
try {
await adapter.generate(prompt, { input, constraints: [citeSources] });
} catch (error) {
if (error instanceof ConstraintViolationError) {
error.failedConstraints;
error.decisions[0]?.captured.preview;
}
}Use evaluateConstraint() for focused unit tests. In Evals, prefer
decision-report assertions when the contract you care about is the runtime
Safety decision:
ctx.expect.decisionReport.safety.toHaveOutcome("cite-sources", "retry");