Story 2179: resolve_agent_runtime() — SSM registry resolver + consumer migration

Why (the catastrophe this prevents)

An AgentCore runtime ARN is a string that only infra knows and only a Lambda can use. Until now every consumer learned its ARN the same way: someone hand-wired it into a Terraform env map or a CI --environment block. With N consumer lambdas and M agents, that is an N×M grid of hand-maintained pairings, and every cell is a place a deploy can quietly drop a variable.

In one week it dropped three: the mira-deploy var-divergence that destroyed the chatbot IAM role (Bug-2146); the agents-deploy apply that stripped AGENT_RUNTIME_ARN_MIRA off the broker (Bug-2154); and matter chat going unroutable because the ws-handler Lambda never received the mira ARN while the clerk-worker did (Bug-2177). All three are the same disease — deploy-time coupling — and none of them fail loudly. A consumer with a missing ARN doesn't crash at boot; it dies at invoke time, or worse, hangs.

Per pramaan-docs ADR-2178, the cure is runtime service discovery. Each agent deploy publishes its own live ARN to one well-known SSM path. Consumers stop being told their ARNs and start asking for them, by agent name, at the moment they need one.

The shape of it

BEFORE — N x M deploy-time grid, every edge hand-wired:

  terraform/CI env maps
    ├─ ws-handler      ← AGENT_RUNTIME_ARN_MIRA         (Bug-2177: never arrived)
    ├─ clerk-worker    ← AGENT_RUNTIME_ARN_ATLAS_INTAKE_REVIEW
    │                  ← AGENT_RUNTIME_ARN_MIRA         (Bug-2186: optional, out-of-band)
    ├─ app-api         ← AGENT_RUNTIME_ARN_ATLAS
    ├─ framer-trigger  ← AGENT_RUNTIME_ARN_FRAMER       (never set by its workflow)
    └─ registrar       ← AGENT_RUNTIME_ARN_REGISTRAR

AFTER — one resolver, one registry, N + M edges:

  agent deploys  ──write──▶  SSM  /pramaan/{env}/agents/{name}/runtime-arn
                                    ▲
                                    │ resolve_agent_runtime(name)
                                    │   · 60s TTL cache
                                    │   · AgentRuntimeNotRegistered on miss
                                    │
  consumers ──────read───────────────┘
    clerk-worker → "atlas" + "mira"   app-api   → "atlas"
    framer       → "framer"           registrar → "registrar"
  

The clerk-worker resolves two runtimes. agent_runtime_arn is the Atlas intake/dock runtime; agent_runtime_arn_mira is the dedicated Mira runtime that matter-chat converse runs invoke (Bug-2186). _runtime_arn_for picks Mira for matter-chat agent keys and falls back to Atlas when Mira is unset — so Mira, and only Mira, resolves optionally.

What changed

SurfaceChange
src/shared/agent_runtime_registry.py (new) The one resolver. resolve_agent_runtime(name) reads /pramaan/{env}/agents/{name}/runtime-arn from SSM, memoises successes for 60 s, and raises AgentRuntimeNotRegistered when the parameter is absent or empty. Exports the four roster constants (AGENT_MIRA, AGENT_ATLAS, AGENT_FRAMER, AGENT_REGISTRAR) so a rename is one edit, not a string hunt.
src/shared/errors/exceptions.py New typed AgentRuntimeNotRegistered(PramaanError), code pramaan.agents.runtime_not_registered, http_status = 503. An unregistered runtime is now a clean typed WS/broker error, never a silent hang or an os.environ KeyError.
src/clerk_worker/handler.py os.environ["AGENT_RUNTIME_ARN_ATLAS_INTAKE_REVIEW"] → resolve_agent_runtime(AGENT_ATLAS), and os.environ.get("AGENT_RUNTIME_ARN_MIRA") → a new _optional_runtime(AGENT_MIRA) that returns None on AgentRuntimeNotRegistered (and logs), preserving Bug-2186's fall-back-to-Atlas tolerance rather than failing every launch. This is the exact mira ARN Bug-2177's ws-handler never received. _build_deps() is uncached, so the 60 s TTL is honoured per invocation.
src/app_api/router_agents.py _atlas_runtime_arn() now delegates to resolve_agent_runtime(AGENT_ATLAS). Still resolved lazily at request time; the only caller is the dev-only buffered SSE route.
src/workers/framer_trigger/handler.py settings.agent_runtime_arn_framer + RuntimeError guard → resolve_agent_runtime(AGENT_FRAMER). Latent fix: deploy-framer-trigger.yml never set AGENT_RUNTIME_ARN_FRAMER at all, so this consumer was one cold start away from the Bug-2177 failure mode.
src/workers/document_registration_consumer/handler.py settings.agent_runtime_arn_registrar + RuntimeError guard → resolve_agent_runtime(AGENT_REGISTRAR).
src/shared/settings.py Deletes agent_runtime_arn_atlas, agent_runtime_arn_registrar, and agent_runtime_arn_framer. A comment in their place tells the next agent not to re-add them.

