# Architecture — `@codefast/di`

> **How to read this file.** These are working notes to help the next person (possibly you, months from now) build an
> accurate mental model of the engine before changing it — not to fence the code off. Two kinds of statement are mixed
> together below, and it's worth telling them apart:
>
> - **Invariants** (labelled as such) are about _correctness_ — behaviour that other parts of the engine, or the tests,
>   depend on. If you break one, something resolves the wrong binding or throws where it shouldn't. Take these
>   seriously, and note that most are pinned by a named test.
> - Everything else is _explanation and design rationale_ — why the code is shaped the way it is, and what was true when
>   it was written. That reasoning is a starting point, not a boundary. Performance claims in particular are
>   time-specific; treat any "this shape is faster" argument here as a pointer to go measure it yourself against the
>   benchmark suite, not as settled fact.
>
> The honest summary: understand the shape and its invariants first, then change whatever you have a good reason to.

**What this document is.** It describes _what the shape is and what it guarantees_. What a shape **costs**, and whether
a new idea beats it, is an empirical question — the benchmark suite in
[`benchmarks/di-inversify`](../../benchmarks/di-inversify/README.md) answers it, and re-running the suite is how you
check. No figure belongs in this file: a cost claim has to be re-measurable, and numbers belong with the method that
produced them (the suite, its [`RESULTS.md`](../../benchmarks/di-inversify/RESULTS.md) ledger, or the commit).

## Layers

Dependencies point downward only. Nothing below knows about anything above. An upward **value** import is a violation; a
type-only one erases at build time and couples nothing.

```
container/        Container, fluent binding chain             ← the public surface
introspection/    inspector, dependency graph, adapters
  ↓
decorators/       @injectable, @inject, lifecycle decorators
metadata/         the reader port and its default reader
  ↓
resolution/       DependencyResolver + its collaborators      ← the engine
  ↓
lifecycle/        LifecycleManager, ScopeManager              ← per-container state
ambient/          the container an @inject accessor reads
  ↓
core/             token, types, tag, binding, registry, module   ← the model
errors/           the taxonomy and its diagnostics
injection/        the descriptor every dependency normalises to
```

Five levels, not four: `decorators/` sits above the engine because an `@inject` accessor's initializer resolves through
the ambient container while the instance is being constructed, and `container/` sits above both because it composes them
— it imports `@injectable`'s registry and four of the metadata modules. Neither of those two levels is on a hot path,
which is why they read as peripheral; they are still ordered, and an import the other way is a violation.

Three of those directories name a rule rather than a topic. **`errors/` is cold by construction** — the hot path imports
the constructors and nothing else, because message building at a throw site is what an error path can afford and a hot
function's prefix is not. **`injection/` is the one shape both dependency sources normalise to**, which is why the model
can read it without reaching up into `decorators/`. And **`resolution/` groups by lane** — `cache/`, `path/`, `plan/`,
`select/` — because the reasoning about it is per-lane, not per-noun.

**Every module is an entry point.** `package.json#exports` is generated by `codefast mirror` from the
`mirror["@codefast/di"]` entry in [`codefast.config.js`](../../codefast.config.js), with no exclusions: this repo is its
own sole consumer, so full access beats encapsulation, and the root export additionally re-exports everything a typical
consumer needs. One naming consequence: because the map is derived from `dist/`, reorganising `src/` renames published
specifiers, so `strip: "./introspection/"` pins the introspection modules' consumer-facing specifiers where they
shipped.

**Practical note.** `package.json#exports` is generated — edit `codefast.config.js` and re-run `pnpm cli:mirror` rather
than hand-editing the map. If a refactor moves a directory, `pnpm cli:mirror:preview` shows renamed specifiers before
you commit.

**The build runs `isolatedDeclarations`, so every exported value carries an explicit type.** A `satisfies` alone is not
one: it validates a literal without naming the type the declaration emits, which is what per-file declaration emit
needs. The flag is set once for the whole repo in `@codefast/typescript-config/library-build.json`.

**Covariance is annotated, not assumed.** `Token`, `Constructor` and `InjectionDescriptor` declare `out Value`, so the
compiler rejects the annotation the day one of them stops being covariant — the property the engine leans on when it
erases the value type at every internal lane and casts once at a public entry point. The binding kinds deliberately have
**no** variance annotation: their lifecycle hooks are methods so their parameters compare bivariantly, and pinning a
variance there would fight the assignability `tests/types/binding-variance.test.ts` exists to protect.

## The model

**One binding shape, one construction site.** Every binding is built by `createBinding()` in
[`binding.ts`](src/core/binding.ts) — a single object literal listing every kind's fields in one fixed order, so all
bindings in a process share one V8 hidden class. Mixed binding kinds otherwise make the resolver's hot property reads
(`kind`/`scope`/`factory`) megamorphic. The registry therefore stores what it is handed **by reference** rather than
re-copying it.

