The Game Panel specification

RFC-0016: Module scopes

Abstract

A resolution knows whose code it is constructing. The container holds a stack of module scopes, pushing a binding’s owner while that binding resolves and popping afterwards, so everything constructed beneath it inherits the owner by recursion. The active scope is stamped onto each Dependency and Invocation as it is built, so factories and resolvers receive it without any signature changing. This is the mechanism alone: what a module is then handed belongs to module resources.

Motivation

A module needs things that belong to it rather than to the engine: its own logger channel, its own filesystem root, a query builder bound to its own schema. Each of those needs an answer to one question, asked at the moment something is constructed: which module is this for.

Nothing can answer it today. Registrars are instantiated directly, before the container exists, per RFC-0015. The container is immutable once built. Nothing at dispatch knows which module a class belongs to. There is no ambient current module at runtime.

The binding is what carries the identity. A binding registered through a module’s EngineBuilder records the ident that registered it, per RFC-0015, so resolving that binding is the point at which the container can know whose code it is about to construct.

Proposal

Concepts

Term Meaning
Scope The module a resolution is running under, named by its ident.
Owner The module that registered a binding.
Scope stack The scopes currently pushed, innermost last.
Ambient scope The scope at the top of the stack, which a resolution runs under.
Explicit scope A scope named at a parameter, used instead of the ambient one.

Components

Component Responsibility
Container Holds the scope stack, pushes and pops it, and stamps the active scope onto what it builds.
Binding Carries the ident that registered it, or none for the engine’s own bindings.
Dependency, Invocation Carry the scope they were built under.
Scoped Attribute naming another module’s scope for one dependency.

The scope stack

The container holds a stack rather than a single value, because resolution nests: a scoped binding may depend on another module’s scoped binding, and both scopes are live at once.

Push and pop are wrapped in try/finally. A constructor throwing part way down a graph would otherwise leave the stack dirty for the remaining life of the worker, which is the same failure the resolution stack in RFC-0006 avoids by removing its entry however the resolution finishes.

The stack is emptied when a cycle closes, whatever state it is in, through the disposal in RFC-0007. A cycle rather than a request, because the vocabulary is the container’s: HTTP opens one per request, a queue worker in a warm process opens one per job, and a scheduler opens one per tick. A scope leaked in a long-lived process shows up as one module’s channel appearing in another module’s cycles, which is miserable to trace back to its cause.

Ownership

A binding registered through a module-scoped EngineBuilder has an owning ident. That presence is the entire signal:

There is no flag to opt a binding into being scope-aware. Such a flag would be true exactly when an owner exists, so it would carry no information. There is no gate on reading one either: the scope is stamped unconditionally, so every resolver and factory already has it.

The container therefore has one behaviour rather than a set of modes: push when a binding has an owner, pop when it is done.

Capturing a scope

A scope is captured when a Dependency or an Invocation is created, and held, rather than looked up when it is read. A nested resolution moves the stack between a value being built and being consumed, and several live values holding different scopes is the expected state, not an edge case.

The two acquire it differently, because they are built differently:

  How it acquires the scope
Dependency Constructed with its values, so it takes the scope as one more of them, alongside the parameter, type, name, qualifier, resolvable attribute, default and liminality of RFC-0001. Whoever builds it supplies the scope.
Invocation Built by static factories that have no container reference and so can read nothing. The container stamps it after the fact, as it already does for the arguments supplied to it.

Neither inherits a scope from whatever caused it. A Dependency stamped X may resolve to a binding owned by Y, at which point the container pushes Y, and the Invocation that constructs it is stamped Y. Propagating from the parent would be wrong in exactly the case that matters.

Consuming a scope

Two paths, and neither changes a published signature:

Scope and attributes

An attribute carries information about one use site. A scope carries information about one module. Neither substitutes for the other, and both apply at once.

A channel attribute is the worked example. Logging is not designed yet, and whatever designs it ships the attribute and its resolver with no scope to compose against, because modules do not exist. Once they do, the resolver composes the two, and the name on the attribute is read relative to the scope:

Site Attribute Scope Channel
Engine component #[Channel('database')] none database
Engine component #[Channel] none the engine’s own
Module class #[Channel] backups backups
Module class #[Channel('audit')] backups backups.audit

A module never writes its own ident at a consumption site. That is the property worth protecting: a module’s code does not name the module.

Naming another module’s scope

#[Scoped] marks one dependency as resolving under a named scope rather than the ambient one. The case it exists for is a module extending another module and needing that module’s instance of something.

public function __construct(
    #[Scoped(Servers::IDENT)] private Connection $servers,
) {}

This opens no hole. Modules are PHP running in one process with no isolation, so a module wanting another module’s connection can already construct one directly. The attribute makes an intent explicit and greppable that would otherwise be invisible.

It stamps that one Dependency and pushes nothing. A parameter attribute silently rescoping an arbitrarily deep subtree is hard to reason about, and what this targets is leaf factories with no onward dependencies, so the difference rarely shows. If a binding with an owner turns up during that resolution, ordinary ownership pushing applies as usual. Pushing can be added later; it could not be removed.

Two constraints:

There is no attribute for the opposite case, a parameter opting out of the ambient scope. Unmodular is the name reserved for it, because every shorter word is already taken: Unscoped means something else for a #[Collect] method in RFC-0015, Platform collides with the panel context, Core is ambiguous when core features are themselves modules, per ADR-0013, and Global reads badly in PHP. It is named here so it is not renamed later, and added when a parameter actually needs it.

Where a scope is pushed

Site Ident from
Resolving a binding with an owner The binding.
Router and action dispatch The owning ident the collector handler recorded when it collected the route.
A queue worker The job’s payload, if it carries one.
Register and boot The manifest, which the lifecycle driver already holds.

Anything resolved outside these runs with no scope. That residue is accepted rather than closed.

An empty stack

Nothing errors when the stack is empty. Whether that matters belongs to the service, not to the container, because services differ:

So the schema builder’s factory checks the scope and throws, and the container stays ignorant of which services have opinions about being unscoped.

In debug, a class resolved under a module’s autoload prefix with an empty stack warns, which surfaces a missing push site while developing at no cost in production.

Memoisation

Stamping a scope costs nothing, because a Dependency is not memoised and there is no cached structure for a scope to invalidate, per ADR-0020.

A cached resolution plan would change that. Every entry in one would be scope-dependent, so a plan could not be shared between resolutions running under different scopes. Nothing proposes such a plan and the module lifecycle has no compile step, so this is recorded as a conflict to be aware of rather than something given up.

Rules

Out of scope

Alternatives considered

A single current scope rather than a stack. A scoped binding can depend on another module’s scoped binding, so the value has to nest.

A flag marking a binding scope-aware. It would be true exactly when an owner exists, and so would say nothing that the owner does not.

Looking the scope up when a value is read, rather than capturing it when the value is built. A nested resolution moves the stack in between, so the answer read later is not the answer that was true when the value was created.

Inheriting a scope from the value that caused it. A Dependency stamped X resolving to a binding owned by Y must produce Y, which is precisely the case inheritance would get wrong.

Pushing a scope from #[Scoped]. A parameter attribute that silently rescopes an arbitrarily deep subtree is hard to reason about. It can be added later if a case needs it, and could not be taken back.

Erroring on an empty stack. Services disagree about whether an empty stack is a problem, so the decision belongs to each service rather than to the container.

No other alternatives were weighed.

Backwards compatibility

Nothing else changes. Resolver::resolve() is untouched, and no binding needs editing: a binding with no owner behaves exactly as it does today.

Open questions

Changelog

Sources