Adding a Chain-Stage Predicate
Audience: Pulse internals contributors tightening or relaxing the
gate that decides whether an operator is allowed in a ProcessChain
stage.
ProcessChain (pulse.ProcessChain, pulse_process_chain,
pulse api process-chain) executes a linear pipeline whose stages all
pass one shared gate, internal/mergegate.ChainRefusal. The gate enforces that each stage
emits a shape the next stage can consume; v1 admits mergeable operators
only, and every mergeable built-in aggregator emits one float64 per row.
1. Edit the shared gate
Edit internal/mergegate/gate.go. ChainRefusal runs MergeRefusal
(the merge rule processing.CanMergeRequestWithExtensions also
delegates to) and, today, nothing else — no mergeable built-in emits a
shape the next stage cannot read. Add a chain-specific branch after
MergeRefusal when an operator is mergeable but its emit shape would
break the synthesised f64 / categorical_u32 schema the next stage
expects; return a reason string naming the operator.
2. Both sides pick it up
There is nothing to mirror. The runtime calls it as
processing.ChainRefusal (over the ExtensionRegistry) and
internal/descriptor/chain.go calls mergegate.ChainRefusal directly
(over the ExtensionsSnapshot), so code, message and details
{stage_index, stage_name} are identical by construction. The package
imports only types, encoding and errors: a fact only the engine
holds must reach it through the mergegate.Extensions interface, which
both adapters implement — never by importing internal/processing.
3. Update the capability surface
Edit internal/descriptor/capabilities_chain.go. processChainCapability()
carries the manifest-facing allowlists and RejectionRules strings.
After editing, regenerate descriptor/testdata/manifest.json:
go test ./descriptor/ -update -run Golden
Then verify the new hash sticks:
go test ./descriptor/ -run TestGoldensNotHandEdited
4. Tests
Add the rule’s case to internal/mergegate/gate_test.go and a row to
TestChainGate_ValidatorMatchesRuntime (root chain_gate_parity_test.go),
which runs each stage through ProcessChain and ValidateChainWithOptions
and requires the same code, message and details.
5. Allowlist skim
Skim skills/session-bootstrap.md and skills/process-chain.md for
any operator allowlist that needs adjustment in prose.
6. Whole-chain overlays
ChainRequest.Overlays []*ChainOverlaySpec is the whole-chain overlay
surface — overlays here execute AFTER every stage finalises (NOT
between stages). Per-stage overlays continue to ride the universal
ChainStage.Request.Overlays []OverlaySpec slot.
ChainOverlaySpec(intypes/chain.go):Name string,Kind OverlayKind,Ref StageRef,Target StageRef,Scope OverlayScope,Params map[string]any.StageRef(intypes/chain.go): XOR{Index *int, Name string}.Indexis a pointer so0is meaningful — the canonical “stage 0” call site setsIndex = &zero, notIndexunset. The downstream validator enforces “exactly one of Index / Name”.OverlayRef.Stage(intypes/overlay.go) is the same*StageRef— there is exactly oneStageRefdeclaration in the codebase. The legacyOverlayStageRefidentifier is a type alias toStageRefand is deprecated.ChainResponse.Overlays []*OverlayLayerreuses the universalOverlayLayerwrapper fromtypes/overlay.go(one entry perChainRequest.Overlaysspec in matching index order).
Canonical-hash coverage is data-driven (types/hash.go): the slot’s
omitempty tag means overlay-free chain requests hash byte-identically
to the overlay-free baseline; populated overlays fold into the hash
automatically. Locked by TestChainCanonicalHash_OverlayFreeByteIdentity
and TestChainCanonicalHash_OverlaysIncluded.
Whole-chain handler dispatch lives in internal/processing/overlay_chain_dispatch.go.
Predict-time validation lives in internal/descriptor/chain_overlay.go —
ValidateChain walks ChainRequest.Overlays after the per-stage
gate and emits:
PULSE_OVERLAY_KIND_UNKNOWN— anything outsideOVERLAY_INDEX_VS_STAGE/OVERLAY_DELTA_VS_STAGEis rejected today.PULSE_OVERLAY_REFERENCE_UNKNOWN/PULSE_OVERLAY_TARGET_UNKNOWN— StageRef resolution failures. Same Index / Name / XOR / latest-stage-default contract the runtimeresolveChainStageRefuses.PULSE_OVERLAY_CHAIN_STAGE_SHAPE_DIVERGENT— Ref and Target stages produce different host shapes perinferChainStageShape:req.Crosstab != nil ⇒ MATRIX,req.Aggregations + req.Groups ⇒ SERIES,req.Aggregations only ⇒ SCALAR.
Every rejected (Ref, Target) pair also lands in
ChainValidationResult.OverlaysSchemaDivergence so LLM planners can
budget reshapes without re-parsing envelope details.
7. Run the gates
go test ./internal/service/ -run TestChain
go test ./internal/descriptor/ -run 'TestValidateChain|TestProcessChain'
go test ./descriptor/ -run TestGoldensNotHandEdited
The Update Demand row for ProcessChain capability changes covers
all of these in one PR; see The Update Demand.