diff --git a/research/lifecycle-prototype/demo.html b/research/lifecycle-prototype/demo.html
new file mode 100644
index 0000000..4d7284c
--- /dev/null
+++ b/research/lifecycle-prototype/demo.html
@@ -0,0 +1,1172 @@
+
+
+
Lifecycle ownership experiment
+
+Who owns observability cleanup?
+Throwaway prototype: can one explicit owner replace lifecycle coordination without losing shutdown telemetry? Compare framework-lifespan ownership with a context around the runner.
+Recorded walkthroughs replay actual Python runs. Free play is an illustrative state model, not a running framework. No production code has changed.
+Recorded walkthroughs
+Framework fastapi faststream
+Owner Framework lifespan Runner context
+Scenario
+
Next step Show complete run Restart walkthrough
+Complete-run observations
+
+Free play Try closing telemetry while application cleanup is still pending. Borrowed providers remain under their external owner's control.
+ Provider already belongs to another owner
+
+
+
+
diff --git a/research/lifecycle-prototype/floor-results.json b/research/lifecycle-prototype/floor-results.json
new file mode 100644
index 0000000..dfa0da1
--- /dev/null
+++ b/research/lifecycle-prototype/floor-results.json
@@ -0,0 +1,79 @@
+{
+ "faststream": "0.6.0",
+ "results": [
+ {
+ "framework": "faststream",
+ "variant": "lifespan",
+ "scenario": "happy",
+ "status": "pass",
+ "events": [
+ "own provider",
+ "user startup",
+ "work",
+ "shutdown hook",
+ "broker drain",
+ "user shutdown",
+ "exporter shutdown"
+ ],
+ "exported_spans": [
+ "user startup",
+ "work",
+ "shutdown hook",
+ "broker drain",
+ "user shutdown"
+ ],
+ "shutdown_calls": 1,
+ "error": null,
+ "error_message": null,
+ "notes": [],
+ "checks": {
+ "expected_error": true,
+ "primary_preserved": true,
+ "cleanup_note": true,
+ "shutdown_ownership": true,
+ "shutdown_spans": true,
+ "arguments": true,
+ "lifespan_result": true,
+ "external_survives": true
+ }
+ },
+ {
+ "framework": "faststream",
+ "variant": "explicit",
+ "scenario": "combined-error",
+ "status": "pass",
+ "events": [
+ "own provider",
+ "user startup",
+ "work",
+ "shutdown hook",
+ "broker drain",
+ "user shutdown",
+ "exporter shutdown"
+ ],
+ "exported_spans": [
+ "user startup",
+ "work",
+ "shutdown hook",
+ "broker drain",
+ "user shutdown"
+ ],
+ "shutdown_calls": 1,
+ "error": "ValueError",
+ "error_message": "primary failed",
+ "notes": [
+ "Observability cleanup: cleanup failed"
+ ],
+ "checks": {
+ "expected_error": true,
+ "primary_preserved": true,
+ "cleanup_note": true,
+ "shutdown_ownership": true,
+ "shutdown_spans": true,
+ "arguments": true,
+ "lifespan_result": true,
+ "external_survives": true
+ }
+ }
+ ]
+}
diff --git a/research/lifecycle-prototype/naive-wrapper-result.json b/research/lifecycle-prototype/naive-wrapper-result.json
new file mode 100644
index 0000000..a64899c
--- /dev/null
+++ b/research/lifecycle-prototype/naive-wrapper-result.json
@@ -0,0 +1,25 @@
+{
+ "framework": "faststream",
+ "variant": "lifespan",
+ "scenario": "happy",
+ "status": "fail",
+ "events": [
+ "own provider",
+ "exporter shutdown"
+ ],
+ "exported_spans": [],
+ "shutdown_calls": 1,
+ "error": "TypeError",
+ "error_message": "make_application..faststream_lifespan() got an unexpected keyword argument 'context__'",
+ "notes": [],
+ "checks": {
+ "expected_error": false,
+ "primary_preserved": true,
+ "cleanup_note": true,
+ "shutdown_ownership": true,
+ "shutdown_spans": false,
+ "arguments": false,
+ "lifespan_result": true,
+ "external_survives": true
+ }
+}
diff --git a/research/lifecycle-prototype/report.md b/research/lifecycle-prototype/report.md
new file mode 100644
index 0000000..9d71374
--- /dev/null
+++ b/research/lifecycle-prototype/report.md
@@ -0,0 +1,140 @@
+# Lifecycle ownership spike
+
+Throwaway code, not a production implementation. Based on main `2af92e041eb0d22fffe1ef3e3961cfad031342c5`;
+compares alternatives to PR [#443](https://github.com/modern-python/lite-bootstrap/pull/443), head `840f6ce`.
+
+## Decision
+
+Use the framework lifespan as the normal owner of observability. Let code that drives
+`start()` / `stop()` manually own cleanup explicitly. Share the resource ownership and
+error-preservation policy internally. Keep the existing bootstrapper interface for 2.0.
+
+This is a deliberate change to manual-operation behavior. Update #434's contract before
+changing #443. The prototype does not authorize that compatibility change or implement it.
+
+An explicit context around every runner is feasible, but replacing the normal framework
+lifespan with it would increase deployment responsibilities for callers. A broad new
+public observability interface is not justified by this experiment.
+
+## Run and inspect
+
+From the repository root:
+
+```bash
+uv run --no-sync python research/lifecycle-prototype/run.py
+```
+
+Every case gets a fresh process because OTel's global tracer provider is set once.
+The command prints each outcome and saves full observations in [results.json](results.json).
+No production modules are changed, no telemetry goes to a network exporter, and broker
+network start/stop are replaced with local stand-ins.
+
+Individual working examples:
+
+```bash
+uv run --no-sync python research/lifecycle-prototype/run.py fastapi lifespan happy
+uv run --no-sync python research/lifecycle-prototype/run.py faststream lifespan happy
+uv run --no-sync python research/lifecycle-prototype/run.py faststream explicit manual
+uv run --no-sync python research/lifecycle-prototype/run.py fastapi explicit combined-error
+```
+
+[demo.html](demo.html) is a self-contained recorded walkthrough and free-play model.
+Its recorded data comes from the Python runs; its free-play state machine is illustrative.
+Browser preview was blocked by the browser's local-file URL policy, so the HTML has not
+received visual verification. The Python experiments ran successfully.
+
+## What was compared
+
+Both designs use the same proposed `observability()` owner in [run.py](run.py).
+It creates a provider only when none exists, shuts down only that provider, and preserves
+the original error if cleanup also fails. This isolates lifecycle placement from SDK policy.
+
+| | A: Framework lifespan | B: Explicit runner context |
+|---|---|---|
+| Actual placement | `managed_lifespan(user_lifespan, state)` passed to the framework constructor | `with observability(state)` around the runner |
+| Shutdown order | Framework shutdown, user shutdown, telemetry cleanup | Framework shutdown, user shutdown, telemetry cleanup |
+| Ordinary ASGI caller | Framework enters and exits the owner | Caller must enclose the server's complete run |
+| Manual FastStream start/stop | Requires explicit ownership | Works inside the context |
+| Installed prototype hooks / flags | Constructor lifespan helper; no instance replacement or active flag | No lifecycle hooks or active flag |
+| Existing bootstrapper compatibility | Can retain the existing interface with one lifespan replacement | Requires caller entry-point changes |
+
+The prototype's constructor helper is an illustrative seam, not a proposed public function
+with a diagnostic `state` parameter. Retaining lite-bootstrap's existing application-taking
+interface would still replace its lifespan callable. That implementation cost remains.
+
+## Evidence
+
+34 isolated runs: **33 passed, one unsupported by the chosen contract**. The unsupported
+case calls FastStream start/stop directly with design A, so neither telemetry initialization
+nor automatic cleanup is entered. This is a visible limitation, not a passing cleanup case.
+
+| Observation | A | B |
+|---|---|---|
+| FastAPI request span, user startup/shutdown spans, lifespan state | Preserved | Preserved |
+| FastStream work, shutdown-hook, broker-drain and user-shutdown spans | Preserved | Preserved |
+| FastStream configured lifespan argument | Preserved | Preserved |
+| Telemetry setup failure | Owned provider closed once | Owned provider closed once |
+| User startup/shutdown failure | Original exception preserved; provider closed once | Same |
+| Broker/shutdown-hook failure | User cleanup runs before telemetry closes | Same |
+| Exporter shutdown failure | Reported | Reported |
+| Lifecycle failure plus exporter shutdown failure | Original exception plus cleanup note | Same |
+| Existing external provider | Same provider, zero owner shutdowns, still exports afterward | Same |
+
+Installed versions: FastAPI 0.162.0, FastStream 0.7.8, Starlette 1.10.0,
+OTel SDK 1.45.2 and FastAPI instrumentation 0.66b2. Additionally ran the FastStream 0.6.0
+lifespan happy path and explicit combined-error path successfully, using the isolated
+package in `/tmp/lite-faststream-floor-434`. These are spot checks, not full floor validation.
+
+The naive constructor wrapper failed FastStream's injection with
+`unexpected keyword argument 'context__'`. Preserving the original callable with
+`functools.wraps` fixed it. [naive-wrapper-result.json](naive-wrapper-result.json) preserves
+that observation. Reproduce it by removing only the wrapper's `functools.wraps` decorator.
+
+## Caller setup and deployment cost
+
+Design A adds lifespan composition at construction in the prototype. Framework-controlled
+startup then determines the correct lifetime. Constructor composition must preserve the
+user callable's signature; wrapping FastStream's already-injected `lifespan_context`, as
+#443 does, avoids that particular injection problem.
+
+Design B adds a context around the complete runner. It fits embedded servers, scripts,
+and manual broker operation. An application exported for a server to import cannot hold
+a synchronous module-level `with` block open through serving. Such deployments still need
+a per-worker initialization/cleanup hook, naturally the ASGI lifespan.
+
+The installed Uvicorn runner also requires an import string for reload or multiple workers
+(`uvicorn/main.py`, reload/worker validation and supervisor branches). A context around the
+parent launcher is insufficient evidence of worker ownership. Worker/reload behavior was
+read in source, not executed in this spike; no support claim is made for it.
+
+## Exact simplification available in #443
+
+If 2.0 adopts explicit cleanup for manual operation:
+
+1. In `FastStreamBootstrapper._wrap_lifecycle`, delete the captured `application_stop`,
+ `lifespan_active`, its `nonlocal` declaration and reset, the nested `stop()` wrapper,
+ and `application.stop = stop` including its type-checker suppression.
+2. Keep one lifespan wrapper. Keep exception-safe cleanup and the one-bootstrapper guard.
+ A smaller interface does not remove error-preservation or SDK ownership requirements.
+3. Replace the six new automatic-manual-cleanup regression cases with coverage and examples
+ for the agreed explicit manual-cleanup contract. Preserve shutdown-span and failure-path
+ regressions for the ASGI lifespan.
+4. Keep FastAPI's existing lifespan attachment. Share cleanup-error policy privately where
+ the two real implementations need it; do not introduce a universal framework adapter.
+5. Update the migration guide to state that direct start/stop and application context-manager
+ usage require an explicit telemetry owner or `bootstrapper.teardown()` in `finally`.
+
+If automatic cleanup for manual operation remains a requirement, keep #443's stop fallback.
+It implements a real compatibility promise; renaming or moving it would not remove that cost.
+
+## Limits and next implementation
+
+The spike validates tracing lifetime and one provider's ownership. It does not implement
+logging, Sentry, metrics, profiling, provider conflicts, partial custom-instrumentor rollback,
+external instrumentor ownership, worker startup, cancellation, or repeated lifetimes.
+Process-global SDK state remains set once and a closed provider cannot be made reusable by
+this interface. Issue #436's ownership/rollback work remains necessary.
+
+The recommended next implementation is a narrow revision of #434's manual-operation contract
+and #443's lifecycle glue. Defer configuration and public-interface redesign. No production
+changes, PR updates, merge, release, or issue-contract edits are part of this spike.
diff --git a/research/lifecycle-prototype/results.json b/research/lifecycle-prototype/results.json
new file mode 100644
index 0000000..7b252c6
--- /dev/null
+++ b/research/lifecycle-prototype/results.json
@@ -0,0 +1,1127 @@
+{
+ "versions": {
+ "fastapi": "0.162.0",
+ "faststream": "0.7.8",
+ "opentelemetry-sdk": "1.45.2",
+ "opentelemetry-instrumentation-fastapi": "0.66b2",
+ "starlette": "1.10.0"
+ },
+ "results": [
+ {
+ "framework": "fastapi",
+ "variant": "lifespan",
+ "scenario": "happy",
+ "status": "pass",
+ "events": [
+ "own provider",
+ "user startup",
+ "work",
+ "user shutdown",
+ "exporter shutdown"
+ ],
+ "exported_spans": [
+ "user startup",
+ "work",
+ "GET /work",
+ "user shutdown"
+ ],
+ "shutdown_calls": 1,
+ "error": null,
+ "error_message": null,
+ "notes": [],
+ "checks": {
+ "expected_error": true,
+ "primary_preserved": true,
+ "cleanup_note": true,
+ "shutdown_ownership": true,
+ "shutdown_spans": true,
+ "arguments": true,
+ "lifespan_result": true,
+ "external_survives": true
+ }
+ },
+ {
+ "framework": "fastapi",
+ "variant": "lifespan",
+ "scenario": "setup-error",
+ "status": "pass",
+ "events": [
+ "own provider",
+ "exporter shutdown"
+ ],
+ "exported_spans": [],
+ "shutdown_calls": 1,
+ "error": "ValueError",
+ "error_message": "primary failed",
+ "notes": [],
+ "checks": {
+ "expected_error": true,
+ "primary_preserved": true,
+ "cleanup_note": true,
+ "shutdown_ownership": true,
+ "shutdown_spans": true,
+ "arguments": true,
+ "lifespan_result": true,
+ "external_survives": true
+ }
+ },
+ {
+ "framework": "fastapi",
+ "variant": "lifespan",
+ "scenario": "startup-error",
+ "status": "pass",
+ "events": [
+ "own provider",
+ "user startup",
+ "exporter shutdown"
+ ],
+ "exported_spans": [
+ "user startup"
+ ],
+ "shutdown_calls": 1,
+ "error": "ValueError",
+ "error_message": "primary failed",
+ "notes": [],
+ "checks": {
+ "expected_error": true,
+ "primary_preserved": true,
+ "cleanup_note": true,
+ "shutdown_ownership": true,
+ "shutdown_spans": true,
+ "arguments": true,
+ "lifespan_result": true,
+ "external_survives": true
+ }
+ },
+ {
+ "framework": "fastapi",
+ "variant": "lifespan",
+ "scenario": "shutdown-error",
+ "status": "pass",
+ "events": [
+ "own provider",
+ "user startup",
+ "work",
+ "user shutdown",
+ "exporter shutdown"
+ ],
+ "exported_spans": [
+ "user startup",
+ "work",
+ "GET /work",
+ "user shutdown"
+ ],
+ "shutdown_calls": 1,
+ "error": "ValueError",
+ "error_message": "primary failed",
+ "notes": [],
+ "checks": {
+ "expected_error": true,
+ "primary_preserved": true,
+ "cleanup_note": true,
+ "shutdown_ownership": true,
+ "shutdown_spans": true,
+ "arguments": true,
+ "lifespan_result": true,
+ "external_survives": true
+ }
+ },
+ {
+ "framework": "fastapi",
+ "variant": "lifespan",
+ "scenario": "cleanup-error",
+ "status": "pass",
+ "events": [
+ "own provider",
+ "user startup",
+ "work",
+ "user shutdown",
+ "exporter shutdown"
+ ],
+ "exported_spans": [
+ "user startup",
+ "work",
+ "GET /work",
+ "user shutdown"
+ ],
+ "shutdown_calls": 1,
+ "error": "RuntimeError",
+ "error_message": "cleanup failed",
+ "notes": [],
+ "checks": {
+ "expected_error": true,
+ "primary_preserved": true,
+ "cleanup_note": true,
+ "shutdown_ownership": true,
+ "shutdown_spans": true,
+ "arguments": true,
+ "lifespan_result": true,
+ "external_survives": true
+ }
+ },
+ {
+ "framework": "fastapi",
+ "variant": "lifespan",
+ "scenario": "combined-error",
+ "status": "pass",
+ "events": [
+ "own provider",
+ "user startup",
+ "work",
+ "user shutdown",
+ "exporter shutdown"
+ ],
+ "exported_spans": [
+ "user startup",
+ "work",
+ "GET /work",
+ "user shutdown"
+ ],
+ "shutdown_calls": 1,
+ "error": "ValueError",
+ "error_message": "primary failed",
+ "notes": [
+ "Observability cleanup: cleanup failed"
+ ],
+ "checks": {
+ "expected_error": true,
+ "primary_preserved": true,
+ "cleanup_note": true,
+ "shutdown_ownership": true,
+ "shutdown_spans": true,
+ "arguments": true,
+ "lifespan_result": true,
+ "external_survives": true
+ }
+ },
+ {
+ "framework": "fastapi",
+ "variant": "lifespan",
+ "scenario": "external",
+ "status": "pass",
+ "events": [
+ "borrow provider",
+ "user startup",
+ "work",
+ "user shutdown",
+ "external after cleanup",
+ "exporter shutdown"
+ ],
+ "exported_spans": [
+ "user startup",
+ "work",
+ "GET /work",
+ "user shutdown",
+ "external after cleanup"
+ ],
+ "shutdown_calls": 0,
+ "error": null,
+ "error_message": null,
+ "notes": [],
+ "checks": {
+ "expected_error": true,
+ "primary_preserved": true,
+ "cleanup_note": true,
+ "shutdown_ownership": true,
+ "shutdown_spans": true,
+ "arguments": true,
+ "lifespan_result": true,
+ "external_survives": true
+ }
+ },
+ {
+ "framework": "fastapi",
+ "variant": "explicit",
+ "scenario": "happy",
+ "status": "pass",
+ "events": [
+ "own provider",
+ "user startup",
+ "work",
+ "user shutdown",
+ "exporter shutdown"
+ ],
+ "exported_spans": [
+ "user startup",
+ "work",
+ "GET /work",
+ "user shutdown"
+ ],
+ "shutdown_calls": 1,
+ "error": null,
+ "error_message": null,
+ "notes": [],
+ "checks": {
+ "expected_error": true,
+ "primary_preserved": true,
+ "cleanup_note": true,
+ "shutdown_ownership": true,
+ "shutdown_spans": true,
+ "arguments": true,
+ "lifespan_result": true,
+ "external_survives": true
+ }
+ },
+ {
+ "framework": "fastapi",
+ "variant": "explicit",
+ "scenario": "setup-error",
+ "status": "pass",
+ "events": [
+ "own provider",
+ "exporter shutdown"
+ ],
+ "exported_spans": [],
+ "shutdown_calls": 1,
+ "error": "ValueError",
+ "error_message": "primary failed",
+ "notes": [],
+ "checks": {
+ "expected_error": true,
+ "primary_preserved": true,
+ "cleanup_note": true,
+ "shutdown_ownership": true,
+ "shutdown_spans": true,
+ "arguments": true,
+ "lifespan_result": true,
+ "external_survives": true
+ }
+ },
+ {
+ "framework": "fastapi",
+ "variant": "explicit",
+ "scenario": "startup-error",
+ "status": "pass",
+ "events": [
+ "own provider",
+ "user startup",
+ "exporter shutdown"
+ ],
+ "exported_spans": [
+ "user startup"
+ ],
+ "shutdown_calls": 1,
+ "error": "ValueError",
+ "error_message": "primary failed",
+ "notes": [],
+ "checks": {
+ "expected_error": true,
+ "primary_preserved": true,
+ "cleanup_note": true,
+ "shutdown_ownership": true,
+ "shutdown_spans": true,
+ "arguments": true,
+ "lifespan_result": true,
+ "external_survives": true
+ }
+ },
+ {
+ "framework": "fastapi",
+ "variant": "explicit",
+ "scenario": "shutdown-error",
+ "status": "pass",
+ "events": [
+ "own provider",
+ "user startup",
+ "work",
+ "user shutdown",
+ "exporter shutdown"
+ ],
+ "exported_spans": [
+ "user startup",
+ "work",
+ "GET /work",
+ "user shutdown"
+ ],
+ "shutdown_calls": 1,
+ "error": "ValueError",
+ "error_message": "primary failed",
+ "notes": [],
+ "checks": {
+ "expected_error": true,
+ "primary_preserved": true,
+ "cleanup_note": true,
+ "shutdown_ownership": true,
+ "shutdown_spans": true,
+ "arguments": true,
+ "lifespan_result": true,
+ "external_survives": true
+ }
+ },
+ {
+ "framework": "fastapi",
+ "variant": "explicit",
+ "scenario": "cleanup-error",
+ "status": "pass",
+ "events": [
+ "own provider",
+ "user startup",
+ "work",
+ "user shutdown",
+ "exporter shutdown"
+ ],
+ "exported_spans": [
+ "user startup",
+ "work",
+ "GET /work",
+ "user shutdown"
+ ],
+ "shutdown_calls": 1,
+ "error": "RuntimeError",
+ "error_message": "cleanup failed",
+ "notes": [],
+ "checks": {
+ "expected_error": true,
+ "primary_preserved": true,
+ "cleanup_note": true,
+ "shutdown_ownership": true,
+ "shutdown_spans": true,
+ "arguments": true,
+ "lifespan_result": true,
+ "external_survives": true
+ }
+ },
+ {
+ "framework": "fastapi",
+ "variant": "explicit",
+ "scenario": "combined-error",
+ "status": "pass",
+ "events": [
+ "own provider",
+ "user startup",
+ "work",
+ "user shutdown",
+ "exporter shutdown"
+ ],
+ "exported_spans": [
+ "user startup",
+ "work",
+ "GET /work",
+ "user shutdown"
+ ],
+ "shutdown_calls": 1,
+ "error": "ValueError",
+ "error_message": "primary failed",
+ "notes": [
+ "Observability cleanup: cleanup failed"
+ ],
+ "checks": {
+ "expected_error": true,
+ "primary_preserved": true,
+ "cleanup_note": true,
+ "shutdown_ownership": true,
+ "shutdown_spans": true,
+ "arguments": true,
+ "lifespan_result": true,
+ "external_survives": true
+ }
+ },
+ {
+ "framework": "fastapi",
+ "variant": "explicit",
+ "scenario": "external",
+ "status": "pass",
+ "events": [
+ "borrow provider",
+ "user startup",
+ "work",
+ "user shutdown",
+ "external after cleanup",
+ "exporter shutdown"
+ ],
+ "exported_spans": [
+ "user startup",
+ "work",
+ "GET /work",
+ "user shutdown",
+ "external after cleanup"
+ ],
+ "shutdown_calls": 0,
+ "error": null,
+ "error_message": null,
+ "notes": [],
+ "checks": {
+ "expected_error": true,
+ "primary_preserved": true,
+ "cleanup_note": true,
+ "shutdown_ownership": true,
+ "shutdown_spans": true,
+ "arguments": true,
+ "lifespan_result": true,
+ "external_survives": true
+ }
+ },
+ {
+ "framework": "faststream",
+ "variant": "lifespan",
+ "scenario": "happy",
+ "status": "pass",
+ "events": [
+ "own provider",
+ "user startup",
+ "work",
+ "shutdown hook",
+ "broker drain",
+ "user shutdown",
+ "exporter shutdown"
+ ],
+ "exported_spans": [
+ "user startup",
+ "work",
+ "shutdown hook",
+ "broker drain",
+ "user shutdown"
+ ],
+ "shutdown_calls": 1,
+ "error": null,
+ "error_message": null,
+ "notes": [],
+ "checks": {
+ "expected_error": true,
+ "primary_preserved": true,
+ "cleanup_note": true,
+ "shutdown_ownership": true,
+ "shutdown_spans": true,
+ "arguments": true,
+ "lifespan_result": true,
+ "external_survives": true
+ }
+ },
+ {
+ "framework": "faststream",
+ "variant": "lifespan",
+ "scenario": "setup-error",
+ "status": "pass",
+ "events": [
+ "own provider",
+ "exporter shutdown"
+ ],
+ "exported_spans": [],
+ "shutdown_calls": 1,
+ "error": "ValueError",
+ "error_message": "primary failed",
+ "notes": [],
+ "checks": {
+ "expected_error": true,
+ "primary_preserved": true,
+ "cleanup_note": true,
+ "shutdown_ownership": true,
+ "shutdown_spans": true,
+ "arguments": true,
+ "lifespan_result": true,
+ "external_survives": true
+ }
+ },
+ {
+ "framework": "faststream",
+ "variant": "lifespan",
+ "scenario": "startup-error",
+ "status": "pass",
+ "events": [
+ "own provider",
+ "user startup",
+ "exporter shutdown"
+ ],
+ "exported_spans": [
+ "user startup"
+ ],
+ "shutdown_calls": 1,
+ "error": "ValueError",
+ "error_message": "primary failed",
+ "notes": [],
+ "checks": {
+ "expected_error": true,
+ "primary_preserved": true,
+ "cleanup_note": true,
+ "shutdown_ownership": true,
+ "shutdown_spans": true,
+ "arguments": true,
+ "lifespan_result": true,
+ "external_survives": true
+ }
+ },
+ {
+ "framework": "faststream",
+ "variant": "lifespan",
+ "scenario": "shutdown-error",
+ "status": "pass",
+ "events": [
+ "own provider",
+ "user startup",
+ "work",
+ "shutdown hook",
+ "broker drain",
+ "user shutdown",
+ "exporter shutdown"
+ ],
+ "exported_spans": [
+ "user startup",
+ "work",
+ "shutdown hook",
+ "broker drain",
+ "user shutdown"
+ ],
+ "shutdown_calls": 1,
+ "error": "ValueError",
+ "error_message": "primary failed",
+ "notes": [],
+ "checks": {
+ "expected_error": true,
+ "primary_preserved": true,
+ "cleanup_note": true,
+ "shutdown_ownership": true,
+ "shutdown_spans": true,
+ "arguments": true,
+ "lifespan_result": true,
+ "external_survives": true
+ }
+ },
+ {
+ "framework": "faststream",
+ "variant": "lifespan",
+ "scenario": "cleanup-error",
+ "status": "pass",
+ "events": [
+ "own provider",
+ "user startup",
+ "work",
+ "shutdown hook",
+ "broker drain",
+ "user shutdown",
+ "exporter shutdown"
+ ],
+ "exported_spans": [
+ "user startup",
+ "work",
+ "shutdown hook",
+ "broker drain",
+ "user shutdown"
+ ],
+ "shutdown_calls": 1,
+ "error": "RuntimeError",
+ "error_message": "cleanup failed",
+ "notes": [],
+ "checks": {
+ "expected_error": true,
+ "primary_preserved": true,
+ "cleanup_note": true,
+ "shutdown_ownership": true,
+ "shutdown_spans": true,
+ "arguments": true,
+ "lifespan_result": true,
+ "external_survives": true
+ }
+ },
+ {
+ "framework": "faststream",
+ "variant": "lifespan",
+ "scenario": "combined-error",
+ "status": "pass",
+ "events": [
+ "own provider",
+ "user startup",
+ "work",
+ "shutdown hook",
+ "broker drain",
+ "user shutdown",
+ "exporter shutdown"
+ ],
+ "exported_spans": [
+ "user startup",
+ "work",
+ "shutdown hook",
+ "broker drain",
+ "user shutdown"
+ ],
+ "shutdown_calls": 1,
+ "error": "ValueError",
+ "error_message": "primary failed",
+ "notes": [
+ "Observability cleanup: cleanup failed"
+ ],
+ "checks": {
+ "expected_error": true,
+ "primary_preserved": true,
+ "cleanup_note": true,
+ "shutdown_ownership": true,
+ "shutdown_spans": true,
+ "arguments": true,
+ "lifespan_result": true,
+ "external_survives": true
+ }
+ },
+ {
+ "framework": "faststream",
+ "variant": "lifespan",
+ "scenario": "external",
+ "status": "pass",
+ "events": [
+ "borrow provider",
+ "user startup",
+ "work",
+ "shutdown hook",
+ "broker drain",
+ "user shutdown",
+ "external after cleanup",
+ "exporter shutdown"
+ ],
+ "exported_spans": [
+ "user startup",
+ "work",
+ "shutdown hook",
+ "broker drain",
+ "user shutdown",
+ "external after cleanup"
+ ],
+ "shutdown_calls": 0,
+ "error": null,
+ "error_message": null,
+ "notes": [],
+ "checks": {
+ "expected_error": true,
+ "primary_preserved": true,
+ "cleanup_note": true,
+ "shutdown_ownership": true,
+ "shutdown_spans": true,
+ "arguments": true,
+ "lifespan_result": true,
+ "external_survives": true
+ }
+ },
+ {
+ "framework": "faststream",
+ "variant": "lifespan",
+ "scenario": "hook-error",
+ "status": "pass",
+ "events": [
+ "own provider",
+ "user startup",
+ "work",
+ "shutdown hook",
+ "user shutdown",
+ "exporter shutdown"
+ ],
+ "exported_spans": [
+ "user startup",
+ "work",
+ "shutdown hook",
+ "user shutdown"
+ ],
+ "shutdown_calls": 1,
+ "error": "ValueError",
+ "error_message": "primary failed",
+ "notes": [],
+ "checks": {
+ "expected_error": true,
+ "primary_preserved": true,
+ "cleanup_note": true,
+ "shutdown_ownership": true,
+ "shutdown_spans": true,
+ "arguments": true,
+ "lifespan_result": true,
+ "external_survives": true
+ }
+ },
+ {
+ "framework": "faststream",
+ "variant": "lifespan",
+ "scenario": "broker-error",
+ "status": "pass",
+ "events": [
+ "own provider",
+ "user startup",
+ "work",
+ "shutdown hook",
+ "broker drain",
+ "user shutdown",
+ "exporter shutdown"
+ ],
+ "exported_spans": [
+ "user startup",
+ "work",
+ "shutdown hook",
+ "broker drain",
+ "user shutdown"
+ ],
+ "shutdown_calls": 1,
+ "error": "ValueError",
+ "error_message": "primary failed",
+ "notes": [],
+ "checks": {
+ "expected_error": true,
+ "primary_preserved": true,
+ "cleanup_note": true,
+ "shutdown_ownership": true,
+ "shutdown_spans": true,
+ "arguments": true,
+ "lifespan_result": true,
+ "external_survives": true
+ }
+ },
+ {
+ "framework": "faststream",
+ "variant": "lifespan",
+ "scenario": "manual",
+ "status": "unsupported",
+ "events": [
+ "work",
+ "shutdown hook",
+ "broker drain"
+ ],
+ "exported_spans": [],
+ "shutdown_calls": 0,
+ "error": null,
+ "error_message": null,
+ "notes": [],
+ "checks": {
+ "expected_error": true,
+ "primary_preserved": true,
+ "cleanup_note": true,
+ "shutdown_ownership": true,
+ "shutdown_spans": true,
+ "arguments": true,
+ "lifespan_result": true,
+ "external_survives": true
+ }
+ },
+ {
+ "framework": "faststream",
+ "variant": "explicit",
+ "scenario": "happy",
+ "status": "pass",
+ "events": [
+ "own provider",
+ "user startup",
+ "work",
+ "shutdown hook",
+ "broker drain",
+ "user shutdown",
+ "exporter shutdown"
+ ],
+ "exported_spans": [
+ "user startup",
+ "work",
+ "shutdown hook",
+ "broker drain",
+ "user shutdown"
+ ],
+ "shutdown_calls": 1,
+ "error": null,
+ "error_message": null,
+ "notes": [],
+ "checks": {
+ "expected_error": true,
+ "primary_preserved": true,
+ "cleanup_note": true,
+ "shutdown_ownership": true,
+ "shutdown_spans": true,
+ "arguments": true,
+ "lifespan_result": true,
+ "external_survives": true
+ }
+ },
+ {
+ "framework": "faststream",
+ "variant": "explicit",
+ "scenario": "setup-error",
+ "status": "pass",
+ "events": [
+ "own provider",
+ "exporter shutdown"
+ ],
+ "exported_spans": [],
+ "shutdown_calls": 1,
+ "error": "ValueError",
+ "error_message": "primary failed",
+ "notes": [],
+ "checks": {
+ "expected_error": true,
+ "primary_preserved": true,
+ "cleanup_note": true,
+ "shutdown_ownership": true,
+ "shutdown_spans": true,
+ "arguments": true,
+ "lifespan_result": true,
+ "external_survives": true
+ }
+ },
+ {
+ "framework": "faststream",
+ "variant": "explicit",
+ "scenario": "startup-error",
+ "status": "pass",
+ "events": [
+ "own provider",
+ "user startup",
+ "exporter shutdown"
+ ],
+ "exported_spans": [
+ "user startup"
+ ],
+ "shutdown_calls": 1,
+ "error": "ValueError",
+ "error_message": "primary failed",
+ "notes": [],
+ "checks": {
+ "expected_error": true,
+ "primary_preserved": true,
+ "cleanup_note": true,
+ "shutdown_ownership": true,
+ "shutdown_spans": true,
+ "arguments": true,
+ "lifespan_result": true,
+ "external_survives": true
+ }
+ },
+ {
+ "framework": "faststream",
+ "variant": "explicit",
+ "scenario": "shutdown-error",
+ "status": "pass",
+ "events": [
+ "own provider",
+ "user startup",
+ "work",
+ "shutdown hook",
+ "broker drain",
+ "user shutdown",
+ "exporter shutdown"
+ ],
+ "exported_spans": [
+ "user startup",
+ "work",
+ "shutdown hook",
+ "broker drain",
+ "user shutdown"
+ ],
+ "shutdown_calls": 1,
+ "error": "ValueError",
+ "error_message": "primary failed",
+ "notes": [],
+ "checks": {
+ "expected_error": true,
+ "primary_preserved": true,
+ "cleanup_note": true,
+ "shutdown_ownership": true,
+ "shutdown_spans": true,
+ "arguments": true,
+ "lifespan_result": true,
+ "external_survives": true
+ }
+ },
+ {
+ "framework": "faststream",
+ "variant": "explicit",
+ "scenario": "cleanup-error",
+ "status": "pass",
+ "events": [
+ "own provider",
+ "user startup",
+ "work",
+ "shutdown hook",
+ "broker drain",
+ "user shutdown",
+ "exporter shutdown"
+ ],
+ "exported_spans": [
+ "user startup",
+ "work",
+ "shutdown hook",
+ "broker drain",
+ "user shutdown"
+ ],
+ "shutdown_calls": 1,
+ "error": "RuntimeError",
+ "error_message": "cleanup failed",
+ "notes": [],
+ "checks": {
+ "expected_error": true,
+ "primary_preserved": true,
+ "cleanup_note": true,
+ "shutdown_ownership": true,
+ "shutdown_spans": true,
+ "arguments": true,
+ "lifespan_result": true,
+ "external_survives": true
+ }
+ },
+ {
+ "framework": "faststream",
+ "variant": "explicit",
+ "scenario": "combined-error",
+ "status": "pass",
+ "events": [
+ "own provider",
+ "user startup",
+ "work",
+ "shutdown hook",
+ "broker drain",
+ "user shutdown",
+ "exporter shutdown"
+ ],
+ "exported_spans": [
+ "user startup",
+ "work",
+ "shutdown hook",
+ "broker drain",
+ "user shutdown"
+ ],
+ "shutdown_calls": 1,
+ "error": "ValueError",
+ "error_message": "primary failed",
+ "notes": [
+ "Observability cleanup: cleanup failed"
+ ],
+ "checks": {
+ "expected_error": true,
+ "primary_preserved": true,
+ "cleanup_note": true,
+ "shutdown_ownership": true,
+ "shutdown_spans": true,
+ "arguments": true,
+ "lifespan_result": true,
+ "external_survives": true
+ }
+ },
+ {
+ "framework": "faststream",
+ "variant": "explicit",
+ "scenario": "external",
+ "status": "pass",
+ "events": [
+ "borrow provider",
+ "user startup",
+ "work",
+ "shutdown hook",
+ "broker drain",
+ "user shutdown",
+ "external after cleanup",
+ "exporter shutdown"
+ ],
+ "exported_spans": [
+ "user startup",
+ "work",
+ "shutdown hook",
+ "broker drain",
+ "user shutdown",
+ "external after cleanup"
+ ],
+ "shutdown_calls": 0,
+ "error": null,
+ "error_message": null,
+ "notes": [],
+ "checks": {
+ "expected_error": true,
+ "primary_preserved": true,
+ "cleanup_note": true,
+ "shutdown_ownership": true,
+ "shutdown_spans": true,
+ "arguments": true,
+ "lifespan_result": true,
+ "external_survives": true
+ }
+ },
+ {
+ "framework": "faststream",
+ "variant": "explicit",
+ "scenario": "hook-error",
+ "status": "pass",
+ "events": [
+ "own provider",
+ "user startup",
+ "work",
+ "shutdown hook",
+ "user shutdown",
+ "exporter shutdown"
+ ],
+ "exported_spans": [
+ "user startup",
+ "work",
+ "shutdown hook",
+ "user shutdown"
+ ],
+ "shutdown_calls": 1,
+ "error": "ValueError",
+ "error_message": "primary failed",
+ "notes": [],
+ "checks": {
+ "expected_error": true,
+ "primary_preserved": true,
+ "cleanup_note": true,
+ "shutdown_ownership": true,
+ "shutdown_spans": true,
+ "arguments": true,
+ "lifespan_result": true,
+ "external_survives": true
+ }
+ },
+ {
+ "framework": "faststream",
+ "variant": "explicit",
+ "scenario": "broker-error",
+ "status": "pass",
+ "events": [
+ "own provider",
+ "user startup",
+ "work",
+ "shutdown hook",
+ "broker drain",
+ "user shutdown",
+ "exporter shutdown"
+ ],
+ "exported_spans": [
+ "user startup",
+ "work",
+ "shutdown hook",
+ "broker drain",
+ "user shutdown"
+ ],
+ "shutdown_calls": 1,
+ "error": "ValueError",
+ "error_message": "primary failed",
+ "notes": [],
+ "checks": {
+ "expected_error": true,
+ "primary_preserved": true,
+ "cleanup_note": true,
+ "shutdown_ownership": true,
+ "shutdown_spans": true,
+ "arguments": true,
+ "lifespan_result": true,
+ "external_survives": true
+ }
+ },
+ {
+ "framework": "faststream",
+ "variant": "explicit",
+ "scenario": "manual",
+ "status": "pass",
+ "events": [
+ "own provider",
+ "work",
+ "shutdown hook",
+ "broker drain",
+ "exporter shutdown"
+ ],
+ "exported_spans": [
+ "work",
+ "shutdown hook",
+ "broker drain"
+ ],
+ "shutdown_calls": 1,
+ "error": null,
+ "error_message": null,
+ "notes": [],
+ "checks": {
+ "expected_error": true,
+ "primary_preserved": true,
+ "cleanup_note": true,
+ "shutdown_ownership": true,
+ "shutdown_spans": true,
+ "arguments": true,
+ "lifespan_result": true,
+ "external_survives": true
+ }
+ }
+ ]
+}
diff --git a/research/lifecycle-prototype/run.py b/research/lifecycle-prototype/run.py
new file mode 100644
index 0000000..e330b74
--- /dev/null
+++ b/research/lifecycle-prototype/run.py
@@ -0,0 +1,276 @@
+"""THROWAWAY lifecycle contract experiment. Run with: uv run --no-sync python research/lifecycle-prototype/run.py."""
+
+import asyncio
+import contextlib
+import functools
+import importlib.metadata
+import json
+import subprocess
+import sys
+from pathlib import Path
+from unittest.mock import AsyncMock, patch
+
+from fastapi import FastAPI, Request
+from faststream.asgi import AsgiFastStream
+from faststream.redis import RedisBroker
+from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
+from opentelemetry.sdk.trace import TracerProvider
+from opentelemetry.sdk.trace.export import SimpleSpanProcessor
+from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
+from opentelemetry.trace import ProxyTracerProvider, get_tracer, get_tracer_provider, set_tracer_provider
+from starlette.testclient import TestClient
+
+
+class ProbeExporter(InMemorySpanExporter):
+ def __init__(self, state, fail=False):
+ super().__init__()
+ self.state = state
+ self.fail = fail
+ self.shutdown_calls = 0
+
+ def shutdown(self):
+ self.shutdown_calls += 1
+ self.state["events"].append("exporter shutdown")
+ super().shutdown()
+ if self.fail:
+ raise RuntimeError("cleanup failed")
+
+
+def span(state, name):
+ state["events"].append(name)
+ with get_tracer("prototype").start_as_current_span(name):
+ pass
+
+
+@contextlib.contextmanager
+def observability(state):
+ """Proposed process-level ownership, shared by both designs; no production imports."""
+ owned_provider = None
+ primary_error = None
+ try:
+ provider = get_tracer_provider()
+ if isinstance(provider, ProxyTracerProvider):
+ provider = TracerProvider(shutdown_on_exit=False)
+ owned_provider = provider
+ provider.add_span_processor(SimpleSpanProcessor(state["exporter"]))
+ set_tracer_provider(provider)
+ state["events"].append("own provider")
+ else:
+ state["events"].append("borrow provider")
+ if state["scenario"] == "setup-error":
+ raise state["primary"]
+ yield provider
+ except BaseException as error:
+ primary_error = error
+ raise
+ finally:
+ if owned_provider is not None:
+ try:
+ owned_provider.shutdown()
+ except Exception as error:
+ if primary_error is None:
+ raise
+ primary_error.add_note(f"Observability cleanup: {error}")
+
+
+def managed_lifespan(user_lifespan, state):
+ """Design A: constructor-supplied lifespan composition, with no application mutation."""
+
+ @contextlib.asynccontextmanager
+ @functools.wraps(user_lifespan)
+ async def lifespan(*args, **kwargs):
+ with observability(state):
+ async with user_lifespan(*args, **kwargs) as result:
+ yield result
+
+ return lifespan
+
+
+def make_application(framework, variant, state):
+ scenario = state["scenario"]
+
+ @contextlib.asynccontextmanager
+ async def fastapi_lifespan(app):
+ state["arguments_preserved"] = app is state["app"]
+ span(state, "user startup")
+ if scenario == "startup-error":
+ raise state["primary"]
+ try:
+ yield {"service": "ready"}
+ finally:
+ span(state, "user shutdown")
+ if scenario in ("shutdown-error", "combined-error"):
+ raise state["primary"]
+
+ @contextlib.asynccontextmanager
+ async def faststream_lifespan(service: str = "default"):
+ state["arguments_preserved"] = service == "configured"
+ span(state, "user startup")
+ if scenario == "startup-error":
+ raise state["primary"]
+ try:
+ yield
+ finally:
+ span(state, "user shutdown")
+ if scenario in ("shutdown-error", "combined-error"):
+ raise state["primary"]
+
+ user_lifespan = fastapi_lifespan if framework == "fastapi" else faststream_lifespan
+ lifespan = managed_lifespan(user_lifespan, state) if variant == "lifespan" else user_lifespan
+ if framework == "fastapi":
+ app = FastAPI(lifespan=lifespan)
+
+ @app.get("/work")
+ async def work(request: Request):
+ span(state, "work")
+ return {"service": request.state.service}
+
+ FastAPIInstrumentor.instrument_app(app, exclude_spans=["receive", "send"])
+ else:
+
+ async def shutdown_hook():
+ span(state, "shutdown hook")
+ if scenario == "hook-error":
+ raise state["primary"]
+
+ app = AsgiFastStream(RedisBroker(), lifespan=lifespan, on_shutdown=[shutdown_hook], logger=None)
+ state["app"] = app
+ return app
+
+
+async def drive_faststream(app, state):
+ async def stop_broker():
+ span(state, "broker drain")
+ if state["scenario"] == "broker-error":
+ raise state["primary"]
+
+ with (
+ patch.object(app.broker, "start", new=AsyncMock()),
+ patch.object(app.broker, "stop", new=AsyncMock(side_effect=stop_broker)),
+ ):
+ if state["scenario"] == "manual":
+ await app.start()
+ span(state, "work")
+ await app.stop()
+ else:
+ async with app.start_lifespan_context(run_extra_options={"service": "configured"}):
+ span(state, "work")
+
+
+def child(framework, variant, scenario):
+ state = {"scenario": scenario, "events": [], "primary": ValueError("primary failed")}
+ exporter = ProbeExporter(state, fail=scenario in ("cleanup-error", "combined-error"))
+ state["exporter"] = exporter
+ external = scenario == "external"
+ if external:
+ external_provider = TracerProvider(shutdown_on_exit=False)
+ external_provider.add_span_processor(SimpleSpanProcessor(exporter))
+ set_tracer_provider(external_provider)
+ app = make_application(framework, variant, state)
+ error = None
+ try:
+ with observability(state) if variant == "explicit" else contextlib.nullcontext():
+ if framework == "fastapi":
+ with TestClient(app) as client:
+ response = client.get("/work")
+ state["result_preserved"] = response.json() == {"service": "ready"}
+ else:
+ asyncio.run(drive_faststream(app, state))
+ except Exception as caught:
+ error = caught
+ if external:
+ span(state, "external after cleanup")
+ names = [item.name for item in exporter.get_finished_spans()]
+ expected_error = scenario.endswith("error")
+ owned_shutdowns = 0 if external or (scenario == "manual" and variant == "lifespan") else 1
+ expected_names = {
+ "setup-error": [],
+ "startup-error": ["user startup"],
+ "hook-error": ["user startup", "work", "shutdown hook", "user shutdown"],
+ "broker-error": ["user startup", "work", "shutdown hook", "broker drain", "user shutdown"],
+ "manual": ["work", "shutdown hook", "broker drain"] if variant == "explicit" else [],
+ }.get(
+ scenario,
+ ["user startup", "work", "user shutdown"]
+ if framework == "fastapi"
+ else ["user startup", "work", "shutdown hook", "broker drain", "user shutdown"],
+ )
+ primary_expected = scenario in (
+ "setup-error",
+ "startup-error",
+ "shutdown-error",
+ "hook-error",
+ "broker-error",
+ "combined-error",
+ )
+ checks = {
+ "expected_error": (error is not None) == expected_error,
+ "primary_preserved": not primary_expected or error is state["primary"],
+ "cleanup_note": scenario != "combined-error"
+ or any("cleanup failed" in note for note in getattr(error, "__notes__", [])),
+ "shutdown_ownership": exporter.shutdown_calls == owned_shutdowns,
+ "shutdown_spans": all(name in names for name in expected_names),
+ "arguments": scenario in ("setup-error", "manual") or state.get("arguments_preserved", False),
+ "lifespan_result": framework != "fastapi" or expected_error or state.get("result_preserved", False),
+ "external_survives": not external
+ or (get_tracer_provider() is external_provider and "external after cleanup" in names),
+ }
+ result = {
+ "framework": framework,
+ "variant": variant,
+ "scenario": scenario,
+ "status": "unsupported"
+ if scenario == "manual" and variant == "lifespan"
+ else "pass"
+ if all(checks.values())
+ else "fail",
+ "events": state["events"],
+ "exported_spans": names,
+ "shutdown_calls": exporter.shutdown_calls,
+ "error": type(error).__name__ if error else None,
+ "error_message": str(error) if error else None,
+ "notes": getattr(error, "__notes__", []),
+ "checks": checks,
+ }
+ if external:
+ external_provider.shutdown()
+ print(json.dumps(result))
+
+
+def main():
+ if len(sys.argv) == 4:
+ child(*sys.argv[1:])
+ return
+ cases = ["happy", "setup-error", "startup-error", "shutdown-error", "cleanup-error", "combined-error", "external"]
+ results = []
+ for framework in ("fastapi", "faststream"):
+ for variant in ("lifespan", "explicit"):
+ for scenario in cases + (["hook-error", "broker-error", "manual"] if framework == "faststream" else []):
+ process = subprocess.run(
+ [sys.executable, __file__, framework, variant, scenario], text=True, capture_output=True, check=True
+ )
+ result = json.loads(process.stdout)
+ results.append(result)
+ print(f"{framework:10} {variant:9} {scenario:15} {result['status']}")
+ output = {
+ "versions": {
+ name: importlib.metadata.version(name)
+ for name in (
+ "fastapi",
+ "faststream",
+ "opentelemetry-sdk",
+ "opentelemetry-instrumentation-fastapi",
+ "starlette",
+ )
+ },
+ "results": results,
+ }
+ path = Path(__file__).with_name("results.json")
+ path.write_text(json.dumps(output, indent=2) + "\n")
+ print(f"Recorded {len(results)} isolated runs in {path}")
+ if any(row["status"] == "fail" for row in results):
+ raise SystemExit(1)
+
+
+if __name__ == "__main__":
+ main()