Architecture

The decorator is the adapter.
The descriptor is the foundation.

Every artifact adorn produces is read from one registry of reified contract descriptors. This page covers how they're recorded, how enforcement is installed, and the rules every projection follows, so that "can't drift" holds by construction rather than by discipline.

Design rules

Five rules constrain every implementation choice. Most of the decision log is these rules being applied to a specific case.

One descriptor, many readers

Enforcement, docs, schemas, telemetry and fuzzers read the same recorded data. No projection is allowed its own copy of a constraint (DEC-006, DEC-010).

Degrade toward the weaker truth

When a projection can't express something exactly, it says less, never more. An over-claiming schema is worse than none (DEC-029).

Validate, never coerce

adorn rejects an invalid value. It never silently converts, defaults or transforms it, so a malformed argument is always a violation.

Safe by default

Every path adorn emits through redacts values without reading any annotation. Disclosure is opt-in, and @Sensitive vetoes it (DEC-030).

Frozen reads, private writes

The registry's read surface (getContracts, getEffective…) is semver-frozen public API. Its writers are deliberately not exported, because they would bypass the adapter that owns ordering and installation (DEC-039).

The core trick

A legacy TypeScript parameter decorator runs once, at class-definition time, and receives only (target, propertyKey, parameterIndex). It never sees an argument. adorn bridges that gap in two phases.

@Contract() is required

Parameter constraints do nothing on their own. A class that declares them without @Contract() isn't enforced and is invisible to every projection. Because that failure is silent, adorn emits a development-mode warning naming the class and methods. @Validate() is the per-method alternative, but class-level @Contract() installs outermost, so guard-before-flow holds by construction (DEC-043).

Constraints evaluate in declaration order: parameters left to right, stacked decorators top to bottom. When several arguments are invalid, the first one in the signature is the one reported (DEC-015, DEC-017).

Descriptors as data

The load-bearing move is that a constraint isn't an opaque closure that throws. It's a reified descriptor: an identity plus JSON-serializable params, with the predicate derived from those params. @GreaterThanEqual(1) is a thin @-syntax adapter over NumberContracts.GreaterThanEqual(1).

a custom constraint is first-class
import { defineConstraint, Verify, toolSchemaFrom, type JsonObject } from 'adorn';

const even = defineConstraint<JsonObject, number>({
    kind: 'number',
    constraint: 'custom.even.v1',              // unique identity: the rehydration key
    derive: () => (v: unknown) =>              // the predicate is DERIVED from params
        typeof v === 'number' && Number.isInteger(v) && v % 2 === 0,
    describe: () => 'an even integer',
    schema: () => ({ type: 'integer', multipleOf: 2 }),
});

Verify.check(even({}), 'seats', 3);              // { ok: false, … 'an even integer' }
toolSchemaFrom({ params: { seats: even({}) } }); // seats: { type: 'integer', multipleOf: 2 }

Because the descriptor is data, the same object can be turned into a schema keyword, a docs row, a fast-check generator and the runtime predicate. It also round-trips through JSON, which is what lets compositional constraints such as ArrayOf, Dictionary, Shape and Nullable nest.

Total predicates

Every built-in predicate is total: a null, NaN or wrong-type argument reports a contract violation, never a bare TypeError. That matters twice over. A fuzzer feeds those values first, and a schema claims a type on the strength of it. It's also why required can be derived soundly: a missing argument already fails at runtime.

Bands & blame

A contracted call passes through ordered bands. Each band names who is at fault when it fails, and whether it can be projected.

BandReadsRunsBlamesProjectable
@EnabledWhenreceiver + argsbeforecaller ("not enabled in this state")no, closure
Parameter constraintsone argumentbeforecalleryes
@Requiresall argsbeforecallerno, closure
@Returnssettled resultaftercalleeyes
@Ensuresresult + argsaftercalleeno, closure
Field @Enforceassigned valueon setassigneryes

@EnabledWhen is deliberately a separate band from @Requires. For enforcement alone they behave the same, but they mean opposite things downstream. A failed @Requires means "these arguments are never valid", which is a generation signal that shapes an input schema. A failed @EnabledWhen means "correctly not enabled in this state", an expected skip that no choice of arguments could satisfy (DEC-089).

The postcondition bands run on the settled value. adorn's shared settle classifier handles sync returns, thenables and observable-like values, so an async method's contract is checked against what it resolves to.

Trust & enforcement

Each contract is classified by trust (DEC-008):

  • guard (the default) validates untrusted input and stays on in production. Only disableGuardEnforcement({confirm, reason}) strips it.
  • assert is an internal self-check that's stripped in production.

setViolationPolicy('throw' | 'warn' | handler) controls what happens on a violation. 'warn' proceeds into the method, which is what makes it possible to stage a new contract over live traffic before it starts rejecting.

Process-level configuration

The configuration family is set once at startup, not per request. Set at boot it's safe under concurrency. The data-first core (constraint decorators, *Contracts descriptors, Verify.check) is pure and carries no ambient state at all.

Inheritance: strengthen, never weaken

A subclass's field is checked against the union of every declaration on its prototype chain, and an overridden method inherits its parent's preconditions (DEC-021, DEC-022). A subclass can add constraints but can't remove one. @Optional follows required-wins: if any level requires a parameter, it's required.

