Skip to content

15C: Scenario suite — the host's own wiring - #30

Merged
d4mation merged 1 commit into
mainfrom
15C-scenario-host
Aug 20, 2026
Merged

d4mation merged 1 commit into
mainfrom
15C-scenario-host

Conversation

@nikolaystrikhar

Copy link
Copy Markdown
Contributor

What: three scenarios about the host rather than about a sub-plugin — that a boot() arriving after plugins_loaded priority 5 still loads, and that a host's own implementations are the objects the request reaches, across seven seams (registrar, plugin checker, plugin deactivator, notice writer, activator, gatekeeper and resolver) — plus Spy_Gatekeeper.

Usage: where a host may bind, and when.

$container->singleton( Resolver_Interface::class, static fn() => $resolver );

$this->boot( $container );

// A concrete class id has to wait until after boot().
$container->singleton( Gatekeeper::class, static fn() => $gatekeeper );

$this->run_request();

Why this way:

An interface binds before boot, a concrete class after it. Provider::bind_once() skips only when ! class_exists( $id ) && $container->has( $id ) — and di52 answers has() true for any class that exists whether or not anything was bound, so the provider cannot see a host's binding for a class and replaces it. Binding after boot() works because Boot\Scheduler wires closures that resolve when the hook fires rather than objects built at boot, so a host may rebind right up until plugins_loaded. ProviderTest::host_bindings() states the same rule from the provider's side; these scenarios show both halves of it in a request.

Each binding scenario asserts the defaults did not run. active_plugins still holds the standalone, and both options are unwritten. On their own the positive assertions would hold just as well if the library had also resolved a second, default copy behind the host's back — which is the failure they exist to catch.

Spy_Gatekeeper counts the two gates separately. They are asked at different moments: the request gate turns away everything that is not an interactive admin GET, and the capability gate is asked only once a conflict is known to exist. One counter would read a request that never passed the first gate as one that passed both.

The binding scenario runs two requests. A DEACTIVATE resolution ends in a redirect and exit, so the load pass belongs to the request after it — one request would be asserting the load happened where production never reached it.

Three scenarios about the host rather than about a sub-plugin: that a boot
arriving too late still loads, and that a host's own implementations are the
objects the request really reaches.

The two binding scenarios show both halves of a rule that is easy to get wrong.
An interface id is bound before boot() and the provider leaves it alone, because
nothing can build an interface unprompted and has() is therefore true only where
a binding exists. A concrete class id has to be bound after boot(), because di52
answers has() true for any class that exists whether or not anything was bound,
so the provider cannot see the host's binding and replaces it. Binding after
boot works because Boot\Scheduler wires closures that resolve when the hook
fires rather than objects built at boot, which is the property that makes the
late binding reach the request at all. ProviderTest states the same rule from
the provider's side.

Each binding scenario also asserts the defaults did *not* run: active_plugins
still holds the standalone, and both options are unwritten. Without that, a
library that resolved a second, default copy behind the host's back would
satisfy every positive assertion in these tests.

Spy_Gatekeeper counts the two gates separately, because they are asked at
different moments -- the request gate turns away everything that is not an
interactive admin GET, and the capability gate is asked only once a conflict is
known to exist. One counter would read a request that never passed the first
gate as one that passed both.

@d4mation d4mation left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It may be cool to consider making Conflict_Policy a bit more abstracted so that a Host could decide to add its own, custom Conflict Type to use in addition to the built-in ones. Currently, when you rebind Resolver you're still limited to the built-in Conflict Types unless you completely replace Resolver::resolve().

That said, the Filter effectively accomplishes this, so it likely isn't necessary.

Base automatically changed from 15B-scenario-conflict to main August 20, 2026 19:27
@d4mation
d4mation merged commit 158b3ef into main Aug 20, 2026
5 checks passed
@d4mation
d4mation deleted the 15C-scenario-host branch August 20, 2026 19:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants