Source Walkthrough

Every file in src/signalpy/kernel/, explained in dependency order. A reference for reading the source.

When to read this: You want to read the actual source code and need a map. This is NOT a tutorial — it’s a companion to have open alongside the code. For the mental model without source-diving, see Architecture or Reactivity by Example.

The kernel is 9 files, ~3,800 lines. You can read the whole thing in one sitting. Here is the order that will make the most sense — each file only depends on the ones before it.

Reading order

flowchart LR
    A["1. reactive.py<br/><i>~600 lines</i>"] --> B["2. component.py<br/><i>~750 lines</i>"]
    B --> C["3. contracts.py<br/><i>~80 lines</i>"]
    C --> D["4. runtime.py<br/><i>~170 lines</i>"]
    D --> E["5. registry.py<br/><i>~200 lines</i>"]
    E --> F["6. bus.py<br/><i>~400 lines</i>"]
    F --> G["7. lifecycle_manager.py<br/><i>~560 lines</i>"]
    G --> H["8. traits.py<br/><i>~180 lines</i>"]
    H --> I["9. __init__.py<br/><i>~900 lines</i>"]

Why this order? Each file only depends on the ones before it. If you read them in this sequence, you will never hit a concept you haven’t seen yet.


File 1: reactive.py — The Foundation

What it is: Three primitives (Signal, Computed, Effect) and a batch mechanism. This is the reactivity engine. Everything else in the kernel builds on top of it.

Zero dependencies. This file imports nothing from the rest of the kernel. It only uses Python’s standard library (threading, asyncio, contextvars).

Key concepts

Signal — a box that holds a value. When you read it, the reader is tracked. When you write it, all tracked readers are notified.

counter = Signal(0)
counter.get()   # read --- if inside an Effect, the Effect is now subscribed
counter.set(5)  # write --- all subscribers are notified
counter.peek()  # read without subscribing

Computed — a cached derived value. It wraps a function. The function runs once, and its result is cached. When any Signal it read changes, the cache is marked dirty. On the next .get(), it recomputes.

doubled = Computed(lambda: counter.get() * 2)
doubled.get()      # 10 (runs the function, caches result)
doubled.get()      # 10 (cache hit, function does NOT run)
counter.set(6)     # marks doubled as dirty
doubled.get()      # 12 (recomputes)

Effect — a side effect function that re-runs automatically. Like Computed, but instead of caching a value, it runs for its side effects.

Effect(lambda: print(f"Counter is {counter.get()}"))
# Prints "Counter is 5"
counter.set(10)
# Automatically prints "Counter is 10"

batch() — group multiple writes so effects fire once, not per-write.

with batch():
    a.set(1)
    b.set(2)
# Effects that depend on a OR b run ONCE here, not twice

How tracking works

There is one global variable: _active_consumer. It holds whichever Effect or Computed is currently executing (or None).

  1. An Effect starts running. The engine sets _active_consumer = this_effect.
  2. The Effect’s body calls some_signal.get().
  3. Inside Signal.get(): “Is there an active consumer? Yes. Record this signal as a dependency of that consumer.”
  4. The Effect finishes. The engine clears _active_consumer.

Later, when some_signal.set(new_value) is called, the Signal looks at its subscriber list and calls ._notify() on each one. Effects re-run. Computeds mark themselves dirty.

Thread safety

A single threading.RLock serializes all mutations. This means:

  • Two threads writing to the same Signal will not corrupt it.
  • An Effect that writes to a Signal it reads won’t deadlock (RLock is reentrant).
  • The tradeoff: throughput. Only one thread can mutate reactive state at a time. For I/O-bound services this is fine.

What to look for when reading

  • Signal.__init__, Signal.get, Signal.set — the core.
  • _active_consumer ContextVar — the tracking mechanism.
  • Computed._recompute — where lazy evaluation happens.
  • Effect.run — where the consumer sets _active_consumer and tracks deps.
  • _flush_batch — effects are sorted by creation order (parent before child).

File 2: component.py — Decorators and Metadata

What it is: The 11 decorators that turn a plain Python class into a kernel-managed component. Every decorator does one thing: attach metadata to the class. Nothing runs at decoration time.

The mental model

Decorators are just sticky notes. @runnable("search", ...) is a sticky note that says “this method is a runnable named search.” The kernel reads the sticky notes later.

Key data structures

