ADR-0035: Cognitive Pathway Framework Rearchitecture¶
| Field | Value |
|---|---|
| Status | Accepted (Implemented) |
| Date | 2026-09-14 |
| Authors | Spector Maintainers & Architecture Working Group |
| Deciders | Spector Technical Steering Committee (TSC) |
| Supersedes | None |
| Superseded By | None |
| Last Verified | 2026-09-16 (Verified against main) |
1. Context¶
Spector Memory expresses cognition as pathways — Remember, Recall, Reflect, Dream, Decide, Wander, Express — each a chain of SynapticRelay stages conducted by CognitivePathway<S> in spector-commons.
That conductor is sound. It already provides:
- sequential conduction with short-circuit (
transmit→false) ErrorPolicy.FAIL_FAST/DEGRADE_GRACEFULLYGatedRelay+SpecificationDivergentRelay+DivergentCapablefork/mergeCircuitBreakerRelay,ConsolidationRelay- interceptor decoration
TraceableSignal/RelayTrace
The surrounding domain layer is not sound.
1.1 Current shape¶
flowchart TD
pathway["RecallPathway / RememberPathway / ReflectPathway / …"]
conductor["CognitivePathway<S><br>← conductor (commons)"]
stages["SynapticRelay<S>[]<br>← stages"]
signal["XxxSignal<br>← request + workspace + service locator"]
pathway -- "owns" --> conductor
conductor -- "conducts" --> stages
stages -- "mutate" --> signal Each domain class is a standalone final type. There is no Pathway interface. SpectorRuntime holds seven concrete fields and exposes seven getters.
1.3 Constraints¶
- Do not rewrite the conductor.
CognitivePathway<S>stays the engine. - Public verbs stay:
recall(...),ingest(...),reflect(...),dream(...). They become sugar overconduct. - Pathways remain process-wide singletons owned by
SpectorRuntime(already true). - Hot path (Recall SIMD scan) must not pay for extra allocations per relay beyond what exists today.
- Java 25, virtual threads, existing
ConcurrentTasks/MemoryScope. - Biological naming is retained.
2. Problem Statement¶
1.2 Failure modes we are fixing¶
All counts below were verified against main at commit 33af1601.
| Symptom | Evidence |
|---|---|
| No common type | RecallPathway (L161) is the only pathway that does not implement AutoCloseable; the other six do. Callers cannot store List<Pathway<?,?>>. |
| Inconsistent entry-point naming | ReflectPathway and DreamPathway already expose conduct(signal); RecallPathway and RememberPathway do not. Partial convergence on the target shape, never finished. |
| Cross-pathway calls overload the caller | ReflectPathway has 8 public entry points — conduct ×1 (L183), execute ×4 (L206, L217, L228, L240), reflect ×3 (L305, L315, L326) — which chain down to a widest form. DreamPathway holds RememberPathway as a field (L76) and puts it on DreamSignal (L242). DreamIngestionRelay L74 calls signal.rememberPathway().ingestCognitiveWithHeader(...). |
| Caller builds callee relays | Adding a Remember relay forces Dream / Reflect construction sites to change, even when they only wanted to invoke Remember. |
| Signals are god objects | RecallSignal carries 9 request/workspace fields and 15 service-locator fields (L53–67): kernel, partition registry, index, BM25 index, Hebbian graph, temporal chain, TKG, entity directory, hyper-entity graph, quantizer, co-activation tracker, suppression set, habituation penalty, surprise detector, prospective scheduler. |
| Hidden ambient state | RecallPathway.ACTIVE_SIGNAL (InheritableThreadLocal, L182) has 12 read sites across 3 files — see §8.2. Not 3, as an earlier draft of this ADR claimed. |
| Nested recall corrupts ambient state | execute sets ACTIVE_SIGNAL (L413) and clears it in finally (L456). A nested recall therefore clears the outer conduction's signal on exit. ConductionScope fixes this as a side effect. |
| Factory explosion | RecallPathwayFactory has 6 positional create overloads (L33, L57, L82, L115, L152, L190); the widest takes 26 relay parameters plus an interceptor, with a forest of if (relay != null) builder.gated(...) null checks. ReflectPathwayFactory has 3, RememberPathwayFactory 2. One new AISME relay = new overload + constructor surgery. |
| Only one signal is traceable | RecallSignal (L34) is the sole signal implementing TraceableSignal (and DivergentCapable). The other six implement neither, so CognitivePathway's trace recording — gated on signal instanceof TraceableSignal — is inert for six of seven pathways. |
| Duplicated façade boilerplate | Every pathway reimplements builder, interceptor, try/catch → SpectorPathwayException, and close. |
| A pathway doubles as a storage SPI | RememberPathway implements IngestionTarget (L71), an SPI from spector-ingestion. Its void ingest(...) is what blocks Pathway<RememberSignal, RememberResult>. See §8.1a and ADR-0037. |
3. Decision Drivers¶
Goals¶
- One interface every domain pathway implements.
- Invoke pathway B from pathway A without A knowing B’s relays, factory, or constructor.
- Generic, typed, sharable state across pathways and relays.
- Collapse Reflect/Dream/Recall execute overloads.
- Delete
RecallPathway.ACTIVE_SIGNALand all 12 of its read sites. - Replace positional
*PathwayFactoryoverloads with recipes. - Preserve existing relay semantics (gate, diverge, short-circuit, intercept, trace).
- Make tracing work for all seven pathways, not just Recall.
- Stop
RememberPathwaydoubling as anIngestionTargetimplementation (ADR-0037). - Give error handling and circuit breaking a first-class nested-pathway story (ADR-0036).
Non-goals¶
- Replacing
CognitivePathwaywith a new engine. - Introducing a DI framework (Spring, Guice). Runtime + context is enough.
- Making signals immutable. Working memory stays mutable; services become immutable-per-conduction.
- Unifying all signal types into one class.
- Changing MCP / REST / SDK request shapes.
4. Considered Options¶
| Option | Why rejected |
|---|---|
| Keep façades, add a marker interface only | Does not fix cross-pathway wiring or god signals. |
Make domain pathways extend CognitivePathway | Collapses “operation” and “conductor”. Conductor is generic over a signal; operation has an input and an output. |
Global static Pathways.get(RecallPathway.class) | Hidden coupling, untestable, fights SpectorRuntime as the existing process singleton. |
Pass every collaborator through every execute overload | Status quo. Overload combinatorics. |
| Event bus between pathways | Async, unordered, hard to trace, wrong for “Dream ingest then continue”. Nested conduct is synchronous and scoped. |
One shared CognitiveSignal for all pathways | Forces every relay to downcast. Per-pathway signals stay; they implement ContextualSignal. |
5. Decision Outcome¶
We split three concerns that are currently one class:
Pathway<I,O>— a cognitive operation that can be addressed, invoked, and composed.CognitivePathway<S>— the relay conductor (unchanged role; optionally aliased asPathwayEngine).PathwayContext+AttributeBag— shared services and per-conduction scratch. Signals stop being service locators.
Cross-pathway calls happen through a PathwayCatalog (implemented by SpectorRuntime) and a PathwayRelay adapter. A caller never constructs the callee’s relays and never holds the callee as a field unless it is the composition root.
flowchart TD
SR["SpectorRuntime"]
PCatalog["PathwayCatalog"]
PContext["PathwayContext"]
Items["catalog, kernel, typed services, AttributeBag"]
DP["DreamPathway.conduct(DreamSignal)"]
SRelay["SynapticRelay…"]
PRelay["PathwayRelay<RememberPathway>"]
RP["RememberPathway.conduct(RememberSignal)"]
CP["CognitivePathway<RememberSignal>"]
SR -- "implements" --> PCatalog
SR -- "register(Remember, Recall, Reflect, Dream, Decide, Wander, Express)" --> PContext
PContext -- "contains" --> Items
PContext --> DP
DP --> SRelay
DP --> PRelay
PRelay -- "catalog.invoke" --> RP
RP --> CP New types live in commons so memory, metrics, and tests share them.
nucleus/spector-commons/src/main/java/com/spectrayan/spector/commons/pathway/
CognitivePathway.java // unchanged conductor
SynapticRelay.java // + optional Lifecycle
GatedRelay.java, DivergentRelay.java, NamedRelay.java
CircuitBreakerRelay.java // extended in ADR-0036
ConsolidationRelay.java
ErrorPolicy.java // extended in ADR-0036
Specification.java
TraceableSignal.java, RelayTrace.java
CognitivePathwayException.java // extended in ADR-0036
Pathway.java // NEW
AbstractPathway.java // NEW
ContextualSignal.java // NEW
AbstractSignal.java // NEW — base for all seven signals (§6.3)
PathwayContext.java // NEW
DefaultPathwayContext.java // NEW
AttributeBag.java // NEW
Key.java // NEW
PathwayCatalog.java // NEW
DefaultPathwayCatalog.java // NEW
PathwayRelay.java // NEW
PathwayComposer.java // NEW
PathwayRecipe.java // NEW
RelayFactory.java // NEW
ConductionOutcome.java // NEW (detail in ADR-0036 §5)
ConductionScope.java // NEW — ONE per root conduction; frame stack
// for cycle detection + nested traces
PathwayExceptions.java // NEW — single wrap() helper (§6.2)
Domain pathways stay in memory.pathway.{recall,remember,reflect,dream,decide,wander,express} and implement Pathway<I,O>.
Note: an earlier draft listed ConductionResult.java. The type is named ConductionOutcome (ADR-0036 §5); there is only one such type.
6. Core types¶
6.1 Pathway<I, O>¶
package com.spectrayan.spector.commons.pathway;
public interface Pathway<I, O> extends AutoCloseable {
String name();
Class<I> inputType();
Class<O> outputType();
/**
* Conduct {@code input}. If {@code input} is a {@link ContextualSignal}
* and has no context yet, the implementation MUST refuse with
* {@link IllegalStateException} — context is bound by the catalog or by
* {@link #conduct(PathwayContext, Object)}.
*/
O conduct(I input);
/**
* Bind {@code ctx} onto the input (when it is a {@link ContextualSignal})
* and conduct. Used by {@link PathwayCatalog} and {@link PathwayRelay}.
*/
default O conduct(PathwayContext ctx, I input) {
if (input instanceof ContextualSignal cs) {
cs.bind(ctx);
}
return conduct(input);
}
@Override
default void close() {
// domain pathways override when they own AutoCloseable collaborators
}
}
Public verbs remain on the concrete type and delegate:
public final class RecallPathway
extends AbstractPathway<RecallSignal, List<CognitiveResult>> {
public List<CognitiveResult> recall(String query, RecallOptions options) {
return conduct(contextOrThrow(), RecallSignal.forTextQuery(query, options));
}
}
contextOrThrow() is supplied by the runtime at attach time (see §9). Standalone unit tests pass a test context explicitly.
6.2 AbstractPathway<S, O>¶
Kills the copy-paste try/catch + exception wrap in Decide / Wander / Express / Dream / Reflect.
public abstract class AbstractPathway<S extends ContextualSignal, O>
implements Pathway<S, O> {
private final String name;
private final Class<S> inputType;
private final Class<O> outputType;
private final CognitivePathway<S> engine;
protected AbstractPathway(String name,
Class<S> inputType,
Class<O> outputType,
CognitivePathway<S> engine) {
this.name = Objects.requireNonNull(name);
this.inputType = inputType;
this.outputType = outputType;
this.engine = Objects.requireNonNull(engine);
}
@Override public final String name() { return name; }
@Override public final Class<S> inputType() { return inputType; }
@Override public final Class<O> outputType() { return outputType; }
/** Visible for metrics interceptors and tests. */
public final CognitivePathway<S> engine() { return engine; }
@Override
public final O conduct(S signal) {
Objects.requireNonNull(signal, name + " signal");
final PathwayContext ctx = signal.context();
if (ctx == null) {
throw new CognitivePathwayException(name, "<entry>", FaultKind.CONTRACT, false,
new IllegalStateException(name + " conducted without PathwayContext"));
}
final ConductionScope scope = ctx.scope();
scope.enter(name);
try {
engine.conduct(signal);
// Finish MUST be set before project(): Remember's project() reads it to
// decide RememberResult.skipped() when dedup short-circuited the engine.
ctx.outcome().finish(scope.shortCircuited(name)
? ConductionOutcome.Finish.SHORT_CIRCUITED
: ConductionOutcome.Finish.COMPLETED);
return project(signal);
} catch (RuntimeException e) {
ctx.outcome().finish(ConductionOutcome.Finish.FAILED);
throw PathwayExceptions.wrap(name, e); // ADR-0036 §3
} finally {
scope.leave(name);
}
}
/**
* Map finished signal → public output.
*
* <p>MUST tolerate a partially-populated signal: the engine may have
* short-circuited. Read {@code signal.context().outcome().finish()} to
* distinguish completion from short-circuit. MUST pass the outcome onto
* the returned report where the report has an outcome field (ADR-0036 §5).</p>
*/
protected abstract O project(S signal);
}
CognitivePathway.conduct stays exactly as it is. Domain exception wrapping moves here, once.
Three deliberate choices in the sketch above:
catch (RuntimeException)is sufficient, and this is load-bearing.CognitivePathway.conductalready catchesExceptioninternally and rethrows only unchecked types (SpectorExceptionas-is, elseCognitivePathwayException). Soengine.conductcannot throw a checked exception.Erroris deliberately not caught.- Missing context is
CONTRACT, not a bareIllegalStateException. Per the repo's error convention, pathway-layer failures carry anErrorCode. RawIllegalArgumentException/IllegalStateExceptionthrows inCircuitBreakerRelay(L78, L81) andDivergentRelay(L69) are pre-existing debt to clean up in the same pass. AbstractPathwaydoes not notify any circuit breaker. ADR-0036 §9.3 assigns both permit acquisition and success/failure recording to thePathwayRelaydecorator, so the accounting lives in one place. See ADR-0036 §9.3.
6.3 ContextualSignal¶
public interface ContextualSignal extends TraceableSignal {
PathwayContext context();
/**
* Binds (or re-binds) the context. Implementations store the reference;
* they do not copy services onto fields.
*
* <p>MUST be idempotent-overwrite, not once-only. A retried
* {@link PathwayRelay} stage (ADR-0036 §8) constructs a fresh nested input
* per attempt but binds a fresh nested context each time; a once-only
* contract would make retry throw.</p>
*/
void bind(PathwayContext ctx);
}
Every existing signal implements this — and this is real work, not a rename. Today only RecallSignal implements TraceableSignal. The other six (RememberSignal, ReflectSignal, DreamSignal, DecideSignal, WanderSignal, ExpressSignal) implement nothing, which means:
CognitivePathway.conductrecords no traces for them at all (its trace block is gated onsignal instanceof TraceableSignal && isTraceEnabled()).- §12's nested trace tree and ADR-0036 §13's trace-status table are inert for six of seven pathways until they extend
AbstractSignal. - ADR-0036's
GatedRelay-records-BYPASSEDfix is likewise a no-op outside Recall until then.
AbstractSignal is therefore scheduled explicitly in M6 (§15), not treated as incidental. ConductionOutcome living on the context rather than on each signal (ADR-0036 §5) is what keeps outcome reporting working for all seven pathways before that migration lands.
DivergentCapable is unchanged and orthogonal — RecallSignal remains its only implementor.
A small base class avoids seven copies of bind/traces:
public abstract class AbstractSignal implements ContextualSignal, TraceableSignal {
private PathwayContext context;
private final List<RelayTrace> traces = new ArrayList<>();
private final Object tracesLock = new Object();
@Override public final PathwayContext context() { return context; }
@Override
public final void bind(PathwayContext ctx) {
this.context = Objects.requireNonNull(ctx);
}
@Override
public boolean isTraceEnabled() {
return context != null && context.traceEnabled();
}
@Override
public void recordTrace(RelayTrace trace) {
if (!isTraceEnabled()) return;
synchronized (tracesLock) { traces.add(trace); }
}
@Override
public List<RelayTrace> traces() {
synchronized (tracesLock) { return List.copyOf(traces); }
}
}
RecallSignal.fork() copies candidates/attributes and shares context() (same conduction). PathwayContext.nested is only used when crossing a pathway boundary.
6.4 Key<T> and AttributeBag¶
public final class Key<T> {
private final String id;
private final Class<T> type;
private Key(String id, Class<T> type) {
this.id = Objects.requireNonNull(id);
this.type = Objects.requireNonNull(type);
}
public static <T> Key<T> of(String id, Class<T> type) {
return new Key<>(id, type);
}
public String id() { return id; }
public Class<T> type() { return type; }
@Override public boolean equals(Object o) { /* id + type */ }
@Override public int hashCode() { return Objects.hash(id, type); }
}
public interface AttributeBag {
<T> void put(Key<T> key, T value);
/**
* @throws CognitivePathwayException with {@link FaultKind#CONTRACT} and
* {@code ErrorCode.MEMORY_PATHWAY_FAILED} when the key is absent.
* Never a bare {@code IllegalStateException} — pathway-layer
* failures carry an ErrorCode so they are classifiable by
* {@code Faults.kindOf} (ADR-0036 §3).
*/
<T> T get(Key<T> key);
<T> Optional<T> find(Key<T> key);
boolean contains(Key<?> key);
AttributeBag snapshot(); // shallow immutable copy for fork()
}
Implementation: ConcurrentHashMap<Key<?>, Object> for bags that cross divergent branches; IdentityHashMap is forbidden (keys are value-equal).
The same rule applies to PathwayContext.get(Class) and get(Key).
Canonical keys live next to the type that owns them, not on a god interface:
public final class PathwayKeys {
private PathwayKeys() {}
public static final Key<CoActivationMemory> CO_ACTIVATION =
Key.of("coActivation", CoActivationMemory.class);
public static final Key<TemporalKnowledgeGraph> TKG =
Key.of("tkg", TemporalKnowledgeGraph.class);
public static final Key<EntityDirectory> ENTITY_DIRECTORY =
Key.of("entityDirectory", EntityDirectory.class);
public static final Key<Boolean> TEXT_SEARCH_EXECUTED =
Key.of("textSearchExecuted", Boolean.class);
public static final Key<Float> EFFECTIVE_TEMPERATURE =
Key.of("effectiveTemperature", Float.class);
public static final Key<Integer> LAST_INGESTED_IDX =
Key.of("lastIngestedIdx", Integer.class);
}
Stringly-typed signal.attributes() (Map<String,Object>) is deprecated and delegated to the bag for one release.
6.5 PathwayContext¶
public interface PathwayContext {
String conductionId(); // UUID string, stable for the root call
String namespaceId(); // may be null for process-global ops
NamespaceKernel kernel(); // may be null
PathwayCatalog catalog();
ConductionScope scope();
boolean traceEnabled();
<T> T get(Class<T> type); // exactly one registration
<T> Optional<T> find(Class<T> type);
<T> T get(Key<T> key);
<T> Optional<T> find(Key<T> key);
AttributeBag bag();
/** Degraded / bypassed marks and finish state. ADR-0036 §5. */
ConductionOutcome outcome();
/**
* Child context for a nested pathway invocation.
*
* <p>Shares catalog, services, bag, outcome, AND the SAME
* {@link ConductionScope} instance by reference. Pushes {@code segment}
* as a new frame on that shared scope. Does NOT share the parent signal.</p>
*/
PathwayContext nested(String segment);
}
6.5.1 ConductionScope — one instance per root conduction¶
An earlier draft described nested() as producing a "new scope frame" while §6.6 called assertNotOnStack on the parent scope. Those are incompatible: if nested() allocated a fresh ConductionScope, the pathway-name stack would reset at every hop and the Remember → Dream → Remember cycle in §10.5 could never be detected.
Settled semantics:
- One
ConductionScopeper root conduction.nested()shares it by reference and pushes a frame; it never allocates a new one. enter(name)/leave(name)push and pop frames.assertNotOnStack(name)walks the frames.pathwayName()returns the innermost pathway frame;segment()returns the dotted path used for trace prefixes and outcome mark scopes.shortCircuited(name)reports whether the engine broke early for that frame.
Thread confinement. ConductionScope is not thread-safe and must not be. DivergentRelay runs branches on separate virtual threads and forks share the context (§6.3), so concurrent enter() from N branches would corrupt the frame stack. Two rules follow:
DivergentCapable.fork()does not push a frame (§10.7) — a fork is same-pathway parallelism.PathwayRelayis forbidden inside aDivergentRelaybranch.PathwayComposer.divergent(...)rejects any branch that is, or wraps, aPathwayRelayat build time. No current recipe needs nested pathway invocation inside a parallel branch; if one ever does, the fix is a per-fork scope copy merged on join, which is a separate change.
ConductionOutcome is a different matter and is safe to share across forks — its mark lists are CopyOnWriteArrayList (ADR-0036 §5).
6.5.2 Mutable services must be bound as accessors, not values¶
bind(Class<T>, instance) snapshots a reference into an immutable service map built once per namespace. That is correct for genuinely stable collaborators and wrong for anything with a runtime setter, because the context would pin a stale value for the process lifetime.
Two live examples, both currently reached through signal.rememberPathway():
| Service | Why a value bind is wrong |
|---|---|
| Soul version | RememberPathway.currentSoulVersion is volatile short (L82), mutated at runtime via DefaultSpectorMemory.setSoulVersion → rememberPathway.setSoulVersion (L1500). Binding the short would make persona switches invisible to Reflect and Dream. |
ScalarQuantizer | Swappable via updateCognitiveRouter / updateTextDataStore. |
Rule: if the service has a setter, bind a supplier/accessor interface, not the value. See §8.1b for the two interfaces this introduces.
DefaultPathwayContext is an immutable service map + shared bag + nested scope:
public final class DefaultPathwayContext implements PathwayContext {
public static Builder builder() { return new Builder(); }
public static final class Builder {
public Builder conductionId(String id);
public Builder kernel(NamespaceKernel kernel);
public Builder catalog(PathwayCatalog catalog);
public Builder traceEnabled(boolean enabled);
public <T> Builder bind(Class<T> type, T instance);
public <T> Builder bind(Key<T> key, T instance);
public Builder bag(AttributeBag bag);
public PathwayContext build();
}
}
Registration rule: bind(Class, instance) fails if that class is already bound. Ambiguous types (List, Map, common interfaces with several implementations) must use Key<T>.
What belongs where
| Kind | Home | Examples |
|---|---|---|
| Process-wide services | Context Class<T> bind | MemoryIndex, EmbeddingProvider, ScalarQuantizer, HebbianGraphBase, PathwayCatalog |
| Per-namespace handle | Context | NamespaceKernel, PartitionManager |
| Optional subsystem | Context Key<T> | ContinuousHopfieldNetwork, LlmProvider, CognitiveManifold |
| Per-request inputs / outputs | Signal fields | query text, options, candidates, dream scenes, ReflectReport accumulators |
| Cross-relay scratch | AttributeBag | TEXT_SEARCH_EXECUTED, effective temperature |
| Cross-pathway scratch | same bag, via nested() | dream quality score consumed by Remember tags |
Relays that today call RecallPathway.activeSignal().temporalKnowledgeGraph() become:
6.6 PathwayCatalog¶
public interface PathwayCatalog {
<I, O> void register(Class<? extends Pathway<I, O>> type, Pathway<I, O> instance);
<I, O> Optional<Pathway<I, O>> find(Class<? extends Pathway<I, O>> type);
<I, O> Pathway<I, O> require(Class<? extends Pathway<I, O>> type);
<I, O> O invoke(Class<? extends Pathway<I, O>> type, PathwayContext ctx, I input);
Collection<Pathway<?, ?>> all();
}
DefaultPathwayCatalog is a ConcurrentHashMap<Class<?>, Pathway<?,?>>. Register-once: a second register of the same type throws.
invoke is the only legal nested entry:
@Override
public <I, O> O invoke(Class<? extends Pathway<I, O>> type,
PathwayContext ctx,
I input) {
final Pathway<I, O> pathway = require(type);
// Same ConductionScope instance throughout (§6.5.1) — nested() shares it by
// reference, so this guard sees every frame from the root conduction down.
ctx.scope().assertNotOnStack(pathway.name()); // PATHWAY_CYCLE — ADR-0036 §3
final ConductionOutcome childOutcome = new ConductionOutcome();
try {
return pathway.conduct(ctx.nestedWithOutcome(pathway.name(), childOutcome), input);
} finally {
// MUST be in finally (ADR-0036 §4.2): a DEGRADE_GRACEFULLY caller stage still
// has to report *why* the nested conduction failed. Importing only on the
// success path silently drops every mark from a failed nested pathway.
ctx.outcome().importFrom(childOutcome, pathway.name());
}
}
require throws CognitivePathwayException with FaultKind.CONTRACT when the type is not registered.
Amendment (implementation). Two things the original sketch got wrong:
- Outcome import belongs here, not only in
PathwayRelay— and it must be in afinally. This is better than the original design: production nests throughcatalog.invokedirectly for 1:N writes (§8.4 option b), so putting the import solely inPathwayRelaywould lose outcome merging on the path actually taken. - A caller that may run without the callee registered must check
catalog.find(type).isPresent()first.invokethrowsCONTRACTon a missing registration, so a barecatalog != nullguard is insufficient. This is the hand-rolled equivalent ofPathwayRelay'srequired(false)(§6.7), and it is easy to get wrong —DreamIngestionRelayregressed on exactly this during implementation and was caught only byDreamPathwayNestingTest.
SpectorRuntime implements this interface (or owns a DefaultPathwayCatalog and delegates). The seven getters stay as convenience:
public RecallPathway recallPathway() {
return (RecallPathway) catalog.require(RecallPathway.class);
}
6.7 PathwayRelay<S, I, O> — composition primitive¶
This is what stops Dream from owning Remember.
public final class PathwayRelay<S extends ContextualSignal, I, O>
implements SynapticRelay<S> {
private final String name;
private final Class<? extends Pathway<I, O>> targetType;
private final Function<S, I> toInput;
private final BiConsumer<S, O> absorb;
private final boolean required; // false → skip if catalog has no target
@Override
public boolean transmit(S signal) throws Exception {
final PathwayCatalog catalog = signal.context().catalog();
final Optional<Pathway<I, O>> target = catalog.find(targetType);
if (target.isEmpty()) {
if (required) {
throw new CognitivePathwayException(
signal.context().scope().pathwayName(),
name,
new IllegalStateException(targetType.getSimpleName() + " not registered"));
}
return true;
}
final I nestedInput = toInput.apply(signal);
final O output = catalog.invoke(targetType, signal.context(), nestedInput);
absorb.accept(signal, output);
return true;
}
@Override
public String relayName() { return name; }
public static <S extends ContextualSignal, I, O> Builder<S, I, O> to(
Class<? extends Pathway<I, O>> targetType) {
return new Builder<>(targetType);
}
public static final class Builder<S extends ContextualSignal, I, O> { /* fluent */ }
}
Dream ingestion stage:
composer.relay(
RelayNames.DREAM_INGEST,
PathwayRelay.<DreamSignal, RememberSignal, RememberResult>to(RememberPathway.class)
.named(RelayNames.DREAM_INGEST)
.from(DreamPorts::toRememberSignal)
.into(DreamPorts::absorbRemembered)
.required(false) // dream still succeeds if remember is absent in tests
.build(),
ErrorPolicy.DEGRADE_GRACEFULLY // see ADR-0036 for nested policy interaction
);
DreamPorts is a package-private mapper. Remember never hears about dreams. Dream never hears about Remember relays.
Reflect gist / promotion stages use the same adapter. Reflect’s eight execute overloads collapse to:
public ReflectReport reflect(ReflectSignal signal) {
return conduct(signal);
}
public ReflectReport reflect(ReflectSignal.Builder signalBuilder) {
return conduct(signalBuilder.build());
}
The builder on ReflectSignal takes partition manager, index, sweep spec, checkpoint store. It does not take RememberPathway.
6.8 Port interfaces (optional, recommended)¶
Pathways that are called from others expose a port so mappers stay honest:
public interface RememberPort {
RememberSignal toRememberSignal();
void absorbRemembered(RememberResult result);
}
Only DreamSignal and the Reflect gist working object implement this. RecallSignal does not.
RememberResult is new and tiny — Remember currently returns void / mutates internals:
public record RememberResult(
String memoryId,
int memoryIndex,
boolean dedupHit,
MemoryType type,
MemorySource source
) {
public static RememberResult skipped() { /* dedup / gated */ }
}
RememberPathway.project returns this. Existing ingest* methods stay void and ignore the result.
7. Recipes replace positional factories¶
7.1 PathwayRecipe and PathwayComposer¶
public interface PathwayRecipe<S> {
void compose(PathwayComposer<S> composer);
}
public interface PathwayComposer<S> {
PathwayComposer<S> withInterceptor(Function<SynapticRelay<S>, SynapticRelay<S>> interceptor);
PathwayComposer<S> relay(String name, SynapticRelay<S> relay, ErrorPolicy policy);
PathwayComposer<S> gated(String name, Predicate<S> gate, SynapticRelay<S> relay, ErrorPolicy policy);
PathwayComposer<S> divergent(String name, List<SynapticRelay<S>> branches, List<ErrorPolicy> policies);
PathwayComposer<S> consolidate(String name, Consumer<S> asyncAction);
PathwayComposer<S> circuitBreaker(String name, SynapticRelay<S> relay, CircuitBreakerConfig cfg, ErrorPolicy policy);
PathwayComposer<S> pathway(String name, PathwayRelay<S, ?, ?> nested, ErrorPolicy policy);
/** Skip the stage when the factory cannot resolve the relay (optional AISME). */
PathwayComposer<S> optional(String name, Class<? extends SynapticRelay<S>> type, ErrorPolicy policy);
CognitivePathway<S> build();
}
PathwayComposer is a thin wrapper over today’s CognitivePathway.Builder. No behavior change.
7.1.1 Amendment (implementation) — StageBuilder additions¶
Three methods were added during implementation that this ADR did not anticipate. Recorded because each encodes a constraint the design missed.
| Method | Why it exists |
|---|---|
gate(Predicate<S>) | The decorator chain wraps the supplied relay, so passing a pre-built GatedRelay places the gate innermost — a closed gate would then consume a bulkhead permit and register as a circuit-breaker success. gate() places it outermost, which is what ADR-0036 §6 actually requires. The original RecallRecipe COLBERT_RERANK wiring had exactly this inversion. |
timeoutIfInterruptible(Duration) | Recipes receive injected relays. Strict timeout() correctly rejects a relay that does not declare InterruptibleRelay, but test doubles never declare it — Mockito answers un-stubbed default methods with the type default, so a mocked relay reports interruptible() == false. This variant skips the budget instead of failing the build. |
retryIfIdempotent(RetryPolicy) | Same reasoning for IdempotentRelay. |
The safety property is unchanged either way: a relay that does not declare the marker never receives a budget or a retry. The only difference is whether the omission is a build failure (statically-known relay) or a logged skip (injected relay). CorticalWriteTransactionRelay declares neither marker, so it stays protected in both modes.
7.2 RelayFactory¶
Resolved once at pathway build time, not per conduction.
public interface RelayFactory {
<S> SynapticRelay<S> create(Class<? extends SynapticRelay<S>> type);
}
Default implementation: a map populated by the domain pathway constructor (same place relays are new’d today). This is not general-purpose DI.
7.3 Example: Remember recipe (replaces RememberPathwayFactory)¶
public final class RememberRecipe implements PathwayRecipe<RememberSignal> {
private final DedupGuardRelay dedup;
private final SynapticTagTransductionRelay tags;
private final DopaminergicSurpriseRelay surprise;
private final CorticalWriteTransactionRelay write;
private final SynapticGraphLinkingRelay graph;
private final KnowledgeGraphEnrichmentRelay kg;
@Override
public void compose(PathwayComposer<RememberSignal> c) {
c.relay(RelayNames.DEDUP_GUARD, dedup, ErrorPolicy.FAIL_FAST)
.relay(RelayNames.TAG_TRANSDUCTION, tags, ErrorPolicy.FAIL_FAST)
.relay(RelayNames.DOPAMINERGIC_SURPRISE, surprise, ErrorPolicy.FAIL_FAST)
.relay(RelayNames.CORTICAL_WRITE, write, ErrorPolicy.FAIL_FAST)
.relay(RelayNames.GRAPH_LINKING, graph, ErrorPolicy.DEGRADE_GRACEFULLY)
.relay(RelayNames.KG_ENRICHMENT, kg, ErrorPolicy.DEGRADE_GRACEFULLY);
}
}
RememberPathwayFactory.create(...) becomes a deprecated one-liner that builds this recipe. Adding a relay is one line in compose, not a new overload.
Recall’s four factory overloads collapse to one recipe with optional(...) for AISME relays (HopfieldAssociativeRelay, ManifoldRerankRelay, ConsciousAccessRelay, …). If the pathway constructor did not instantiate the relay (feature off), optional no-ops.
7.4 Reflect companion-skip becomes a gate¶
Today ReflectPathwayFactory.companion wraps every relay in a lambda that checks sweepSpec.runCompanionRelays(). That is a Specification:
public static final Specification<ReflectSignal> COMPANION =
Specification.of("companion relays disabled by sweep spec",
s -> s.sweepSpec() == null || s.sweepSpec().runCompanionRelays());
WAL / pruning stay ungated (FAIL_FAST) as they are today.
8. Domain pathway mapping¶
8.1 Remember — Pathway<RememberSignal, RememberResult>¶
- Constructor still creates the six relays from cortex / graph / index builders.
- Drops nothing functionally.
- Gains
RememberResult. - Does not take other pathways.
- Stops implementing
IngestionTarget— see §8.1a.
project() returns RememberResult.skipped() when the engine short-circuited (dedup hit or a closed gate), which is why §6.2 sets Finish before calling project.
Note that Remember is not uniformly void today: ingestCognitiveWithHeader (L369) already returns boolean. RememberResult generalises an existing return value rather than inventing one.
8.1a Remember sheds IngestionTarget¶
RememberPathway implements IngestionTarget, AutoCloseable (L71) is the single hardest blocker on Pathway<RememberSignal, RememberResult>, because IngestionTarget.ingest(String, String, float[]) is void and pins the void-returning entry points in place.
It is also a stale abstraction. The interface's own javadoc names two implementations:
* <li><b>EngineIngestionTarget</b> (spector-engine): VectorStore → HNSW → BM25</li>
* <li><b>CognitiveIngestionTarget</b> (spector-memory): quantize → surprise → tier route → WAL</li>
Neither exists. spector-engine was deleted, and CognitiveIngestionTarget was collapsed into RememberPathway — DefaultImportanceProvider L87 still carries the comment // Gaming detection logging (matches original CognitiveIngestionTarget). RememberPathway is the only production implementor, and it overrides neither storeParentMetadata nor onBatchComplete, so parent-document tracking and batch WAL flush are silently dropped for every connector ingest today.
Decision: IngestionTarget is deleted, not adapted. Callers use SpectorMemory.remember(...), the same single entry point MCP and REST already use. Full analysis, module moves, and the SpectorMemory.target() deprecation are specified in ADR-0037.
Sequencing consequence: the IngestionTarget removal is M1.5 in §15 — it must land before M2, because M2 is what declares implements Pathway<I,O>.
8.1b Service extraction is not pathway invocation¶
PathwayRelay + catalog.invoke solves "A invokes B." It does not solve "A reads B's mutable state," and four relays do exactly that — holding rememberPathway purely as a state accessor, never invoking it:
| Site | What it actually reads |
|---|---|
SoulDriftRefusionRelay L73 | currentSoulVersion() |
SoulDriftRefusionRelay L107, L191 | quantizer() |
ProceduralCrystallizationRelay L111 | currentSoulVersion() |
DreamIngestionRelay L57 | currentSoulVersion() |
ConstructiveMemoryPersistenceRelay L113 | currentSoulVersion() |
M5 removes rememberPathway from ReflectSignal and DreamSignal. SoulDriftRefusionRelay has no pathway to invoke — it wants a soul version and a quantizer — so M5 as originally sequenced would break it with nowhere to land.
Two narrow services are introduced and bound on the context, as accessors per §6.5.2:
/** Current soul/persona version. Accessor, not a value — mutated at runtime. */
@FunctionalInterface
public interface SoulVersionSource {
short currentSoulVersion();
}
ScalarQuantizer is bound with bind(Class, …) but sourced through a Supplier-backed holder so updateCognitiveRouter remains visible.
The five call sites become:
short soulVer = signal.context().get(SoulVersionSource.class).currentSoulVersion();
ScalarQuantizer q = signal.context().find(ScalarQuantizer.class).orElse(this.fallbackQuantizer);
RememberPathway implements SoulVersionSource — a one-method interface it already satisfies — which keeps setSoulVersion as the single writer while removing the type dependency from Reflect and Dream.
This is M4.5 in §15, strictly before M5.
8.2 Recall — Pathway<RecallSignal, List<CognitiveResult>>¶
- Deletes
private static final InheritableThreadLocal<RecallSignal> ACTIVE_SIGNAL. - Deletes
public static RecallSignal activeSignal(). execute(kernel, signal)becomes:
public List<CognitiveResult> execute(NamespaceKernel kernel, RecallSignal signal) {
final PathwayContext ctx = baseContext.nested("recall").withKernel(kernel);
return conduct(ctx, signal);
}
HebbianCoActivationListener.effectiveTracker()andTemporalFactWeavingStage.effectiveTkg()readsignal.context()when a signal is in hand; graph stages that are not relays (legacy pipeline objects invoked from a relay) receive the context as a method argument. No thread-local fallback.wasLateral(memoryId)moves toRecallHistory— and this carries state with it.wasLateral(L980) readsrecentRetrievalModes, a boundedConcurrentHashMap<String, RetrievalMode>(L199) with eviction atRETRIEVAL_MODE_CACHE_MAX(L635–645). The map moves too; the eviction policy must move intact.ReinforcementHandlerthen depends onRecallHistory, notRecallPathway.
8.2.1 ACTIVE_SIGNAL has 12 read sites, not 3¶
An earlier draft scheduled this as "update three call sites." The real inventory:
| File | Sites | Nature |
|---|---|---|
RecallPathway.effectivePartitionRegistry() L188–190 | 1 | internal, has field fallback : this.partitionRegistry |
RecallPathway.effectiveIndex() L193–195 | 1 | internal, has field fallback : this.index |
| → called from L277, L278, L677, L678, L717, L807, L905, L906 | 8 | recall hot path |
TemporalFactWeavingStage L56, L61, L66 | 3 | external |
HebbianCoActivationListener L50 | 1 | external |
Two properties of the current code matter for migration:
- The internal readers fall back to instance fields when the thread-local is unset. That fallback is what makes today's code tolerate a null
ACTIVE_SIGNAL;signal.context().find(...)has no equivalent, so the fallback must be preserved explicitly during transition. executere-propagates the signal into listener threads deliberately (L429–433:ACTIVE_SIGNAL.set(activeSig)insideConcurrentTasks.fireAndForget,remove()infinally) because pooled virtual threads do not reliably inherit. Any replacement must carry context into those async listeners explicitly.
M4 is therefore split (§15):
- M4a — thread
RecallSignalexplicitly intoeffectiveIndex/effectivePartitionRegistryand their 8 call sites, keeping the field fallback. Pure mechanical refactor, noPathwayContextinvolved, no behavior change, independently shippable and revertible. - M4b — delete
ACTIVE_SIGNAL+activeSignal(), migrate the 4 external readers tosignal.context(), and pass context explicitly into the listener dispatch.
Doing this as one PR would touch the recall hot path with no intermediate safe state.
8.3 Reflect — Pathway<ReflectSignal, ReflectReport>¶
- Removes
RememberPathwayfrom everyexecute/reflectsignature and fromReflectSignal. - Gist extraction / crystallization stages that currently call
rememberPathway.ingest...becomePathwayRelays. SoftIdentityAnchorRelayand AISME manifold relays stay ordinary relays.
8.4 Dream — Pathway<DreamSignal, DreamReport>¶
- Removes
rememberPathwayfield fromDreamPathwayand fromDreamSignal. DreamIngestionRelayeither (a) becomes aPathwayRelayplus a mapper, or (b) keeps scene-selection logic and callscatalog.invoke(RememberPathway.class, ctx, rememberSignal)for each accepted scene. Prefer (a) if ingest is 1:1 with a scene batch; prefer (b) if one dream conduction writes N memories (loop inside the relay, still via catalog).- Hebbian inhibition on failed pairs stays in Dream — that is Dream domain logic, not Remember.
8.5 Decide, Wander, Express¶
Already close. They extend AbstractPathway, keep inline gated recipes, and implement project as “return the report on the signal.”
WanderPathway.close() continues to close ContinuityMemory. AbstractPathway.close() is a hook they override.
8.6 Shared relays that are not pathways¶
SpacetimeSeedRelay.DreamSeedRelay / WanderSeedRelay stay relays, reused by two recipes. Promote to SimulationPathway only when simulation grows its own multi-stage circuit. Until then, do not invent a pathway for one relay.
9. Runtime wiring¶
SpectorRuntime already constructs pathways once and shares them across namespaces. After this ADR:
public final class SpectorRuntime implements AutoCloseable {
private final DefaultPathwayCatalog catalog = new DefaultPathwayCatalog();
private final PathwayContext processContext; // services, no kernel
// existing builders populate pathways, then:
void registerPathways() {
catalog.register(RememberPathway.class, rememberPathway);
catalog.register(RecallPathway.class, recallPathway);
catalog.register(ReflectPathway.class, reflectPathway);
catalog.register(DreamPathway.class, dreamPathway);
catalog.register(DecidePathway.class, decidePathway);
catalog.register(WanderPathway.class, wanderPathway);
catalog.register(ExpressPathway.class, expressPathway);
}
public PathwayContext contextFor(NamespaceKernel kernel, boolean trace) {
return processContext
.nested("ns:" + kernel.namespaceId())
.withKernel(kernel)
.withTrace(trace);
}
}
DefaultSpectorMemory.recall(...) becomes:
public List<CognitiveResult> recall(String query, RecallOptions options) {
RecallSignal signal = RecallSignal.forTextQuery(query, options);
return runtime.catalog()
.invoke(RecallPathway.class, runtime.contextFor(kernel, options.enableTrace()), signal);
}
Existing memory.admin().recallPathway().recall(...) keeps working because the concrete getter remains.
Attach-time capture (“first SpectorMemory fills in Recall if the runtime was not given one”) stays. After capture, register is called. Double-register is an error — today’s silent replacement becomes explicit.
10. Nested conduction rules¶
- Fresh signal. Never pass a
DreamSignalinto Remember. Map toRememberSignal. Absorb aRememberResult. - Shared context services, bag, outcome, and scope instance; new scope frame. Traces record as
dream/dream_ingest/remember/cortical_write. TheConductionScopeobject itself is shared by reference (§6.5.1) — only the frame is new. - Synchronous. Nested
conductblocks the caller relay. Fire-and-forget remainsConsolidationRelayonly. - Error policy is the caller’s stage policy. Remember’s internal
FAIL_FASTon cortical write still fails the Remember conduction; whether that fails Dream is Dream’sPathwayRelayErrorPolicy. Full matrix: ADR-0036 §4.2. - Cycle guard.
ConductionScopeholds a stack of pathway names. Re-entering the same name throwsCognitivePathwayExceptionwith codePATHWAY_CYCLE. Dream → Remember is legal. Remember → Dream → Remember is not. - Reentrancy exception. A pathway may be marked
reentrant()only if it is stateless w.r.t. the current signal (none of the current seven are). Leave the hook; do not use it. - Fork is not nest.
DivergentCapable.fork()is same-pathway parallelism. It shares context and does not push a scope frame. - No
PathwayRelayinside a divergent branch.ConductionScopeis thread-confined (§6.5.1) and divergent branches run on separate virtual threads, so nested invocation from a branch would corrupt the frame stack.PathwayComposer.divergent(...)rejects such branches at build time. - Retry constructs a fresh nested input per attempt. The retry decorator sits outside
PathwayRelay(ADR-0036 §6), sotoInput.apply(signal)re-runs andbindre-binds on each attempt — hencebindis idempotent-overwrite (§6.3).absorbruns at most once, only on the attempt that succeeds.
11. Lifecycle¶
public interface SynapticRelay<S> {
boolean transmit(S signal) throws Exception;
default String relayName() { /* existing */ }
default void open(PathwayContext processContext) {}
default void close() {}
}
AbstractPathway.close() walks engine entries and closes relays that implement AutoCloseable or override close(). Today only Wander actually owns a resource (ContinuityMemory); this makes the contract uniform. Recall implements AutoCloseable like the others (no-op unless listeners need it).
Open is called once after catalog registration, with the process context (no kernel). Relays that need a kernel keep reading it from the signal context at transmit time.
12. Tracing¶
RelayTrace gains an optional scope prefix. Implementation: CognitivePathway.conduct already records entry.relay().relayName(). DefaultPathwayContext.nested sets scope.segment(). The recorder concatenates:
dream 42_000_000 ns EXECUTED
dream/dream_gate 120_000 EXECUTED
dream/salient_seed 3_100_000 EXECUTED
dream/dream_ingest 8_400_000 EXECUTED
dream/dream_ingest/remember 8_200_000 EXECUTED
dream/dream_ingest/remember/dedup_guard 180_000 EXECUTED
dream/dream_ingest/remember/cortical_write 6_400_000 EXECUTED
dream/langevin_discovery 0 BYPASSED
No change to TraceableSignal. Nested pathway traces are appended onto the caller signal after invoke returns (PathwayRelay copies nestedInput.traces() if both are TraceableSignal). Detail of failure traces: ADR-0036 §13.
13. Performance rules¶
- Context bind is one field write on the signal. No per-relay allocation.
context.get(Class)isIdentityHashMaplookup on an immutable map sized at build time (~20 entries). Cheaper than today’s fluent-setter forest.AttributeBagis not touched on the SIMD hot loop. Cortical scan relays keep using signal fields (candidates,queryVector).- Nested Remember from Dream is already a cold path (sleep / imagination). One extra mapper allocation is acceptable.
- Do not wrap every relay in
PathwayRelay. Only actual pathway boundaries.
19. M8 — PathwayEngine rename and a single authoring API¶
Added after the ten implementation phases landed, in response to a review question that is itself the evidence for the change: "shouldn't build() return a Pathway instance — CognitivePathway is neither a pathway nor does it implement the interface?"
19.1 The naming was inverted¶
Two types, genuinely different, and the one named CognitivePathway was not the pathway:
| Type | Shape | Responsibility |
|---|---|---|
Pathway<I, O> | I → O | The public operation. Scope enter/leave, ConductionOutcome finish, onConduct metric, exception wrapping, and project(S) → O. |
PathwayEngine<S> (was CognitivePathway<S>) | S → S | The relay conductor. Runs the stage list, applies each stage's ErrorPolicy, records traces. No output type, no projection. |
AbstractPathway<S, O> bridges them by composition, not inheritance — and it already called the field engine with an engine() accessor. The code knew it was an engine; only the type name pretended otherwise.
Renamed: CognitivePathway → PathwayEngine, and its static factory pathway(name) → builder(name), which removes the word "pathway" from the authoring call entirely. Blast radius was 154 references across 32 files in three modules (spector-commons, spector-memory, spector-metrics) — nothing in synapse, cli, mcp or spring. CognitivePathwayException keeps its name: it is about pathway failure in general, and renaming it would be a public break with no clarity gain.
19.2 Rejected: make the engine implement Pathway<S, S>¶
Superficially tidy, since S → S is a valid I → O. Rejected because two implementations would then satisfy one interface with different semantics: the engine's conduct skips the scope entry, outcome finish and metric that AbstractPathway.conduct adds. A PathwayCatalog lookup could hand a caller a bare conductor where it expected a full pathway — precisely the class of confusion this ADR set out to remove. Do not propose this again.
19.3 Rejected: composer.buildAs(name, in, out, projection)¶
Would let build() hand back a Pathway<S, O> and delete AbstractPathway subclassing for the simple cases. Rejected for now: the project methods on Recall, Reflect and Dream read substantial signal state and would be worse as lambdas, and all seven pathways still need classes for their builders and config. Revisit only if a pathway appears with a one-line projection and no builder.
19.4 One authoring API — every pathway is recipe + composer¶
Before this step, four of seven pathways used PathwayComposer and three authored stages on the raw engine builder. That was not a resilience gap — ADR-0036 §14's "Decide / Wander / Express" row is uniformly DEGRADE_GRACEFULLY with nothing remote to isolate — but it meant two ways to build the same structure, where the raw one bypasses every build-time guard: rejecting retry on a non-idempotent relay, a timeout on a non-interruptible one, and ABORT or PathwayRelay inside a divergent branch. Those guards live in the composer, not the builder.
Delivered:
- New recipes:
DreamRecipe,WanderRecipe,ExpressRecipe,DecideRecipe. WanderPathway,ExpressPathway,DecidePathwaymigrated off the raw builder ontoPathwayComposer.-
DreamPathway's inline 13-stage list moved verbatim intoDreamRecipe. Dream was the worst case: it used the composer but had no recipe, so the M6.4 parity gate could not see the shape of the pathway with the most delicate wiring in the system — anABORTgate, a budgeted LLM stage, and a nested-Remember stage that must have breaker and bulkhead but deliberately no timeout. -
Relay names for all four moved into
RelayNameswith values preserved verbatim. Relay names are load-bearing: they key traces, outcome scopes and the parity assertions. Express keeps its CamelCase names for that reason, inconsistent though they look next to everything else. -
Production code no longer routes through the
@Deprecatedfactories.RememberPathwayandReflectPathwaynow callPathwayComposer+ recipe directly;RememberPathwayFactory,ReflectPathwayFactoryandRecallPathwayFactoryremain solely as external-caller shims. -
RecipeShapeParityTestpins shape and policy for the four new recipes, plus Dream's decorator placement. With the existingPathwayParityTest.RecipeRelayParitycovering Remember/Recall/Reflect against their factories, all seven pathways now have an asserted shape.
The engine builder remains public because DefaultPathwayComposer is implemented on top of it, and tests that exercise the conductor directly legitimately use it. It is no longer the authoring path for any production pathway.
One process note worth recording: the decorator-walk helper in
RecipeShapeParityTestinitially returned an empty chain for every stage, because the composer re-wraps each stage in aNamedRelaythat the walk did not unwrap. EverydoesNotContainassertion passed — for the wrong reason. The test now carries an explicit anti-vacuity case asserting the walk finds decorators where they are known to exist. Shape tests that can silently degrade to tautologies need that guard.
6. Pros and Cons of the Options¶
| Alternative | Pros | Cons |
|---|---|---|
| Status Quo (Concrete Facades) | No refactoring | 26-param factory constructors, signals as god objects, missing common type |
| Apache Camel Everywhere | Established enterprise EIP | Extreme heap allocation, non-vectorizable, high invocation overhead |
| Pathway | Zero-overhead, standard conductor, typed recipes, clean lifecycle | Migration across all 7 cognitive pathways |
7. Implementation Plan¶
| Layer | What to test | Where |
|---|---|---|
| Conductor | Existing CognitivePathwayTest unchanged | spector-commons |
| Context / bag / keys | bind, missing key, nested shares bag, snapshot isolation on fork | new PathwayContextTest |
| Catalog | register-once, require, invoke pushes scope, cycle throws | new PathwayCatalogTest |
| PathwayRelay | maps in/out, missing target + required=false continues, required=true fails | new PathwayRelayTest |
| AbstractPathway | missing context throws, project is called, wrap of checked exceptions | new AbstractPathwayTest |
| Dream → Remember | fake RememberPathway registered; Dream ingest calls it N times; Remember failure degrades Dream | DreamPathwayNestingTest |
| Recall without ThreadLocal | listener reads TKG from context; after conduct returns, no leftover ambient state | replaces activeSignal tests |
| Nested recall | inner conduction does not clear the outer signal's context on exit (today's L456 bug) | PathwayCatalogTest |
| Scope shared through nesting | Remember → Dream → Remember throws PATHWAY_CYCLE; proves nested() did not reset the frame stack | PathwayCatalogTest |
PathwayRelay in a divergent branch | PathwayComposer.divergent(...) throws at build time | PathwayComposerTest |
| Retry re-binds context | a retried PathwayRelay stage binds twice without throwing; absorb invoked once | PathwayRelayTest |
project() sees Finish | Remember short-circuited on dedup returns RememberResult.skipped() | AbstractPathwayTest |
SoulVersionSource is live | setSoulVersion after context construction is visible to a Reflect relay | SoulDriftRefusionRelayTest |
| Six signals traceable | each of the seven pathways records ≥1 RelayTrace when tracing is on | PathwayTraceParityTest |
Fakes:
catalog.register(RememberPathway.class, new Pathway<>() {
public String name() { return "remember"; }
public Class<RememberSignal> inputType() { return RememberSignal.class; }
public Class<RememberResult> outputType() { return RememberResult.class; }
public RememberResult conduct(RememberSignal in) {
return new RememberResult(in.id(), 0, false, in.type(), in.source());
}
});
Dream tests no longer construct Remember’s six relays.
15. Migration¶
Incremental. Each step is independently shippable.
| Step | Change | Rollback |
|---|---|---|
| M1 | Add commons types. No callers. Also: CognitivePathwayException uses ErrorCode.MEMORY_PATHWAY_FAILED instead of INTERNAL_ERROR; replace raw IllegalArgumentException in CircuitBreakerRelay / DivergentRelay with SpectorValidationException. | Delete the package files. |
| M1.5 | Delete IngestionTarget; RememberPathway stops implementing it. Sink and pipeline call SpectorMemory.remember(...). ADR-0037. | Restore the interface; it has one implementor. |
| M2 | Domain pathways implements Pathway<I,O> and keep public verbs. SpectorRuntime implements/hosts the catalog. | Getters still work; catalog unused. |
| M3 | Introduce PathwayContext on new conduct paths. Fluent setters on signals delegate to context and are @Deprecated. | Setters still populate both. |
| M4a | Thread RecallSignal into effectiveIndex / effectivePartitionRegistry and their 8 call sites. Field fallback retained. No context, no behavior change. | Mechanical revert of one file. |
| M4b | Delete ACTIVE_SIGNAL + activeSignal(). Migrate 4 external readers. Pass context explicitly into async listener dispatch. | Revert those 3 files. |
| M4.5 | Extract SoulVersionSource + ScalarQuantizer onto the context (§8.1b). Unblocks the 5 state-accessor sites. | Sites fall back to the signal getter, still present. |
| M5 | Dream + Reflect call Remember via catalog / PathwayRelay. Remove rememberPathway from their fields, signals, and execute overloads. | Restore field passing. |
| M6 | Recipes replace factory overloads. All six non-traceable signals extend AbstractSignal (§6.3). Widest factory kept as @Deprecated wrapper. | Wrapper still used by any missed caller. |
| M7 | Slim signals: remove service fields that are now on context. Delivered: the dead kernel field is gone from Recall/Remember/Reflect/Dream/Wander signals, with each Builder.kernel(...) and write site. Relays read the kernel from the context binding. | After one release of deprecation. |
| M8 | Rename CognitivePathway → PathwayEngine; one authoring API. See §19. | Mechanical inverse rename. |
Ordering constraints, all of them load-bearing:
- M1.5 before M2.
implements Pathway<RememberSignal, RememberResult>cannot coexist withIngestionTarget'svoid ingest. - M4a before M4b. M4b alone would touch 8 hot-path sites with no intermediate safe state.
- M4.5 before M5. M5 deletes the field that the 5 state-accessor sites read; without M4.5 they have nowhere to go.
- M6 needs
AbstractSignalor §12's trace tree stays inert for six pathways. - Do not start M7 until M4–M5 have soaked. Do not combine M5 and M6 in one PR.
Compatibility shims for one minor version:
@Deprecated
public RememberPathway rememberPathway() { /* field on DreamSignal */ }
@Deprecated
public DreamSignal rememberPathway(RememberPathway p) {
// no-op store; log once
return this;
}
Commons (spector-commons):
-
Key,AttributeBag,PathwayContext,DefaultPathwayContext -
Pathway,AbstractPathway,ContextualSignal,AbstractSignal,PathwayExceptions -
PathwayCatalog,DefaultPathwayCatalog -
ConductionScope— one per root conduction, thread-confined (§6.5.1) -
PathwayRelay -
PathwayRecipe,PathwayComposer(delegate to existing builder) -
PathwayComposer.divergent(...)rejectsPathwayRelaybranches at build time -
RelayFactory(map-backed) -
CognitivePathwayException→ErrorCode.MEMORY_PATHWAY_FAILED - Raw
IllegalArgumentException→SpectorValidationExceptioninCircuitBreakerRelay,DivergentRelay - Tests listed in §14
Memory:
-
RememberPathwaystops implementingIngestionTarget(M1.5, ADR-0037) - Each domain pathway extends
AbstractPathway/ implementsPathway -
SpectorRuntimehosts the catalog and builds process context -
RememberResult+ Rememberproject(incl.skipped()on short-circuit) -
SoulVersionSource;ScalarQuantizerbound on context (M4.5) -
DreamPorts, Reflect ports - M4a: thread
RecallSignalinto the 8 internaleffective*call sites - M4b: delete
ACTIVE_SIGNAL+ 4 external readers -
wasLateral+recentRetrievalModes(with eviction) move toRecallHistory - Collapse Reflect / Dream execute overloads
- Recipes for Remember / Recall / Reflect — superseded by §19: all seven pathways now have a recipe, none stay inline
- All six non-traceable signals extend
AbstractSignal(M6) - Deprecate fluent service setters on signals
Docs:
- This ADR
- ADR-0036 (error handling, isolation, circuit breakers)
- ADR-0037 (ingestion boundary and sensory relocation)
- Short note on
docs/architecture/overview.mdpointing at all three
- A pathway needs asynchronous nested invocation (not fire-and-forget consolidation). That would require a different primitive than
PathwayRelay. - Relay graphs become data-driven (external YAML). Recipes would become the compiler target; this ADR does not block that.
- A DI container is adopted process-wide.
RelayFactory/ context binds would delegate to it;Pathway/ catalog stay.
Error handling, isolation, retries, timeouts, bulkheads, and the circuit-breaker model for nested pathways are specified in ADR-0036.
8. Code Reference & Verification¶
Positive¶
- Pathways are addressable peers. Adding an eighth pathway (e.g.
SimulatePathway) is: implementPathway, write a recipe,catalog.register. No existing constructor grows. - Dream / Reflect constructors stop tracking Remember’s internals.
- Signals shrink to working memory. Relays become testable with a stub context.
- Thread-local ambient state goes away — required for correctness under virtual threads.
- Observability becomes a tree, not a flat list.
Negative / accepted cost¶
- One extra type layer (
PathwayvsCognitivePathway). Documented in §2 so newcomers do not collapse them again. - Mappers (
DreamPorts) must be written and kept in sync withRememberSignalfields. This is cheaper than constructor coupling. - Catalog lookup on every nested call. Cold path only.
- Deprecation window on fluent setters and factory overloads.
Risks¶
| Risk | Mitigation |
|---|---|
| Context used as a dump | Review rule: new Class<T> binds require a one-line justification in the PR. Prefer Key<T> for optionals. |
| Cycle introduced by a new pathway | ConductionScope.assertNotOnStack in invoke, over a scope shared through nested() (§6.5.1). Test in PathwayCatalogTest. |
Missed activeSignal call site | grep -rn activeSignal in CI on M4b. Compile-fail once the method is deleted. M4a first removes the 8 internal readers, leaving 4. |
| Recipe forgets a relay | Parity test: CognitivePathwayParityTest compares recipe-built engine relay names and order to the current factory output for all seven pathways. |
| Stale soul version after M4.5 | SoulVersionSource is an accessor, never a snapshot (§6.5.2). Test asserts post-construction setSoulVersion visibility. |
| Scope corruption from a parallel branch | ConductionScope is thread-confined; PathwayRelay banned inside DivergentRelay at build time (§10.8). |
| Trace tree silently empty | PathwayTraceParityTest fails if any of the seven pathways records zero traces with tracing on. |
Code Reference & Verification Gate¶
- Primary Module(s):
nucleus/spector-commons,memory/spector-memory - Key Packages:
com.spectrayan.spector.commons.pathway,com.spectrayan.spector.memory.pathway - Classes:
CognitivePathway.java,RecallPathway.java,RememberPathway.java,ReflectPathway.java,DreamPathway.java,WanderPathway.java,ExpressPathway.java - Verification Tests:
RecallPathwayTest.java,DreamPathwayTest.java,WanderPathwayTest.java