Amplifier Core 1.4 · Engineering Story

Born Ready

How on_session_ready() ended module blindness
and made the logging hook the first to benefit

A new post-composition lifecycle hook that gives every module a guaranteed moment
when the full coordinator is assembled — with real-world verification via
Digital Twin Universe.

amplifier-core 1.4 hooks-logging Digital Twin Universe Phase 6 Lifecycle

April 2026

Use ← → or click to navigate

The Problem

Modules Mount in the Dark

When a module's mount() runs, the coordinator is only partially assembled. Each phase completes before the next even begins:

Phase 1 Orchestrator — coordinator is empty Phase 2 Context — sees orchestrator only Phase 3 Providers — sees orch + context Phase 4 Tools — sees orch + context + all providers Phase 5 Hooks — sees everything EXCEPT later hooks [return] — no "done" signal
After phase 5, _session_init.py simply returns. There is no "all-modules-ready" signal. A hook that wanted to know whether a tool was already mounted had to guess — and defend against both orderings.

Result: every cross-module interaction required defensive dual-path initialization. And one bug slipped through undetected for months.

The Defensive Pattern Tax

Code Written to Survive Mount Order

Pattern 1 — Dual-path init
# mount() registers the event handler AND
# immediately runs setup if the tool is
# already there — because it might not be.
coordinator.hooks.register(
  "skills:discovered", on_skills_discovered
)
if coordinator.get_capability(
    TOOL_SKILLS_DISCOVERY_CAPABILITY
) is not None:
    # duplicate path — tool mounted first
    await _refresh_watched_skills(...)
        
Pattern 2 — Triple-path event discovery
async def _discover_events(coordinator):
    # Path 1: core canonical list
    discovered = set(ALL_EVENTS)
    # Path 2: contribution channels
    contributions = await coordinator
        .collect_contributions("obs.events")
    for ev in contributions:
        discovered.update(ev)
    # Path 3: legacy capability fallback
    cap = coordinator.get_capability(
        "observability.events")
    if cap is not None:
        discovered.update(cap() if callable(cap)
                           else cap)
    return discovered  # 3 paths for 1 answer
        
4
dual-path inits
9
defensive get_capability()
3
mount-order comments
2
event fallback chains
The Concrete Bug

7% of the Graph Was Missing

The context-intelligence hook used collect_contributions() to discover observable events. tool-delegate registered its 5 delegation events using the wrong API:

Before — invisible to collect_contributions
# tool-delegate/src/__init__.py
obs_events = coordinator.get_capability(
    "observability.events") or []
obs_events.extend([
    "delegate:agent_spawned",
    "delegate:agent_completed",
    ...3 more...
])
coordinator.register_capability(
    "observability.events", obs_events
)
# register_capability ≠ register_contributor
# collect_contributions() never sees this
        
After — correct contribution channel
# tool-delegate/src/__init__.py
coordinator.register_contributor(
    "observability.events",
    "tool-delegate",
    lambda: [
        "delegate:agent_spawned",
        "delegate:agent_resumed",
        "delegate:agent_completed",
        "delegate:agent_cancelled",
        "delegate:error",
    ],
)
# now visible to collect_contributions()
        
Impact: the entire delegation tree — every spawned sub-agent, every completed delegation — was absent from the context-intelligence graph. A 7% event coverage gap, silently present for months.
Before Writing Code

Parallax Discovery First

Before touching the kernel, a multi-agent Parallax Discovery investigation mapped the full scope. Four dedicated sessions, ~1,400 JSONL events each. Three independent agent perspectives per topic.

code-tracer confirmed: grep -r "did_mount|post_mount|modules_ready" amplifier-core/ → zero results. No lifecycle exists.
behavior-observer catalogued all 41 canonical events in events.rs. None were mount-lifecycle events.
Pattern Instances Fixable
Dual-path init4100%
Mount-order comments3+100%
Event fallback chains2100%
Defensive get_capability933%
App-level post-mount rewiring~6~67%
Verdict: the problem is systemic. The fix is a single new lifecycle hook. A 672-line design proposal was written before any implementation began.
The Solution

One Function. One Guarantee.

async def on_session_ready(coordinator) -> None:
    """Called after ALL modules have completed
    their mount() calls. The coordinator is
    fully composed. Return value is ignored.
    Exceptions are non-fatal and isolated.
    """
    # Tools are here. Providers are here.
    # Every hook is here. No guessing.
    contributions = await coordinator
        .collect_contributions("obs.events")
        
Auto-discovered by the loader via __on_session_ready__ attribute — no new entry point needed.

Design Decisions

  • coordinator only, no config — config belongs in mount()
  • async-only — sync implementations log a warning, are skipped
  • non-fatal, isolated — one failure cannot block others
  • fires before session:fork — children run independently
  • 100% backward compatible — existing modules unchanged
  • queue drains after dispatch — no double-dispatch risk
Under the Hood

Phase 6 in the Kernel