Dataclass What it stores
ComponentMeta Everything the kernel needs about a class: name, version, provides, requires, runnables, effects, computeds, subscriptions, lifecycle callbacks, supervision
RequirementDef One @requires entry: attr name, contract name, aggregate/optional/key flags
RunnableDef One @runnable: name, params model, description, auth requirements, transports list
EffectDef One @effect: the function, async flag, cancel_on_supersede
ComputedDef One @computed: the function
SupervisionDef One @lifecycle.supervision: strategy, max_restarts, backoff config

The lifecycle namespace

lifecycle is a class with static methods. Each method is a decorator:

@lifecycle.activate          # called when component starts
@lifecycle.deactivate        # called when component stops
@lifecycle.health            # returns health status
@lifecycle.snapshot          # serializes state before hot_update
@lifecycle.restore           # deserializes state after hot_update
@lifecycle.supervision(...)  # supervision strategy for child failures

_finalize_meta(cls) — the bridge

This function is called once per class, at discovery time. It:

  1. Scans all methods for sticky-note markers (__runnable_defs__, __lifecycle__, __effect__, __computed__, etc.)
  2. Scans type annotations for implicit @requires (if you annotate a field with a contract type, it becomes a requirement)
  3. Auto-infers dependencies from contracts (IConfig means you depend on the config factory)
  4. Collects everything into ComponentMeta

What to look for when reading

  • The 11 decorator functions (each is 5-15 lines).
  • ComponentMeta — the central data structure.
  • _finalize_meta — the only complex function. Read it top-to-bottom.

File 3: contracts.py — Protocol Interfaces

What it is: Python Protocol classes that define what a service looks like. These are the “shapes” that providers must match.

@runtime_checkable
class IConfig(Protocol):
    def get(self, key: str, default: Any = None) -> Any: ...
    def set(self, key: str, value: Any) -> None: ...
    def all(self) -> dict[str, Any]: ...

When you write @requires(config=IConfig), you’re saying “I need something that matches the IConfig shape.” The kernel finds a provider that @provides(IConfig) and injects it.

The 8 contracts

Contract What it provides Used by
IConfig Key-value configuration Almost everything
ILogger Structured logging Almost everything
ICredentials Scoped secrets access Apps that need API keys
IStorage Async key-value storage Apps that persist data
IAuth Token authentication + authorization Auth-protected runnables
ITracer OpenTelemetry-style span tracing Observability
IWorkspace Working directory + settings File-based apps
IConfigAdmin Managed service configuration ConfigProvider itself

This is the shortest file. Read it in 2 minutes.


File 4: runtime.py — The Component’s Window

What it is: The Runtime dataclass. Every component gets one. It is the only object a component interacts with. Accessed as self.rt.

Why it exists

Components never see the kernel, the registry, or the bus directly. They see self.rt, which exposes a curated, policy-checked API.

How reactive injection works

Every injected service is wrapped in a Signal:

self._signals = {
    "config": Signal(config_service),
    "logger": Signal(logger_service),
}

When you access self.rt.config, Python calls Runtime.__getattr__("config"), which calls self._signals["config"].get(). If you’re inside an @effect, that read is tracked. When the config service changes, your effect re-runs.

Key methods

Method What it does
self.rt.config Reactive read of injected service
self.rt.peek("config") Non-reactive read (no tracking)
await self.rt.publish(event, data) Publish a bus event. Policy-checked.
self.rt.on(event, handler) Subscribe to bus events
await self.rt.spawn(factory, name, props) Create a child component

Policy enforcement

The kernel sets _publish_allow on each Runtime. Every publish() call checks these lists before proceeding. Cross-component calls use @requires + direct method calls – the registry and contracts enforce access, not the bus.

What to look for when reading

  • __getattr__ — the reactive read mechanism.
  • inject() — how the kernel pushes service updates.
  • _check_permission — fnmatch-based allow/deny.

File 5: registry.py — Service Registry

What it is: A dictionary of “who provides what.” When component A says @provides(IConfig) and component B says @requires(config=IConfig), the registry connects them.

Key concepts

ServiceEntry — one row in the registry:

contract="IConfig", impl=<ConfigProvider instance>, provider_name="config",
properties={"service.ranking": 0}

Ranking — when multiple components provide the same contract, the registry returns the one with the lowest service.ranking. This is how you override a default provider with a better one.

