Crux
GuidesSafety

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.

constraints.ts
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.output

All 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

OptionDefaultMeaning
severity: 'assert'yesFailure triggers retry and eventually ConstraintViolationError.
severity: 'suggest'noFailure is recorded, but the final output can still return.
maxRetries2Per-constraint retry budget.
constraintMaxRetriesunsetShared 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:

BoundaryUnlocks
.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 pathnever 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");

On this page