Walker capabilities
How calculation strategies and structure resolvers negotiate compatibility through capability strings, and how to add new ones.
Requires Siren Pro
Last updated: June 3, 2026
Programs and distributors bind to a collaborator group with a particular structure. Some calc strategies, upline cascade and downline cascade, only make sense against a group whose walk yields layered steps. A flat-bound program has nothing to walk through, so showing Upline Cascade in its picker would be a trap. Capabilities are how Siren keeps that honest without hardcoding a compatibility table between every calc and every structure. The producer side advertises what its walker yields, the consumer side declares what it needs, and the picker filters by set-subset.
The two marker interfaces
The contract lives in two sibling marker interfaces in lib/Core/Core/Interfaces/. Both stay in Core so the registry plumbing doesn’t have to know about Plus or Pro to surface the fields.
RequiresWalkerCapabilities is the consumer-side marker. Calculation strategies, both the engagement-side EngagementCalculationStrategy and the metric-side MetricCalculationStrategy, implement it when they need particular data on the walker steps they receive.
namespace Siren\Core\Core\Interfaces;
interface RequiresWalkerCapabilities
{
/**
* @return string[]
*/
public function getRequiredWalkerCapabilities(): array;
}
HasProvidedWalkerCapabilities is the producer-side marker. Structure resolvers (the flat resolver in Plus, the linearChain and parentChild resolvers in Pro, plus any third-party resolver, see custom structure resolvers) implement it to advertise what their directional walkers carry.
namespace Siren\Core\Core\Interfaces;
interface HasProvidedWalkerCapabilities
{
/**
* @return string[]
*/
public function getProvidedWalkerCapabilities(): array;
}
Both surface through RegistryResponseInterceptor, which checks instanceof against each marker and emits requiredWalkerCapabilities and providedWalkerCapabilities arrays on the registry responses the frontend consumes. The picker on the program-edit and distributor-edit screens reads both lists, then hides any calc whose required capabilities aren’t a subset of the bound group’s provided capabilities. In plain terms, a structure may provide more capabilities than a calc needs, and a calc is hidden only when it needs a capability the bound group does not provide. A calc requiring [hasLayer] is offered against a structure providing [hasLayer, hasWeight], but hidden against one providing []. With no group bound, no filter applies, so every calc shows. See calc capability matching for the operator-facing view of this behavior.
The WalkerStep contract
Structure resolvers yield WalkerStep value objects, not raw CollaboratorGroupMember instances. The base contract is minimal: a member, period.
namespace Siren\Pro\Core\Groups\Structure\Walkers\Interfaces;
interface WalkerStep
{
public function getMember(): CollaboratorGroupMember;
}
Everything else a step might carry (a layer, a weight, a distance) lives on optional marker interfaces the step implements. This is the same composable shape Siren uses elsewhere (HasInterceptors, HasMiddleware, and so on). Capability is declared by interface, not by inheritance.
Today the only marker is HasLayer:
namespace Siren\Pro\Core\Groups\Structure\Walkers\Interfaces;
interface HasLayer
{
public function getLayer(): int;
}
The hierarchical walker step Pro ships implements both:
final readonly class HierarchicalWalkerStep implements WalkerStep, HasLayer
{
public function __construct(
public CollaboratorGroupMember $member,
public int $layer
) {}
public function getMember(): CollaboratorGroupMember { return $this->member; }
public function getLayer(): int { return $this->layer; }
}
The pair to keep straight: hasLayer is the capability id (a string the picker filters by), and HasLayer is the marker interface (a type the cascade service checks with instanceof to read getLayer() safely). The two are kept in sync through the WalkerCapability enum in Pro:
namespace Siren\Pro\Core\Groups\Structure\Enums;
class WalkerCapability
{
use Enum;
public const HAS_LAYER = 'hasLayer';
}
The picker never sees the interface. The runtime never sees the string. The enum is the seam where one becomes the other.
Writing a calc that requires a capability
A Pro-tier metric calc that needs layered steps looks like this:
namespace Siren\Pro\Core\Metrics\Calculations;
use Siren\Core\Core\Interfaces\RequiresWalkerCapabilities;
use Siren\Metrics\Core\Interfaces\MetricCalculationStrategy;
use Siren\Metrics\Core\Models\MetricCalculationContext;
use Siren\Pro\Core\Groups\Structure\Enums\WalkerCapability;
class UplineCascadeMetricCalculation implements MetricCalculationStrategy, RequiresWalkerCapabilities
{
public function calculate(MetricCalculationContext $context): array
{
return $this->cascade->cascade(
$context,
fn($structure, int $triggerId) => $structure->getUplineWalker($triggerId)
);
}
public function getRequiredWalkerCapabilities(): array
{
return [WalkerCapability::HAS_LAYER];
}
// getId(), getName(), getDescription(), getRequiredArgs() omitted
}
Implementing the marker is the entire opt-in. The interceptor finds it, the picker filters on it, and the cascade service downstream can trust that any step it gets implements HasLayer because the operator was never offered an incompatible combination in the first place. The engagement-side equivalent (UplineCascadeEngagementCalculation) is structurally identical: same marker, same enum, different strategy interface and different service.
That trust is a convenience, not a guarantee. The picker only governs new picks. A calc saved against a compatible group still runs if the group is later switched to a structure that no longer provides the capability, and then its walker yields steps without it. So a calc that reads a capability still guards each step with instanceof (as the example below does) and treats a step that lacks the capability as fail-closed, skipping it rather than calling a method that is not there.
Built-in capabilities
One capability ships today.
hasLayer
ID: 'hasLayer' (WalkerCapability::HAS_LAYER)
Tier: Pro
Declares that a walker step knows its layer, the 1-indexed distance from the triggering collaborator. The linear chain and parent-child structures provide it, and the upline and downline cascade calc strategies require it. The HasLayer marker interface and the WalkerCapability enum both live in Pro.
Adding a new capability
Say you want a weighted walker, a structure where each step carries a numeric weight, and a calc that pays out proportionally to that weight. The full path from zero to a filtered picker:
- Add a constant to a capability vocabulary enum. If you’re extending the first-party Pro work, add to
WalkerCapability:public const HAS_WEIGHT = 'hasWeight';. If you’re a third-party extension, define your own enum (same shape). The picker doesn’t care where the string came from, only what it is. - Define a marker interface,
HasWeightwith agetWeight(): floatmethod. Keep it tier-appropriate. The marker doesn’t need to live in Core. - Have your walker step value object implement both
WalkerStepandHasWeight. Carry the weight as a constructor argument. - Have your structure resolver implement
HasProvidedWalkerCapabilitiesand return['hasWeight']fromgetProvidedWalkerCapabilities(). If your resolver also walks layered, return['hasLayer', 'hasWeight']. - Have your calc strategy implement
RequiresWalkerCapabilitiesand return['hasWeight']fromgetRequiredWalkerCapabilities(). At runtime, type-check each step with$step instanceof HasWeightbefore reading the weight.
The picker auto-filters from here. No frontend changes. No registry rebind. Your calc shows up exactly when a compatible structure is bound, and only then.
These steps cover the capability handshake only. Your structure resolver and your calc strategy still have to be registered through their own events before any of this surfaces. See custom structure resolvers and custom calculation strategies. And the walker step in step 3 does not invent its weight: your resolver computes it as it walks and passes it to the step’s constructor, the same way the hierarchical step receives its layer.
The capability id is a bare string, and nothing checks that your producer and consumer spell it the same way. A typo on either side makes the subset check fail, and your calc silently disappears from the picker with no error. So publish the id as a single shared constant that both your structure resolver and your calc depend on, rather than minting it on each side. When a calc you expect is missing, compare the requiredWalkerCapabilities and providedWalkerCapabilities arrays on the registry responses (the RegistryResponseInterceptor emits both) to see which string did not match.
Layering note
The marker interfaces, RequiresWalkerCapabilities and HasProvidedWalkerCapabilities, sit in Core because they’re tier-neutral plumbing. The interceptor that surfaces them sits in Core for the same reason. But the concrete vocabulary (WalkerCapability::HAS_LAYER) lives in Pro, because the cascade work that needed it is a Pro feature. The HasLayer marker also lives in Pro, alongside the hierarchical walker step that implements it and the cascade services that consume it.
The takeaway for extension authors: you can declare your own capability ids at any tier without touching Pro. The Core interfaces accept opaque strings on purpose. As long as your producer and your consumer agree on the string, the picker handles the rest.
For operators: the user-facing view of this mechanism is documented under calc capability matching and what is a cascade.