Ref countingacquire(contract, consumer) increments a counter. release(contract, consumer) decrements it. ref_count(contract) tells you how many consumers are using a service. Hot-remove uses this for cleanup.

Change listenerson_change(callback) lets you react when services are added or removed. The kernel uses this for reactive propagation: when a service changes, update all consumer Runtimes.

What to look for when reading

  • provide() / unprovide() — add/remove services.
  • require() / require_all() / require_map() — the three retrieval modes.
  • _listeners and _notify() — how the kernel knows a service changed.

File 6: bus.py — Communication

What it is: The event bus. One pattern:

  • publish(event_type, data) — broadcast an event to all subscribers. Like a radio station.

Cross-component calls use @requires to inject a dependency and call its methods directly. The bus is event-only (publish/subscribe).

Reliability features

Feature Method What it does
Dead letter _dead_letter(event, data, reason) Publishes to __dead_letter__ channel on failure

What to look for when reading

  • publish() — the event fan-out.
  • _dead_letter() — failure recording.

File 7: lifecycle_manager.py — State Machine

What it is: Manages every component through its lifecycle:

stateDiagram-v2
    [*] --> DISCOVERED
    DISCOVERED --> RESOLVED : resolve_all() (toposort)
    RESOLVED --> ACTIVATING : activate()
    ACTIVATING --> ACTIVE : success
    ACTIVATING --> ERRORED : exception
    ERRORED --> RESTARTING : supervised retry
    RESTARTING --> RESOLVED : backoff complete
    ACTIVE --> DEACTIVATING : deactivate()
    DEACTIVATING --> STOPPED : always
    ERRORED --> RESOLVED : retry_erroneous() (manual)

Key data structure: ComponentInstance

@dataclass
class ComponentInstance:
    factory_class: type          # the @component class
    meta: ComponentMeta          # all metadata from decorators
    name: str                    # instance name
    properties: dict             # runtime properties
    state: State                 # DISCOVERED, RESOLVED, ACTIVATING, ...
    instance: Any                # the actual Python object (created at activation)
    error: Exception | None      # set if ERRORED
    parent: str | None           # parent instance name
    children: list[str]          # child instance names
    _disposables: list           # Effects/Computeds to clean up
    _restart_tracker             # sliding window for supervision

What happens at activation

  1. The class is instantiated: ci.instance = ci.factory_class()
  2. A Runtime is built (services injected as Signals)
  3. self.rt is attached to the instance
  4. The @lifecycle.activate callback is called
  5. State transitions to ACTIVE

Dependency ordering

resolve_all() runs Kahn’s algorithm (topological sort) on the dependency graph. If A requires B, B activates first. Circular dependencies raise RuntimeError.

Shutdown

shutdown() deactivates in reverse activation order. Children are deactivated before parents. All @effect and @computed disposables are disposed.

Supervision

When a child component fails activation and its parent has @lifecycle.supervision:

  1. The RestartTracker records the failure timestamp.
  2. If restarts within the window exceed max_restarts, escalate to the parent’s supervisor.
  3. Otherwise, call the supervision callback. If it returns True:
    • one_for_one: restart just the failed child.
    • one_for_all: restart ALL siblings.
    • rest_for_one: restart the failed child + everything started after it.
  4. Backoff delay (constant, linear, or exponential) before retry.

What to look for when reading

  • State enum — all 8 states.
  • activate() — the activation sequence.
  • resolve_all() — Kahn’s algorithm.
  • deactivate() — recursive child-first shutdown.
  • _handle_activation_failure() — the supervision entry point.
  • _restart_one(), _restart_all_children(), _restart_from() — the three strategies.

File 8: traits.py — The Trait System

What it is: A registry of traits organized by level (L0–L3). Traits are inferred from what a component declares, not labeled by the developer.

The levels

Level Name What makes it this level
L0 Kernel Every component has these (identifiable, lifecycle, etc.)
L1 Platform Infrastructure services (configurable, observable, supervisable)
L2 App Domain capabilities (runnable, reactive, routable)
L3 Instance Per-instance parameterization (targeted, scoped, versioned)

How inference works

TraitRegistry.compute(meta) reads the ComponentMeta and returns a list of trait names. For example:

  • Has @requires(config=IConfig) in its requirements? -> configurable.
  • Has @runnable declarations? -> runnable, communicable.
  • Has @lifecycle.supervision defined? -> supervisable.
  • Has @computed or @effect? -> reactive.