1
Orchestratoras before
2
Contextas before
3
Providersas before
4
Toolsas before
5
Hooksas before
6
on_session_readynew — fires here
# _session_init.py — Phase 6
on_session_ready_queue = loader.get_on_session_ready_queue()   # sync
for module_id, on_session_ready_fn in on_session_ready_queue:
    try:
        await on_session_ready_fn(coordinator)
    except Exception as e:
        logger.warning(f"on_session_ready for '{module_id}' raised")
        await coordinator.hooks.emit(
            MODULE_ON_SESSION_READY_FAILED,   # observable — new event #42
            {"module_id": module_id, "error": str(e)}
        )
loader.clear_on_session_ready_queue()   # drain — prevents double-dispatch
    

Enqueue happens after each successful mount() — failed mounts never get a Phase 6 callback. Failures in Phase 6 are non-fatal and self-report via the event bus.

First Adopter · hooks-logging

mount() Shrinks to One Line

Before — everything in mount()
async def mount(coordinator, config=None):
    config = config or {}
    priority = int(config.get("priority", 100))
    session_logger = SessionLogger(...)
    
    # define handler closure...
    async def handler(event, data):
        session_logger.write(...)
    
    events = list(ALL_EVENTS)
    if auto_discover:
        # race condition: tools may not be mounted
        discovered = coordinator.get_capability(
            "observability.events") or []
        events.extend(discovered)
    
    for ev in events:
        coordinator.hooks.register(ev, handler)
        
After — deferred, safe, complete
_deferred_configs: list[dict] = []

async def mount(coordinator, config=None):
    _deferred_configs.append(config or {})

async def on_session_ready(coordinator):
    while _deferred_configs:
        config = _deferred_configs.pop(0)
        # ... build logger, closure ...

        # legacy path preserved
        if auto_discover:
            discovered = coordinator
                .get_capability("obs.events") or []
            events.extend(discovered)
        # new path — safe here, all tools mounted
        if auto_discover:
            contributions = await coordinator
                .collect_contributions("obs.events")
            for c in contributions:
                events.extend(c)
        for ev in events:
            coordinator.hooks.register(ev, handler)
        
Testing Strategy

Unit Tests Are Not Enough

All unit tests pass in 0.09s. But pytest can't answer the real question: "Does the Amplifier module loader actually call on_session_ready? Does the kernel event bus work end-to-end? Are events written to disk?"

What unit tests verify:
mount() defers config ✓
on_session_ready() registers handlers ✓
collect_contributions() is called ✓
Module loader invocation … ✗
Kernel event bus … ✗
JSONL written to disk … ✗
What DTU E2E verifies:
Full amplifier run session ✓
Local source code loaded (not PyPI) ✓
Kernel mounts all modules ✓
Phase 6 fires on_session_ready ✓
Events appear in JSONL on disk ✓
Real provider (mock), no API key needed ✓
Digital Twin Universe lets you run your local feature branch inside a fully provisioned, real-user-facing environment — without touching production infrastructure.
Digital Twin Universe · Phase 1

Isolated Test Suite

test-suite.yaml

ENVIRONMENT

Ubuntu 24.04 · Rust toolchain · uv · Gitea mirror

SOURCE

Local feat/on-session-ready branch via Gitea

BUILD

uv sync --group dev → compiles amplifier-core from Rust source

# run via amplifier-digital-twin exec
cd /root/work/amplifier-module-hooks-logging && \
    PATH=/root/.local/bin:/root/.cargo/bin:$PATH \
    uv run pytest tests/ -v

======================== 24 passed in 0.09s ========================
    
Insight discovered here: The test suite validates behavioral isolation. But pytest cannot exercise the Amplifier module loader, kernel event bus, or filesystem write path. A second DTU profile is needed.
Digital Twin Universe · Phase 2

True End-to-End: Real amplifier run

e2e-event-logging.yaml

WHEEL STRATEGY

pypi_overrides.wheel_from_git: builds amplifier-core wheel on host (where cargo lives), serves via in-container pypiserver. Container needs no Rust.

URL REWRITE

allow_uv_github_fast_path: false — critical flag. Without it, uv resolves SHAs via GitHub API, bypassing local Gitea mirror silently.

TEST BUNDLE

loop-basic + provider-mock + hooks-logging from Gitea. No API key required.

ASSERTION

grep JSONL on disk for session:start, prompt:submit, session:end. Binary PASS/FAIL.

for ev in "session:start" "prompt:submit" "session:end"; do
  grep -q '"event": "'"$ev"'"' "$JSONL" || MISSING="$MISSING $ev"
done
PASS: all required events present (session:start, prompt:submit, session:end)
    
Digital Twin Universe · Architecture

How the E2E Profile Wires Together

🖥 Host Machine
cargo build amplifier-core.whl compiled from local source
amplifier-digital-twin launch e2e-event-logging.yaml · spins up Incus container
pypiserver (in-container) serves the .whl to uv inside the container — no Rust needed inside
↓ provisions
📦 Incus Container · Ubuntu 24.04
1
uv tool install amplifierURLs rewritten → gitea mirror + pypiserver (fast-path disabled)
2
amplifier run --bundle e2e-bundleloop-basic · provider-mock · hooks-logging from gitea
3
Phase 6 fireson_session_ready() called → handler registered → events observed
4
events.jsonl written to disk~/.amplifier/projects/.../events.jsonl
✅ PASS · session:start · prompt:submit · session:end — all present in JSONL
Timeline

