The Game Panel Design record
The Game Panel
RFC-0016 Proposed

Module scopes

Created
Versions
History

Abstract

We will make a resolution know which module's code it is constructing. The container will hold a stack of module scopes, pushing a binding's owner while that binding resolves and popping it afterwards, so everything constructed beneath it inherits the owner by recursion. The active scope will be stamped onto each Dependency and Invocation as it is built, so factories and resolvers receive it without any signature changing. This design 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, such as its own logger channel, its own filesystem root, or 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. A module's registrar is instantiated directly, before the container exists, as RFC-0015 describes. 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 module identifier that registered it, as RFC-0015 describes, so resolving that binding is the point at which the container can know whose code it is about to construct.

Proposal

The container will hold the scope stack, push and pop it, and stamp the active module scope onto what it builds. A Binding will carry the module identifier that registered it, or none for the engine's own bindings. A Dependency and an Invocation will each carry the scope they were built under. The Scoped attribute will name another module's scope for one dependency.

The scope stack

The container will hold 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 will be 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. That is the same failure the resolution stack in RFC-0006 avoids by removing its entry however the resolution finishes.

The stack will be emptied when a cycle closes, whatever state it is in, through the disposal in RFC-0007. It will be emptied per cycle rather than per 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 hard to trace back to its cause.

Ownership

A binding registered through a module-scoped EngineBuilder will have an owning module identifier. That presence is the entire signal:

  • A binding with an owner will push it for the duration of the resolution, and pop it afterwards.
  • A binding with no owner, which is every binding the engine registers, will push nothing.

There will be 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 will be no gate on reading a module scope either: the scope will be stamped unconditionally, so every resolver and factory will already have it.

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

Capturing a scope

A module scope will be 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 will 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 will inherit a scope from whatever caused it. A Dependency stamped X may resolve to a binding owned by Y, at which point the container will push Y, and the Invocation that constructs it will be stamped Y. Propagating from the parent would be wrong in exactly the case that matters.

Consuming a scope

A module scope will be consumed along two paths, and neither changes a published signature:

  • A factory will receive an Invocation, which carries the scope. Most first-party scoped services will be factories, because whatever manages them will own their instance tracking and the container will not cache them.
  • A constructor parameter will become a Dependency, which carries the scope, and be handed to a Resolver. Resolver::resolve() is a contract third parties implement, as ADR-0002 records, and it will be untouched: the scope will arrive inside the Dependency it already receives.

Scope and attributes

An attribute carries information about one use site. A module 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 will ship the attribute and its resolver with no scope to compose against, because modules do not exist yet. Once they do, the resolver will compose the two, and the name on the attribute will be 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 will never write its own module identifier at a consumption site. That is the property this design protects: a module's code will not name the module.

Naming another module's scope

#[Scoped] will give one dependency an explicit scope, resolving it under a named module scope rather than the ambient scope. It will exist for a module that extends another module and needs that module's instance of something.

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

This will open 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 will make explicit and greppable an intent that would otherwise be invisible.

It will stamp that one Dependency and push 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 will apply as usual. Pushing can be added later; it could not be removed.

#[Scoped] will carry two constraints:

  • It takes a constant, never a literal. A constant inherits the drift validation under Rules below, so a typo will be fatal rather than a wrong instance.
  • The named scope must be a declared dependency of the module. Cross-module coupling belongs in the manifest rather than buried in a constructor, and declaring it means a missing target will be found when the module boots rather than when something resolves.

There will be 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, as ADR-0013 records, and Global reads badly in PHP. It is named here so it is not renamed later, and will be added when a parameter actually needs it.

Where a scope is pushed

Site Identifier from
Resolving a binding with an owner The binding.
Router and action dispatch The owning identifier 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 whatever drives the module lifecycle already holds.

Anything resolved outside these will run with no module scope. That residue will be accepted rather than closed.

An empty stack

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

  • A logger tolerates it. No module scope means the engine's own channel, and the loss is cosmetic.
  • A query builder bound to a schema does not. No scope means querying the wrong tables, silently.