> **Invariant (correctness).** A token's binding **list** is copy-on-write: `add` and `removeById` replace the array and
> never splice one that has been handed out. Selection walks the registry's own list while running `when()` predicates —
> user code free to rebind the very token mid-walk — and the walk keeping its pre-mutation array is what lets every
> candidate registered at selection start still get its predicate, with no defensive copy on the read side.
> `tests/unit/resolution/select/binding-select.test.ts` pins the observable half.

> **Convention (performance-load-bearing).** Bindings are constructed through `createBinding()`, keeping the literal's
> key order intact, so the single-hidden-class property holds. Constructing a binding with a bare object literal, or
> reordering the keys, quietly gives that binding a different hidden class and makes the hot reads megamorphic — it
> still _works_, it just gives back what the single hidden class buys. If you have a reason to change the construction
> site, measure it against the benchmark suite.

**`scope` is total, so the engine reads it as a field.** `AliasBinding` declares `scope: "transient"` — an alias defers
scoping to what it points at, which _is_ transient behaviour — so no kind is missing the field and no read needs an
`undefined` fallback. `effectiveBindingScope()` is that read, kept as a named function because it is the vocabulary
validation and introspection speak. The gain is not the removed `??`: it's that the field's type feedback stays one
shape.

**The engine erases the value type, and one declaration choice is what allows it.** `Binding<Value>` has to stay
assignable to `Binding`, because every internal lane takes the erased union and the cast to `Value` belongs at the
public entry point, where the caller's token is the claim being made. That holds only because the lifecycle hooks are
declared as **methods** on `BindingLifecycleHooks`: as function-typed properties, `Value` sits in a parameter position,
`strictFunctionTypes` makes the binding invariant, and every internal signature grows either a cast or a structural
stand-in. Method syntax compares those parameters bivariantly. The public `ActivationHandler` stays a function-typed
property, so a user's handler is still checked strictly — the bivariance reaches only the field read off a binding.

> **Invariant (type safety).** Internal lanes take `Binding` and return `unknown`; only the eight public resolve entry
> points name `Value`, and each casts once. A `Value` type parameter on a private method is a fiction — the caller
> supplies it through an unchecked cast, so it documents an intent the compiler never verified.
> `tests/types/binding-variance.test.ts` fails to compile if the hooks lose method syntax.

**A memo on a binding is only sound while what it derives from is immutable.** `frame` derives from the token name, id,
kind, slot **and scope** — and `scope` is the one field a fluent chain writes in place after registration. So
`singleton()`/`transient()`/`scoped()` call `clearBindingFrame()`. Without it a chain refined after its first resolve
reports the old scope to every `when()` predicate that reads `ctx.parent.scope`;
`tests/unit/resolution/cache-invalidation.test.ts` pins it.

**Registration happens once.** `bind(T).toDynamic(f).singleton()` registers on `toDynamic()`; `singleton()` then writes
`scope` in place on that same registered object. Only `when*()` re-slots, because slot and predicate are what the
registry indexes on — and it re-registers under the chain's original id, so `id()` is stable for the whole chain.