From Bug Discovery to Verified Ship

Apr 10
CI design plan documents the 7% graph coverage gap publicly. Delegation events confirmed absent.
Apr 22
Parallax Discovery investigation runs (4 sessions). PROPOSAL.md written (672 lines). Design committed.
Apr 23
tool-delegate fix merged (register_capabilityregister_contributor). Gap closed immediately. Bundle guide updated.
Apr 24
amplifier-core 1.4.0: feat(lifecycle) + 64 tests merged. Version 1.3.3 → 1.4.0. CONTRACTS.md +171 lines.
Apr 28
hooks-logging adopts on_session_ready(). TDD red → green. Code quality review passes.
Apr 29
DTU Phase 1 (test-suite.yaml): 24 tests pass in isolated container. DTU Phase 2 (e2e-event-logging.yaml): real amplifier run → JSONL verified. PASS.
How to Use It

Adopting on_session_ready() in Your Module

1
Add an async def on_session_ready(coordinator) function to your module. The loader auto-discovers it — no new entry point declaration needed.
2
Move cross-module setup out of mount() and into on_session_ready(). Anything that reads from other modules (capabilities, contributions, tool registrations) belongs here.
3
Defer config with a module-level list, not a scalar. Use _deferred_configs: list[dict] = []. Append in mount(), pop in on_session_ready(). No global keyword.
4
Pin amplifier-core≥1.4.0 in dev dependencies. Add amplifier-core>=1.4.0 to [dependency-groups] dev. Runtime dep stays empty — the CLI provides core.
5
Update existing tests to call on_session_ready() after mount(). Add an autouse fixture to clear module-level deferred state between tests.
Pattern Reference

The Canonical Template

# module/__init__.py
# ─────────────────────────────────────────────────────────────
# Module-level deferred state
# list avoids `global`; .append() / .pop(0) mutate without reassignment
_deferred_configs: list[dict] = []

async def mount(coordinator: ModuleCoordinator, config: dict | None = None) -> None:
    _deferred_configs.append(config or {})   # slim — just defer

async def on_session_ready(coordinator: ModuleCoordinator) -> None:
    """Runs after ALL modules are mounted. Coordinator is fully composed."""
    while _deferred_configs:
        config = _deferred_configs.pop(0)

        # ✅ Safe to call collect_contributions() here
        contributions = await coordinator.collect_contributions("observability.events")

        # ✅ Safe to get_capability() for any module
        tool = coordinator.get_capability("my-required-tool")
        if tool is None:
            return   # fail gracefully — on_session_ready is non-fatal

        # ✅ Store session-scoped state via capabilities (not process globals)
        coordinator.register_capability("my-module.instance", MyInstance(config))
    
Test pattern: always call await on_session_ready(coordinator) after await mount(...) in tests. Add an autouse fixture that clears _module._deferred_configs between test cases.
Gotchas & Contracts

What the Docs Say — Read These

Know Before You Ship

No timeout enforced. A hanging on_session_ready() hangs the entire session. Implement your own deadline or rely on the caller's budget.
Fires once per session. Child sessions run an independent mount wave and their own Phase 6 pass. Parent callbacks do not re-run.
Do not use process-scoped globals. Use coordinator.register_capability() for session-scoped instance state instead.

Failure Is Observable

Exceptions in on_session_ready() emit module:on_session_ready_failed on the event bus — event #42 in ALL_EVENTS. Hooks-logging will write it to JSONL.
sync functions are skipped with a warning, not called via asyncio.run(). Always declare async def on_session_ready.
The full contract is in amplifier-core/docs/CONTRACTS.md under "Module Lifecycle: on_session_ready()".
Impact

What Changed Across the Ecosystem

42
canonical events (was 41)
1128
lines of new tests
64+
new test cases
ChangeRepoStatus
Phase 6 + loader + queue amplifier-core Merged 1.4.0
register_capabilityregister_contributor amplifier-foundation (tool-delegate) Merged
on_session_ready adoption + DTU E2E amplifier-module-hooks-logging Verified PASS
CONTRACTS.md lifecycle section amplifier-core +171 lines
on_session_ready in BUNDLE_GUIDE.md amplifier-foundation Documented
Summary

One lifecycle hook.
End of defensive mounting.
Verified end-to-end by DTU.

The Pattern

  • mount() — store config only
  • on_session_ready() — do cross-module work
  • collect_contributions() — safe only here
  • DTU e2e profile — prove the full stack

The Stack

amplifier-core 1.4.0 — Phase 6 lifecycle hooks-logging — first on_session_ready adopter Digital Twin Universe — unit + E2E two-phase testing CONTRACTS.md — lifecycle contract documented
Next: context-intelligence hook eliminates its dual-path init using this same pattern. Any module that registers events or reads capabilities in mount() should migrate.
More Amplifier Stories