So the schema builder's factory will check the scope and throw, and the container will stay ignorant of which services have opinions about being unscoped.

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

Memoisation

Stamping a module scope will cost nothing, because a Dependency is not memoised and there is no cached structure for a scope to invalidate, as ADR-0020 records.

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

  • When module Y resolves a binding registered by module X, the scope will be X. The binding's code is X's code, so X's logger, schema and filesystem are the right answers.
  • A module's declared identifier constant will be validated against its manifest at discovery. A module whose two disagree will fail to load. That will turn drift from a silently wrong schema into a module that refuses to start, and it is what will let #[Scoped] take a constant safely.
  • Cross-module access will be policed by capabilities, in the components that care. Routing will do this with CrossRoutes, as RFC-0015 describes. The container will not be an enforcement layer.

Out of scope

  • Module resources. What a module declares, what is provisioned for it and what it is handed. This design only makes a resolution able to say whose code it is.
  • Enforcement. The container will not decide whether one module may reach another's things.
  • #[Unmodular]. Named above so it is not renamed, and added when a parameter needs it.
  • Pushing from #[Scoped].
  • Logging. The channel attribute is a worked example, not a design.
  • Thread safety. The open question below covers it.

Alternatives considered

The decisions this design rests on are recorded separately, with the alternatives each one rejected:

  • dependencies selecting their instance through parameter attributes, in ADR-0002
  • resolution data not being memoised per parameter, in ADR-0020

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

A flag marking a binding scope-aware. We rejected this because the flag 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. We rejected this because 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. We rejected this because 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]. We rejected this because a parameter attribute that silently rescopes an arbitrarily deep subtree is hard to reason about. Pushing can be added later if a case needs it, and could not be taken back.

Erroring on an empty stack. We rejected this because 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

  • Dependency will take one more constructor argument, so everything that builds one supplies the scope.
  • Invocation will carry a scope the container stamps, which its static factories do not supply.

Nothing else will change. Resolver::resolve() will be untouched, and no binding needs editing: a binding with no owner will behave exactly as it does today.

Open questions

  • Whether queue workers run as an in-process thread pool. A scope stack on a shared container is mutable state across concurrent resolutions, and interleaved pushes and pops would corrupt it. A container per thread makes the question moot; a shared container needs the stack to be thread-local. Nothing in the record says workers are threaded, and the only concurrency anywhere in it is concurrent fragment requests and concurrent index creation. It belongs to the bootstrap design, where the driver contract lives.

Changelog

  • : Reworded in the readable house style, and the project's own vocabulary linked to the glossary. The design is unchanged.

Sources

  • Issue #70, Engine - Container - Module scopes, , edited three minutes later the same day: that there is no ambient current module and why, the binding being the identity carrier, the scope stack and why it is a stack, try/finally and the dirty stack it prevents, emptying at the cycle boundary rather than the request boundary and the leak it avoids, ownership as the whole signal with no opt-in flag and no gate on reading, the empty stack belonging to the service with the logger and schema builder as the two cases, the debug warning, capture at creation with the two values acquiring it differently and neither inheriting, consumption through factories and resolvers with no signature changed, scope and attributes being orthogonal with the channel table, #[Scoped] with its two constraints and why it pushes nothing, Unmodular and the four names it was chosen over, the push sites and the accepted residue, the three rules, the memoisation conflict, every boundary, and the thread-pool question.
  • Issue #39, Engine - Modules - Collectors, , with no edits: Unscoped already meaning something different for a #[Collect] method, which is why the opposite case needs another name.
  • Issue #47, Engine - Container - Circular dependency detection, , with no edits: popping only on the success path leaving stale entries after any failure, which is the precedent for wrapping push and pop.
  • Issue #67, Engine - Sessions, , and issue #59, Engine - Database - Schema: tables, indexes and DDL objects, : concurrent fragment requests and concurrent index creation, the only concurrency anywhere in the record, which is why the thread-pool question is open rather than answered.

Source rfc/0016-module-scopes.md · commit 072385a · exported