**One object per `bind()`.** A single `BindingChain` plays every role — the `BindToBuilder` before `to*()`, the
kind-specific builder after — and commits to the registry itself. `bind()` is typed as `BindToBuilder`, so
`when*()`/`singleton()` are not reachable before a `to*()`; the ordering is a **type-level** guarantee, matching
[SPEC's fluent-chain section](SPEC.md#chain-order) ("Compiler enforce"). A caller who has no types, or casts past them,
gets a `ChainNotRegisteredError` naming the token, never a silent no-op — `whenDefault()` asserts registration too, for
that reason alone, since it otherwise has nothing to do.

> **Invariant (contract, two-part).** Both halves of the ordering contract are pinned, by two different kinds of test.
> `tests/types/container-api.test.ts` asserts the refinements are absent from `bind()`'s **type**;
> `tests/unit/container/bind-to-builder-order.test.ts` asserts every one of them **throws** before `to*()`. Asserting
> instead that the methods are absent from the _object_ would pin an implementation detail and forbid this single-object
> shape — so if you change the class, check which of the two a failing test is actually holding.

## The engine

`DependencyResolver` is one large class on purpose: `#private` access is per class, and the sync and async pipelines
both need the same private state on every hop. Splitting them behind interfaces would put a call and a property load on
paths that run millions of times a second. (That's the design reasoning; if you ever want to challenge it, it's a
measurable claim, not an axiom.)

What _is_ split out are the collaborators that need no cross-instance private access:

| Module                                                                                                                   | Owns                                                                                                               |
| ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
| [`binding-lookup-cache.ts`](src/resolution/cache/binding-lookup-cache.ts)                                                | options-less token → `{binding, owner}` memo, alias hops folded, stamped with the chain's summed registry versions |
| [`class-introspector.ts`](src/resolution/cache/class-introspector.ts)                                                    | per-class metadata: constructor params, `@postConstruct` presence, accessor injection, and the `new` itself        |
| [`activation-need.ts`](src/resolution/cache/activation-need.ts)                                                          | per-binding "does this need the activation pipeline", versioned on the lifecycle manager                           |
| [`instantiation-plan.ts`](src/resolution/plan/instantiation-plan.ts)                                                     | the plan compiler (below)                                                                                          |
| [`resolution-path.ts`](src/resolution/path/resolution-path.ts)                                                           | cycle-detection bookkeeping carried on the path array                                                              |
| [`binding-select.ts`](src/resolution/select/binding-select.ts), [`constraints.ts`](src/resolution/select/constraints.ts) | candidate selection for name/tag/predicate shapes, and `matchesSlot()` — the one slot matcher                      |
| [`resolve-options.ts`](src/injection/resolve-options.ts)                                                                 | `DependencySlot`, the shape both dependency sources share, and the `ResolveOptions` derived from it                |

Lookup caches form their own parent chain mirroring the resolvers', for the same `#private`-is-per-class reason.

> **Invariant (single source of truth).** Whether a slot matches a request is answered by `matchesSlot()` alone, and
> whether a request carries exactly one criterion by `singleCriterionOnlyOf()` — a lone name folds to the reserved
> `slotName` criterion there, so the name spelling and the tag spellings reach one lane. The resolver's fast lanes are
> lanes, not separate semantics — a fast lane that re-implements one of those rules is how the spellings drift. This
> isn't hypothetical: `resolveAll`'s name lane once returned a binding whose `when()` predicate `resolve` was refusing,
> precisely because it re-decided a rule instead of calling the shared matcher.

**Both dependency sources are one shape.** A class's `ParamMetadata` and a `toResolved` `InjectionDescriptor` are
structurally `DependencySlot`, so `#resolveDeps`/`#resolveDepsAsync` serve both and the plan compiler compiles both —
one pair of loops rather than four near-identical ones, and a dispatch rule that can no longer be fixed in only one of
them.

## Compiled plans and escapes

A transient `class`/`resolved` binding resolved at the top level compiles once into a nested-constructor closure. The
static subgraph is cycle-checked **at compile time**, so executing it does no per-resolve bookkeeping at all.

A dependency the compiler cannot see through — a factory, a scoped binding, an activation hook, a class past the depth
limit, a multi/optional/named param — does **not** sink the plan. It compiles to an _escape_: a re-entry into the
runtime resolver seeded with exactly the ancestors the interpreted path would have pushed at that point, dispatched
through exactly the resolve the interpreter would have called. So cycle detection, constraint contexts and error paths
are identical to never having compiled. Without escapes, one `toDynamic` dependency anywhere drops the whole graph to
the interpreted path.

> **Invariant (correctness).** An escape must stay behaviourally indistinguishable from the interpreted path. If you add
> a case, seed it with the same ancestors and replay the same call.
> `tests/unit/resolution/plan/instantiation-plan-escapes.test.ts` pins this.

**A dependency's criteria are fixed when it is declared, so nothing may rebuild them per hop.** `#compileInjectionThunk`
derives a named or tagged param's `ResolveOptions` at _compile_ time and captures them in the escape thunk. The
interpreted path has no such moment — `#resolveDep` runs per hop — so `resolveOptionsForSlot` memoizes them on the slot
itself: sound to share across containers because they derive from the slot alone (a binding-keyed memo would not be,
since class metadata is global to the class rather than per container). The memoized object is **frozen**, because one
object now answers every resolve of that slot and a constraint predicate is handed it as `currentResolveOptions`; a
write through that reference would rewrite what the dependency asks for from then on. Frozen, the attempt throws where
it is made.

**The frame copy in `#compileEscapeThunk` is load-bearing.** This is a correctness invariant. Two mechanisms defeat any
scheme that shares or lends that array:

- The membership `Set` that `enterResolutionPath` attaches past `RESOLUTION_SET_THRESHOLD` lives on the **array
  object**, so a lent array carries it into a lane that does not maintain it; `[...frames]` hands the escape an array
  with no set at all, because a spread does not copy symbol-keyed properties.
- A constraint predicate runs on a live seed **before any push**, at exactly the length a depth guard reads as idle, and
  can re-enter the same cached plan; one indexed write into a lent seed then survives forever, where the interpreted
  lane's `rootStack` drains to zero after each top-level resolve and self-heals.

A poisoned frame changes which binding is selected — a wrong value, not just a wrong diagnostic. That's why this one is
firm rather than a matter of taste; anything faster here has to keep both mechanisms from firing.

**A criterion the registry can settle is not opaque.** A dependency used to escape on `options !== undefined`, before
anything tried to look it up — yet `whenNamed`/`whenTagged` write criteria into the slot rather than a predicate, so a
single-criterion request is usually a plain index hit. `lookupPathIndependentEntry` bakes that selection into the plan
when — and only when — the candidate carries no predicate and its slot matches the request, since a predicate reads the
resolution path and is the runtime's to evaluate.

> **Invariant (correctness).** An entry reached by a criterion carries that criterion into every escape it falls back
> to. `#compileDepThunk`'s escapes replay `resolveFromContext` — the _default_ slot — when handed no options, so a named
> singleton's cold materialization or a named factory would silently resolve a different binding without this.
> `tests/unit/resolution/plan/instantiation-plan-named.test.ts` pins it.

## Fast lanes that read as duplication

Two shapes here look like copy-paste of `#resolveBinding` and aren't. Both were removed on a DRY pass and put back after
measuring the regression, which is the honest status: the duplication earns its keep at the arities the suite measures,
and if those change the trade is worth revisiting — re-run the benchmark before assuming either way.

**A candidate answers where it is selected.** `#resolveCandidateSync`/`#resolveCandidateAsync` re-check plain-constant
and cached-singleton before delegating, which `#resolveBinding` would check anyway. Routing every candidate through
`#resolveBinding` instead is paid per candidate rather than per call, which a `resolveAll` over cached singletons
multiplies out.

**The dispatcher's prefix is charged to every resolve.** In `#resolveDefaultEntry` the plain-constant test lives
_inside_ the `singleton` branch, because a constant is a singleton that is already its own instance. Hoisting it to the
top, where it reads more naturally, helps a constant resolve and charges every transient-factory resolve for a kind it
never has.

> **Heuristic (perf, not correctness).** A test tends to be cheapest under the branch that already implies it — a test
> in the hot dispatcher's prefix is paid by every resolve that _isn't_ the case it's looking for, and there are usually
> more of those. This dispatcher is also inlining-sensitive: a test added inside a branch it doesn't even take has moved
> an unrelated row. That sensitivity is a V8 property and can shift between versions, so measure any edit near it
> against the benchmark rather than trusting a past number.

The same reasoning covers two smaller shapes: `#taggedBindingsFromChain` returns `[binding]` whole for a root container
rather than growing an empty list (a criterion matches at most once per registry, so the size is known), and
`#findBinding` treats a lone candidate as its own selection — matching it _is_ the decision, with no specificity to
weigh and no ambiguity to report.

**An upsert's fallback is eager or computed by hit rate.** The package's own upsert helpers
([`core/map-upsert.ts`](src/core/map-upsert.ts)) come in both forms: `getOrInsert` takes a fallback the caller has
already evaluated, `getOrInsertComputed` calls a factory only on a miss. So the choice follows which case dominates.
`BindingLookupCache.taggedEntry()` runs on every criterion-carrying resolve and almost always hits a long-lived
container, so it takes the computed form with the factory hoisted to module scope, so no closure is allocated per call
either. The registry's index insertions are the mirror image: a bind is usually the token's first, so the fallback is
usually the value that gets stored, and the eager form wins. (`add()` itself no longer upserts — its list is
copy-on-write, so it always builds the next array.) Both forms are in the tree on purpose, and they stay the package's
own rather than the platform's ES2025 `Map` methods, which would move the Node floor to 26 for two call shapes a local
helper already covers. Both reject a value type that admits `undefined`, because they read absence with one `get` rather
than a second `has` — a map that stores `undefined` needs `ScopeManager.readScoped()`'s shape, not this one. A lazily
allocated index also spells its type arguments — `this.#field ??= new Map<Key, Value>()` — because TypeScript does not
contextually type the right-hand side of `??=`, so a bare `new Map()` there becomes `Map<any, any>` and silently drops
every check that reads the field. (Note that "almost always hits" is a claim about a long-lived container; it inverts in
a per-request one, where every first criterion-carrying resolve of a token buys a map it won't read again — worth
measuring fresh vs warm if you revisit this.)

**A criterion is interned, so the index can be keyed by it.** A tag key is minted by `tag()` and its criteria by
`TagKey.of()`, which caches one object per value — so `Object.is` equality
([SPEC — ResolveOptions](SPEC.md#resolve-options)) becomes object identity, and the registry keys tagged bindings by the
criterion itself rather than by key-then-value. That removes a hash level from every tagged lookup and, more
importantly, removes a divergence: a value-keyed `Map` compares keys by SameValueZero, which holds `+0` and `-0` equal
where `Object.is` does not. The intern cache splits those two under a private symbol, so the index cannot conflate them.

> **Invariant (correctness).** Interning is what makes identity sound here, so a `BindingTag` is only ever constructed
> through `TagKey.of()` — the brand on the type is the enforcement, and
> `tests/unit/resolution/select/tagged-selection.test.ts` pins the `±0` split it buys. Constructing one another way
> would let the index conflate `+0` and `-0`.

**The multi-tag lane prefilters on keys, which is a subset test a `Map` still cannot do.** Each key carries a bit, each
slot the OR of its keys, and a request the OR of what it names; a slot whose keys the request does not cover is rejected
by `(requestMask & slotMask) !== slotMask` before any criterion is read. Bits wrap every 32 keys, so two keys can share
one — a false positive the identity comparison then rejects, never a false negative.

**Agreeing on the answer is not agreeing on the lane.** `tag: pair`, `tags: [pair]` and — through the reserved criterion
— `name: n` are each one-criterion requests, and `singleCriterionOnlyOf` is the admission test that has to see them that
way — reading the presence of `tag` as a reason to give up once made the shorthand (the form the README reaches for) the
only spelling the index never served. A request carrying criteria from **two** sources at once still declines: two
criteria asked for is not something a one-criterion index can answer without skipping the ambiguity check the full path
runs.

> **Invariant (consistency of contract).** Two spellings SPEC calls equivalent have to reach the same lane, or the
> shorter one becomes the slower one and the documentation recommending it becomes wrong.
> [SPEC](SPEC.md#resolve-options) makes the two spellings one request;
> `tests/unit/resolution/select/tag-shorthand-parity.test.ts` pins the lane alongside the answer.

**`resolveAll` reads the tag index too, and one criterion is not the subset query several are.** A request carrying
exactly one criterion — a lone name folds to the reserved criterion — matches exactly the bindings whose slot _is_ that
criterion: a multi-criterion slot cannot satisfy it, and last-wins keeps at most one such binding per registry. So the
index holds the whole answer per container and walking the chain is the candidate set rather than a prefilter. Both
lanes that read the index evaluate a found binding's predicate, because a predicate needs a live context no index can
hold.

A caution this lane earned the hard way, worth remembering when you read _any_ comment justifying a restriction:
`simpleTagOf` once excluded predicate-bearing bindings from the tag index "because the index is read without a re-check"
— a justification that stayed in place long after the `±0` fix had given every indexed hit a predicate evaluation, i.e.
long after the reason had stopped being true. The restriction outlived the fact. The predicate evaluation is now pinned
by `tests/unit/resolution/select/tagged-selection.test.ts`, so the failure that stale reasoning could have caused — an
indexed hit reaching a caller past a predicate that refused it — can't land quietly.

The multi-criterion case is a **subset query** — a request carrying several criteria matches every binding whose
criteria are a subset of them, so `[A]`, `[B]` and `[A,B]` all answer a request for `[A,B]` — which no single `Map`
lookup can serve. The index that serves it buckets every multi-criterion slot under its **first** criterion: a matching
slot's every criterion is in the request, its first included, so walking the request's few buckets (plus the single-tag
index under each request criterion, the folded name criterion among them) finds each candidate **exactly once** — no
dedup set, no per-resolve allocation beyond the candidate list selection was already building, and no stringified tag
values (the `Object.is` rule in [SPEC](SPEC.md#resolve-options) still holds; buckets are keyed by the interned
criterion).

Two deliberate bounds on that lane. It serves **`resolve` only** — `resolveAll` keeps the full scan, because its result
order follows the token's list and bucket order would reorder it. And it engages only past a **size threshold** on the
token's list: under it the generic scan is cheaper than the bucket walk, so the threshold switches the data structure,
never the semantics — both paths answer identically, which `tests/unit/resolution/select/multi-tag-selection.test.ts`
pins along with the subset, specificity, predicate and index-invalidation behavior. The residual cost on a small list is
one length read.

**A hash lookup a loop repeats can benefit from an inline cache.** `LifecycleManager.activationHandlersFor()` keeps a
one-entry token→hooks cache in front of its map, invalidated by `registerActivation`, because a resolve loop asks about
the same token every iteration. `BindingLookupCache.defaultEntry()` is the same shape one layer down: it is reached by
exactly two cases the registry's direct index cannot serve — an **alias**, whose terminal the index cannot name, and a
token owned by a **parent**, whose entry has to carry that owner — and both are resolved in a loop over one token.
`null` is a real answer there ("this shape needs full selection"), so the slot tracks absence by its token, not by its
entry.

> **Invariant (correctness).** Alias hops are not folded into `registry.getFastDefault()`. That method is a bare
> own-registry `Map.get` returning a binding, and an alias terminal may live in a parent container whose rebind only the
> chain's summed version can see. Alias folding belongs where the version stamp is; moving it into `getFastDefault()`
> would miss a parent rebind.

**Both lookup lanes memoize the chain walk, and the criterion one defers its map.** `taggedEntry` mirrors `defaultEntry`
— chain-versioned, `null` meaning "this shape needs full selection", predicate- and alias-carrying hits declined — with
one difference the fresh-vs-warm measurement forced: a per-request child usually asks one `(token, tag)` exactly once,
and an inner-map allocation on that first ask was the whole cost of the memo on that shape. So the first shape a cache
generation sees is answered from the walk and parked in a one-entry front, and the map is not written until a second
distinct shape appears; an alternating pair converges after one extra walk per key. The memo key is the criterion object
itself — interning makes identity the slot contract's own `Object.is`, the same exactness the registry's tagged index
relies on, so an indexed hit needs no value re-check. The warm-vs-fresh numbers that settled this, and the shape's A/B,
live in the benchmark suite's `RESULTS.md`.

**Late hooks are why a memo over a binding keys on the hook's identity.** `.onActivation()` writes the field **in
place** on an already-registered binding and bumps no version. `needsActivation()` therefore answers the binding's own
hook from the field itself before touching the memo, and stamps the memo with the registry version too, so a rebind loop
cannot grow it. Getting this wrong is silent: the memo cached "no activation" per binding id and skipped a late hook on
every lane that consulted it, while the default dynamic lane read the field fresh and honored it.
`tests/unit/resolution/cache-invalidation.test.ts` pins all the lanes.

**One alias walk, one set of not-bound diagnostics.** `resolve` and `resolveAsync` both take their terminal binding from
`#requireBinding`, so the alias-cycle walk and the diagnostics exist once. The frame that adds is charged only to a
resolve that fails, and constructing the error at the throw site rather than in the helper keeps most of it back.

## Cycle detection — two mechanisms, on purpose

| Lane                   | Mechanism                                                            | Why not the other one                                                                                               |
| ---------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| Sync transient-dynamic | `binding.inFlight`, set on factory-enter and cleared on exit         | Sync resolution runs on one call stack, so the flag _is_ exact path membership: `O(1)`, no hashing, no side table   |
| Everything else sync   | `enterResolutionPath` — push and pop one shared path/stack pair      | One call stack, so the array _is_ a stack; a per-binding flag cannot name the path in the error                     |
| Async, in a cascade    | `binding.inFlight`, cleared when the factory returns its **promise** | The request that closes a cycle comes from a factory's synchronous prefix, and synchronous code does not interleave |
| Async, out of one      | `extendResolutionBranch` — append-only path, read by branch depth    | A continuation's ancestors are on no call stack, so they have to be carried explicitly                              |

**Both variants of the first lane** — with and without activation hooks — take the flag, because the argument for it
doesn't mention hooks: a hook runs on the same call stack the factory did. A hook that re-resolves its own token still
reports `CircularDependencyError` rather than recursing, and the flag is still released on every exit path —
`tests/unit/resolution/in-flight-invariants.test.ts` pins both for the hooked lane too.

**Every path-based check keys on binding identity, never on a token's display name.** A display name is not unique — two
`token("Config")` from different modules are distinct tokens, and a name-keyed check reported a false cycle for a
legitimately acyclic chain that held both. `enterResolutionPath` and `extendResolutionBranch` compare `bindingId` read
off the frame stack; the names an error or `ctx.resolutionPath` reports are **derived from the frames** at the moment
they are asked for — no name array exists to keep in step. A hop pushes and pops one stack, the branch helper takes one
depth, and an escape thunk copies one frame array; the error path pays the name materialization, not the hot path.

`enterResolutionPath` scans the frames linearly while the stack is short and attaches a membership `Set` of binding ids
past `RESOLUTION_SET_THRESHOLD`, which is 32. That threshold switches a **data structure**, not a behaviour: both
branches answer identically. (The 32 is a tuning constant from a depth sweep; re-sweeping it with the benchmark is cheap
if the typical graph depth in real consumers shifts, or the collector's behaviour changes.)

**The set is seeded from the stack, so it has to be able to notice that it has gone stale.** The frames already on the
stack when it attaches are handed no set and delete nothing on unwind, and the array outlives a resolve — the resolver
lends one stack — so a set that survived the unwind would refuse bindings nobody is resolving. A live set mirrors the
stack exactly (ids on an acyclic path are unique); `enterResolutionPath` drops one whose size no longer matches the
stack's length, and the next deep frame rebuilds it. `tests/unit/resolution/path/resolution-path.test.ts` pins the three
ways the seed becomes observable: a second resolve of the same deep graph, a sibling branch below the attach depth, and
the entry point called directly.

> **Invariant (correctness).** A threshold here may choose an implementation; it must not choose a semantics. The
> removed `DEEP_LANE_THRESHOLD` switched _lanes_, so it silently changed context identity, stack frames and promise
> shape at the crossing point — and reported a false `CircularDependencyError` for a diamond dependency past it.
> `RESOLUTION_SET_THRESHOLD` broke the same rule while looking like it was only choosing a data structure.

## The async lane has two lanes, and the cheap one costs nothing per level

A resolution path is the chain of ancestors a level is being resolved under. Sync resolution runs on one call stack, so
one array pushed and popped **is** that chain, and `binding.inFlight` is exact membership in it. Async resolution was
assumed to have neither property, and paid for a settle-scoped path on every level to compensate.

It has both, for the requests that matter. **A factory's request for a dependency is made from its synchronous prefix**
— `async ctx => await ctx.resolveAsync(dep)` calls `resolveAsync` before it awaits anything. So the chain of "who is
resolving whom" at the moment of a request is the synchronous call stack, and a whole eight-level chain is built inside
**one** synchronous cascade before any of it settles. While that cascade is open the resolver's own `#cascadeStack` is
the ancestor chain: pushed on factory-enter, popped when the factory returns **its promise** — not when that promise
settles. Two cascades can never interleave, so `binding.inFlight` is exact path membership again, and every level shares
one `AsyncCascadeContext`. Nothing is allocated per level, nothing observes its own settlement.

That also removes a false positive rather than adding one. The shared settle-scoped path reported
`Circular dependency detected: a → b → d → c → d` for a diamond — `A` awaiting `B` and `C` in parallel, both needing
`D`, in which `b → d → c` is not a dependency edge at all. Under the cascade, `D`'s flag is cleared when `D`'s factory
returns its promise, so the second sibling finds it clear.

**What the cascade cannot see is a request made from a continuation**, after an await: its ancestors are on no call
stack. Such a request arrives with the cascade empty, which is an exact test — a continuation never runs inside a
synchronous cascade — so it **escapes** to the branch lane, and so does anything the cascade lane does not serve, seeded
with a snapshot of the ancestors the cascade had reached. Once a subtree leaves the cascade it stays off it, which is
what keeps a cycle crossing the boundary on one path.

The branch lane is the general one: `extendResolutionBranch` appends to a path while this branch still owns the next
slot and copies its own prefix once a sibling has claimed it. Nothing is removed there either, so it needs no settle
listener; it pays a context per level instead. A cycle formed entirely from post-await edges is caught there —
`post-q → post-p → post-q` — one level in from the true root, because the ancestors before the first escape were never
written down. That imprecision is the price of the cascade lane, and `tests/unit/resolution/resolver-async.test.ts` pins
it rather than leaving it to be discovered.

> **Invariant (ownership, held by the compiler).** A branch may only ever append to an array it minted itself: a sync
> frame's path is one that frame will pop in its own `finally`, and it may carry an `enterResolutionPath` membership
> `Set` this lane cannot keep true. So `extendResolutionBranch` is the only thing that mints an `OwnedBranchPath`,
> `AsyncLevelContext` accepts nothing else, and a `BranchDepth` is branded so a bare number cannot stand in for one — a
> depth from anywhere but this branch silently re-parents a level. `AsyncLevelContext` reads its depth off the branch it
> was handed rather than taking it as a parameter, so the two cannot disagree.
> `tests/types/async-branch-ownership.test.ts` fails to compile if either brand is removed — check that it still fails
> before trusting it, since a type test that compiles once its invariant is gone asserts nothing.

The cascade entry answers a plain constant and a cached singleton itself rather than escaping — a materialized async
singleton that escaped would snapshot both cascade arrays for a resolve that never reads a path. (A perf shape, not a
correctness matter.)

The sync lane's answer to the same question is `InstantiationPlanCompiler`, which cycle-checks a static subgraph once at
compile time and then executes with no bookkeeping. It ports exactly as far as the graph is visible: `class`, `resolved`
and `resolved-async` bindings declare their dependencies, so `compileAsync` compiles those into an async plan, while a
`dynamic-async` factory stays opaque and keeps the cascade — which needs no graph because it reads the ancestors off the
call stack that is already there.

**The async plan runs only at a true root — a `resolveAsync` arriving with the cascade idle.** Inside an open cascade
the same binding escapes instead, because a plan does no bookkeeping and its escapes must carry the live ancestors. Each
node's promise-ness is settled at compile time: a fully synchronous subtree touches no promise at all, and anything that
may yield one routes its dependencies through `Promise.all` — which is exactly how the interpreted async path treats
every dependency, down to unwrapping a promise-valued constant and starting every sibling before the first rejection
propagates. `tests/unit/resolution/plan/instantiation-plan-async.test.ts` pins the lane being active, the escape
criteria, the late-hook invalidation, and those two exactness corners.

## The sync context pool, and the stack it lends

Pooling a resolution context by depth beat allocating one per level in measurement, which is why it's there. `reset()`
writes a handful of fields, and a pooled context outlives enough resolves to sit in **old space**, so each pointer write
takes a write barrier — which is why the resolver and the stack are compared before they are stored. That comparison can
only ever hit if the stack is the same object, so one stack is reused per resolver rather than minted per
`container.resolve()` call. The same mechanism sets the price: a fresh array is in new space and needs no barrier to
push a frame onto, while the shared stack pays one per push.

> **Invariant (lending protocol).** An empty `rootStack` _is_ the whole lending protocol — every sync lane pops what it
> pushes, so a non-empty one means a resolve is holding the stack and the caller mints its own. A nested
> `container.resolve()` inside a factory must therefore still see an empty stack, and a resolve that throws must hand it
> back; `tests/unit/resolution/in-flight-invariants.test.ts` pins both. If the stack ever leaks dirty the failure mode
> is lost reuse (slower), never a wrong path — the protocol is built so that the correctness case can't break here.

> **Invariant (correctness).** A pooled context is reused only for the stack it already holds. The pools are keyed by
> stack — one for the root stack, one for the cascade stack, each depth-indexed — and any other stack (a nested
> resolve's minted array, an async level's snapshot) mints a context per call. A nested top-level resolve reaches the
> same depth while the outer factory still holds that depth's pooled context — re-pointing it at the nested resolve's
> freshly minted stack would leave the outer factory's `ctx` answering from the wrong path, so a `when()` predicate
> reading `ctx.parent` selects the wrong binding. Keying the pools by stack holds this structurally, with one pointer
> compare on the hot lane; asking the context whether it holds the requested array answered the same question but cost
> the acquire its inlining. `tests/unit/resolution/context-pool-isolation.test.ts` pins the behaviour.

## A container defers most of itself

`DefaultContainer`'s constructor builds only what a resolve cannot happen without: the registry, the scope manager, the
lifecycle manager and the resolver chain. Everything else arrives on first use — the inspector, the module ref/binding
tables, the scope's in-flight and scoped caches, the registry's tagged slot indexes, and the class introspector's three
metadata caches. An empty `Map` is not free — V8 gives it a backing store — and those are `Map`s a bind-and-resolve
container never reads.

> **Invariant (correctness).** Deferral is an allocation decision only. A deferred collaborator must answer identically
> whether or not something touched it first — an unallocated cache reads as a miss, never as an error — which is why
> `tests/unit/container/deferred-subsystems.test.ts` exercises each one as the _first_ thing a fresh container does.

Deferral also raises the question of what a bulk reader should hand back when its cache was never allocated.
`ScopeManager.getAllScoped()` was the only such reader and had no callers anywhere, so it was removed rather than given
an empty-map fallback: the cheapest answer to "what should this return when there is nothing to return" is to not carry
the method.

## One binding, one container — and the singleton slot that follows from it

A binding is registered by its chain into exactly one registry, and only that container's scope ever caches it: a child
resolving a parent's token delegates to the parent resolver, which owns the same binding object. So a singleton's slot
is **per-binding, not per-container**, and the instance lives on `binding.instance` (`NO_INSTANCE` when unset) instead
of in a `Map` keyed by binding id. That replaces a keyed lookup with a field read on the most common resolve shape there
is — a transient over cached singletons. `ScopeManager` keeps only a lazily-created list of the bindings that have
materialized, so disposal and `inspect()` can still enumerate them.

> **Invariant (correctness).** This is only sound while one binding maps to one owning container. Anything that would
> share a binding object between two registries — a snapshot that re-registers into a different container, a clone that
> copies bindings by reference — breaks it silently, by making two containers share one instance.
> `tests/unit/resolution/singleton-on-binding.test.ts` pins the parts that are easy to get wrong: the chain-shared read,
> invalidation on unbind and rebind, enumeration for disposal, and a cached `undefined` that must stay distinguishable
> from a miss.

## Changing anything here

There's no gate to clear — just a suggested order that tends to save time:

1. Understand the invariant(s) the code you're touching depends on (the labelled blocks above), and check the test named
   next to each. If a test is what's holding an invariant, it'll tell you fast whether your change broke it.
2. If the change is about speed, measure it. What a shape costs, and whether a new idea beats it, is an empirical
   question — the benchmark suite ([`benchmarks/di-inversify`](../../benchmarks/di-inversify/README.md)) is the source
   of truth, [`BENCH_GUIDE.md`](../../benchmarks/di-inversify/BENCH_GUIDE.md) is the method, and step 5 of
   [CONTRIBUTING.md](./CONTRIBUTING.md) is the checklist.

Two things that guide doesn't cover and this engine keeps demonstrating:

1. **Measure cold paths too.** A change that wins the hot loop can lose badly on container construction, and the hot
   loops hide it completely.
2. **Validate a perf idea by throwaway ablation**, not by reasoning. Build the variant, measure it, delete it — past
   attempts against this engine were mostly wrong in the direction their author expected, which is the best argument for
   measuring rather than arguing.

## License

Released under the [MIT License](./LICENSE).