What to look for when reading

  • compute() method — all the inference rules in one place.
  • The trait name constants at the bottom of the file.

File 9: __init__.py — The Kernel Orchestrator

What it is: The Kernel class that ties everything together. This is the last file because it imports and uses all the others.

What the Kernel owns

class Kernel:
    traits = TraitRegistry()
    registry = ServiceRegistry()
    bus = Bus()
    lifecycle = LifecycleManager()

Boot sequence

kernel = Kernel()
kernel.discover([ConfigProvider, LoggingProvider, MyApp, RESTTransport])
await kernel.boot()

boot() does:

  1. Setup reactive propagation — registers a listener on the ServiceRegistry so that when any service changes, all consumer Runtimes get updated.
  2. Instantiate — creates one ComponentInstance per factory.
  3. Resolve — topological sort to determine activation order.
  4. Activate each — build Runtime, inject services, call @lifecycle.activate, register bus handlers, set up @effect/@computed.
  5. Gateway rebuild — transport adapters discover runnable schemas via kernel.runnables() and build transport surfaces. Transports are re-activated.

Key methods to read

Method What it does
_build_runtime(ci) Constructs a reactive Runtime for a component. This is where services become Signals.
_setup_reactive_propagation() The global listener that propagates service changes to Runtimes.
_register_component_bus(ci) Registers subscriptions, kinds, skills for a component.
_setup_reactive(ci) Creates ReactiveComputed and ReactiveEffect wrappers for @computed/@effect methods.
_spawn_child(...) Creates and activates a child component with supervision support.
hot_add(cls) / hot_remove(name) Live component add/remove.
hot_update(new_cls) Blue-green replacement: snapshot state, tear down, replace factory, re-activate, restore.

Structural scoping

At the bottom of the file: _ScopedCredentials and _ScopedStorage. These wrappers ensure a component can only see its own credentials and its own storage prefix. This is enforced structurally — there is no configuration to disable it.

What to look for when reading

  • boot() — the full startup sequence.
  • _build_runtime() — the bridge between the registry and reactive injection.
  • _setup_reactive_propagation() — the heart of the reactive graph.
  • hot_update() — the most complex method (blue-green replacement).

How everything connects

flowchart TB
    subgraph "Decoration Time (import)"
        Dec["@component, @provides, @requires, @runnable, ..."]
        Dec -->|"attach markers"| Meta["ComponentMeta<br/>(sticky notes on the class)"]
    end

    subgraph "Discovery Time (kernel.discover)"
        Meta -->|"_finalize_meta()"| Fin["Scan methods,<br/>collect all markers,<br/>infer dependencies"]
    end

    subgraph "Boot Time (kernel.boot)"
        Fin --> Inst["Instantiate<br/>(create POPO)"]
        Inst --> Resolve["resolve_all()<br/>(toposort)"]
        Resolve --> Act["Activate:<br/>1. Build Runtime (Signals)<br/>2. Inject services<br/>3. Call @lifecycle.activate<br/>4. Register bus handlers<br/>5. Set up @effect/@computed"]
    end

    subgraph "Run Time"
        Act --> RT["self.rt.config<br/>(reactive read)"]
        RT --> Sig["Signal.get()<br/>(tracks consumer)"]
        Sig --> Eff["@effect re-runs<br/>when Signal changes"]
        Act --> Bus["rt.publish()<br/>(event bus)"]
    end

Suggested exercise

After reading, try this:

  1. Open src/signalpy/examples/03_reactive_config.py and run it.
  2. Trace the flow: which decorator fires first? When does the Runtime get built? When does the @effect first run?
  3. Modify the config and watch the effect re-run.
  4. Read test_kernel.py — the tests show every feature in isolation.

Quick reference: file sizes

File Lines Purpose
reactive.py ~600 Signal, Computed, Effect, batch
component.py ~750 11 decorators + metadata dataclasses
contracts.py ~80 8 Protocol interfaces
runtime.py ~170 The self.rt object
registry.py ~200 Service registry + ref counting
bus.py ~400 publish/subscribe + dead letter
lifecycle_manager.py ~560 State machine + supervision
traits.py ~180 L0-L3 trait inference
__init__.py ~900 Kernel orchestrator
Total ~3,800