The getEffective… read family answers "what is in force here?", which is exactly what enforcement applies and what a projection should render.

Projection rules

Descriptive vs. prescriptive

schemaForClass and toolSchemaForMethod are siblings, not one function with a flag. A descriptive schema under-claims: it describes values enforcement will accept. A tool schema is tight: it specifies the object a producer must send.

Tool-schema decisionWhy
No $schemaIt's a parameters sub-schema, not a standalone document (DEC-049).
required = has ≥1 constraint, minus @OptionalSound because predicates are total: a missing argument already fails at runtime (DEC-048, DEC-051).
additionalProperties: falseA method's parameter set is closed. It's the one keyword runtime enforcement doesn't itself check, and it's labeled honestly as producer-steering (DEC-048).

Formats never over-claim

@Uuid, @DateTime and @Ipv4 are validating descriptors whose predicate is at least as strict as the format keyword they emit. date-time uses strict RFC 3339 with real calendar validation, not Date.parse.

OpenAI strict mode

Strict function calling requires every property to appear in required and additionalProperties: false at every object level. {providerStrict: true} applies that recursively, but strict mode forbids a truly absent property, so an optional field has to become nullable in the declaration.

nullability lives in the declaration
const parameters = toolSchemaFrom({
    name: 'scheduleReminder',
    params: {
        patientId: StringContracts.Uuid(),
        note: NullableContracts.Nullable(StringContracts.NotEmpty()), // ◆
    },
}, { providerStrict: true });
// note → { type: ['string', 'null'], minLength: 1 }, and it is listed in `required`.
// The model sends null; the same Nullable validator accepts it. No drift.

A bare non-nullable optional parameter, or an open Dictionary map, can't be expressed under strict mode. adorn refuses to build those with an actionable message instead of letting the API return a silent 400 (DEC-068, DEC-069, DEC-071).

Redaction & privacy

Every path adorn can emit a value through, including @Log, violation messages and observation events, renders [REDACTED] by default without reading any annotation. setValueDisclosure('all') turns raw values back on for local debugging, and a @Sensitive marking vetoes even that. The veto is surfaced, not silenced.

@Telemetry.value(), .redact(strategy) and .result() opt a value into disclosure on the observation path. telemetryForClass is the review projection: a pure registry read reporting what each position is configured to disclose, with its scanned denominator printed so a clean table is never mistaken for a clean bill of health.

The sensitive-flow detector

adorn check (also available as an ESLint rule) statically follows a @Sensitive value through aliases, template interpolation and destructuring to adorn's own emitters. It is report-only, permanently, and it states its limits on every run: s.ssn.slice(-4) isn't caught, and a raw console.log of a sensitive value is yours to handle (DEC-025, DEC-028).

The observability seam

A neutral, zero-dependency seam (DEC-035 to DEC-037). setObservationSink installs one around-shaped ObservationSink. The enforcement weave emits value-free violation facts and completions, and @Observe emits completions carrying pre-disclosed values, redacted by default ("seeing ≠ emitting").

  • record is the fire-and-forget point-event surface (completed, violation, message).
  • scope opens a span, and its correlated completed event closes it, matched by callId.
  • nextCallId() is the one shared id sequence, so your own emitter never collides with adorn's.
  • adorn/otel maps only the public sink onto OpenTelemetry, so an event you emit by hand becomes a span too.

The testing projection

adorn/test derives fast-check generators from descriptors. Its rule is sound generation: produce only inputs the contract accepts, or decline and say so (DEC-031). Its oracle consumes the same functions enforcement runs (evaluatePreconditions, evaluatePostconditions, evaluateEnabledness), so it flags an input exactly when the enforced wrapper would throw (DEC-034).

fuzzSequence extends this to stateful objects (DEC-090). A command is a method plus generated arguments, and a sequence is an array of commands that fast-check shrinks structurally. Enabledness is resolved at run time against the real instance, so fc.commands is deliberately not used. It would force a parallel abstract model, which is exactly the drift @EnabledWhen exists to prevent.

It samples. It doesn't prove.

A clean run means no generated sequence broke an invariant or postcondition. The report states that boundary every time, and it warns that the real methods run, side effects included.

Deliberate boundaries

These are things adorn currently doesn't do, recorded as decisions rather than oversights.

No TypeScript cross-check

@GreaterThan(0) x: string compiles. adorn removes drift between projections; it doesn't reconcile the descriptor with TypeScript's own view.

Legacy decorators

Parameter decorators exist only in legacy TypeScript. A Stage-3 adapter recording into the same core is on the roadmap (DEC-040).

Closures stay closures

@Requires, @Ensures and @EnabledWhen carry no identity, so they're enforced and fuzzed but never projected.

Names from toString()

Parameter names are recovered from source text, so minification degrades them, though enforcement and projections degrade together.

No per-request policy

Divergent enforcement per concurrent context isn't expressible today. Configuration is process-level.

Add-ons stay out of core

Resilience, limits, caching and policy decorators belong in separate packages, so the root stays dependent on tslib only (DEC-041, DEC-042).