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).
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.
@GreaterThanEqual(1) files a descriptor against (prototype, 'getWeather', 2) in the contract registry. It writes nothing to the method itself.@Contract() is a class decorator, so it runs after every member decorator. By then the registry holds the whole class, and it replaces each contracted method with a wrapper that checks the recorded constraints before delegating.toolSchemaForMethodschemaForClassrenderMarkdowntelemetryForClassObservationSinkfuzzClass · fuzzSequence@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).
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.
| Band | Reads | Runs | Blames | Projectable |
|---|---|---|---|---|
@EnabledWhen | receiver + args | before | caller ("not enabled in this state") | no, closure |
| Parameter constraints | one argument | before | caller | yes |
@Requires | all args | before | caller | no, closure |
@Returns | settled result | after | callee | yes |
@Ensures | result + args | after | callee | no, closure |
Field @Enforce | assigned value | on set | assigner | yes |
@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. OnlydisableGuardEnforcement({confirm, reason})strips it.assertis 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.
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 decision | Why |
|---|---|
No $schema | It's a parameters sub-schema, not a standalone document (DEC-049). |
required = has ≥1 constraint, minus @Optional | Sound because predicates are total: a missing argument already fails at runtime (DEC-048, DEC-051). |
additionalProperties: false | A 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.
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").
recordis the fire-and-forget point-event surface (completed,violation,message).scopeopens a span, and its correlatedcompletedevent closes it, matched bycallId.nextCallId()is the one shared id sequence, so your own emitter never collides with adorn's.adorn/otelmaps 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.
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).