Design decisions worth naming

Only successes are cached

A negative TTL would mean an agent that registers at t stays invisible to a warm consumer until t+60 s — exactly the window in which a fresh deploy is being smoke-tested. Misses raise and are forgotten, so the very next call sees a newly-published ARN.

AccessDenied must not masquerade as "not registered"

The resolver inspects the botocore error code and re-raises anything that is not ParameterNotFound. If a consumer role is missing its ssm:GetParameter grant, that surfaces as a ClientError, not as AgentRuntimeNotRegistered — otherwise infra would go hunting a publishing bug when the real fault is an IAM policy. There is a test pinning exactly this.

Exactly one agent resolves optionally

Swapping a typed exception in for os.environ.get would have turned Bug-2186's deliberate tolerance into a hard failure: an unregistered Mira would crash every clerk-worker launch, including the Atlas runs that never needed Mira. _optional_runtime keeps that one seam soft — and logs clerk.agent_runtime_unregistered so a silently-degraded matter chat is visible in CloudWatch rather than merely quiet. Every other consumer takes the hard typed failure.

No lock on the cache

Two coroutines racing the same cold key both issue a GetParameter and both write the same value. The read is idempotent, so the race costs one redundant SSM call and corrupts nothing. A lock would buy nothing and cost a contention path in the invoke hot loop.

No manual span (pramaan-docs ADR-124)

resolve_agent_runtime is not a registry-approved manual-span operation, and its only I/O is a botocore GetParameter call — already covered by auto-instrumentation as T1 plumbing. Per the guardrail's own rule ("do not add one span per function"), this new function is instrumentation-exempt by delegation to the auto-instrumented client. The AGENT_INVOKE_CALL manual spans on the invoke paths are untouched.

Tests

TestPins
test_name_routes_to_its_own_ssm_pathEach of the four roster names reads its own parameter — no cross-agent bleed.
test_distinct_names_do_not_share_a_cache_entryThe cache is keyed by name, not global.
test_cache_hit_inside_ttl_does_not_reread_ssmTwo resolves, one SSM round-trip.
test_cache_expiry_rereads_ssmPast 60 s the resolver re-reads, so a redeploy propagates within a minute.
test_missing_parameter_raises_typed_errorCode, http_status 503, and details carry the agent + parameter path.
test_failed_resolve_is_not_cachedA just-registered agent resolves on the next call.
test_empty_parameter_value_raises_typed_errorRegistered-but-blank is as unusable as absent.
test_non_parameter_not_found_client_error_propagatesAccessDenied stays an IAM error.
test_env_defaults_to_local_when_pramaan_env_unsetUnset PRAMAAN_ENV resolves under /pramaan/local/….
test_router_agents_arn.py (rewritten)The dev SSE broker resolves from the registry and fails fast when unregistered.
Merge sequencing — this PR is inert until infra sibling #2180 lands. The resolver reads the nested path /pramaan/{env}/agents/{name}/runtime-arn. Today only the old flat params exist (/pramaan/staging/agents/atlas-runtime-arn, …/registrar-runtime-arn). Until #2180 publishes the nested params and grants the consumer roles ssm:GetParameter, every resolve raises AgentRuntimeNotRegistered. Per #2180's own rule — "resolver first, strip second — never strand consumers" — this PR merges first and must not be deployed to staging ahead of the infra apply.
IAM grant needed (coordinate with #2180). Four consumer execution roles now make a runtime SSM read they never made before — the existing grants are on the CI deploy role, which is a different principal. Each needs:
{
  "Sid": "ReadAgentRuntimeRegistry",
  "Effect": "Allow",
  "Action": ["ssm:GetParameter"],
  "Resource": "arn:aws:ssm:ap-south-1:879602786117:parameter/pramaan/staging/agents/*/runtime-arn"
}
on the roles behind pramaan-ln-staging (clerk-worker/ws-handler), pramaan-app-api, pramaan-framer-trigger, and pramaan-document-registration-consumer.

What this PR deliberately does not do

Verify

uv run ruff check          # clean on all changed files (120 pre-existing repo errors, identical on main)
uv run pytest tests/unit   # 1735 passed (+12 new); the 21 failures are DB-less baseline, identical on main
uv run mypy src/shared/agent_runtime_registry.py src/shared/errors/exceptions.py   # Success
make openapi               # snapshots byte-identical (no route/DTO change)

Recall

Consumers ask the registry for an ARN by agent name at runtime, so a dropped env var can no longer silently unroute an agent.