Skip to content

feat(sdk): rootsign.init() facade + ambient session context (ADR-012 W3) - #23

Merged
oabolade merged 1 commit into
mainfrom
feat/init-facade
Aug 7, 2026
Merged

feat(sdk): rootsign.init() facade + ambient session context (ADR-012 W3)#23
oabolade merged 1 commit into
mainfrom
feat/init-facade

Conversation

@oabolade

@oabolade oabolade commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Workstream 3 of Pre-Phase-2 Sprint A. Implements ADR-012: a three-call public
surface (init()session()wrap_tools()/@trace) with the explicit
API unchanged underneath. Explicit ctx=/client= always win; no existing
call site breaks.

Stacked after #20 (packaging split) and #22 (JSONL backend).

What's in it

  • rootsign/sdk/facade.pyinit() is synchronous and does no I/O, so
    it's safe at module scope and inside a running loop (notebooks, FastAPI
    startup). It validates args and stores a frozen _InitConfig singleton;
    identical re-init is a no-op, conflicting re-init raises. Zero-arg
    rootsign.init() resolves to script name / development / medium / jsonl.
  • Ambient context in a ContextVar, set on session() entry and reset in
    its finally. _resolve_ctx_client() (explicit → ContextVar →
    RootSignNotInitializedError, new in errors.py carrying the two-line fix)
    is used by wrap_tools, @trace, both framework tracers, MCPProxyTracer,
    and decision capture. Resolution is deferred to invocation — decoration
    and tool wrapping normally happen at import time, before any session exists.
  • rootsign.session()agent_id/client are now optional. First entry
    performs the lazy agent get-or-create and builds the backend's client,
    closing it on exit only when it built it.
  • get_or_register_agent (registration.py), backend-dispatched: jsonl →
    jsonl_registry; postgres → INSERT ... ON CONFLICT (name, environment) DO NOTHING + SELECT. Attribute drift logs WARNING and keeps the stored
    values — mutating a registered agent is an admin operation, not a side
    effect of init().
  • Migration 0005_agents_name_env_unique (the sprint's only schema
    change): drop uq_agents_name, add uq_agents_name_environment. Safe with
    no backfill — name is globally unique today, so every existing row already
    satisfies the strictly weaker composite key. The revision id is abbreviated
    because alembic_version.version_num is varchar(32).
  • ManagedLocalIngestClient (client.py) — the postgres facade path has
    no caller-supplied AsyncSession, and LocalIngestClient never commits
    (its callers do). This opens a short-lived AsyncSessionLocal per
    handle() and commits, the same per-call-session pattern as the HiTL poll
    loop. Every record is durable on return, which is what keeps
    rootsign approve and cross-process HiTL working under the facade.

Testing

479 passed, coverage 91% (facade.py 96%). New tests:

  • tests/unit/test_facade.py — init validation/idempotency/zero-arg defaults,
    no-I/O assertion, loop-safety, resolution order, ContextVar reset (including
    on exception), quickstart→VALID, get-or-create across sessions, drift
    warning, HiTL-on-jsonl raising at first invocation, two concurrent
    sessions isolated
    , 8 parallel calls keeping monotonic sequences.
  • tests/integration/test_facade_postgres.py — get-or-register idempotency,
    same name across environments, drift, the constraint swap asserted against
    pg_constraint, per-record commit visible from another connection, and
    init→session→trace→verify_session end-to-end on Postgres.
  • tests/integration/test_facade_implicit_tracers.py — LangGraph, CrewAI, and
    MCPProxyTracer each driven with no ctx=/client= (ADR-012 exit
    criterion 6).

Two existing tests changed for the intended contract change: agent uniqueness
is now per (name, environment) (test_relationships.py, split into
same-env-rejected + cross-env-allowed), and CrewAITracer no longer rejects a
missing ctx at wrap time (test_crewai_tracer.py).

Note for reviewers

Existing deployments need rootsign-admin init to pick up migration 0005.

W4 (README quickstart, examples/quickstart-jsonl, demo.gif, the
docs/framework-support.md frozen-surface note, version bump to 0.2.0) is
deliberately not in this PR.

🤖 Generated with Claude Code

Three-call public surface — init() → session() → wrap_tools()/@trace — with
the explicit API unchanged underneath. Explicit ctx=/client= always win; no
existing call site breaks.

- `rootsign/sdk/facade.py`: `init()` is sync and does no I/O (safe at module
  scope and inside a running loop), validates args, stores a frozen
  `_InitConfig` singleton; identical re-init is a no-op, conflicting re-init
  raises. Zero-arg init resolves script name / development / medium / jsonl.
- Ambient `(ctx, client)` in a `ContextVar` set on `session()` entry and reset
  in its finally. `_resolve_ctx_client()` (explicit → ContextVar →
  `RootSignNotInitializedError`, new in errors.py with the two-line fix) is now
  used by wrap_tools, @trace, both framework tracers, `MCPProxyTracer`, and
  decision capture. Resolution is deferred to *invocation* — decoration and
  tool wrapping usually happen at import time, before any session exists.
- `rootsign.session()`: `agent_id`/`client` optional. First entry does the
  lazy agent get-or-create and builds the backend's client (closing it on exit
  only when it built it).
- `get_or_register_agent` in registration.py, backend-dispatched: jsonl →
  `jsonl_registry`; postgres → `INSERT ... ON CONFLICT (name, environment) DO
  NOTHING` + `SELECT`. Attribute drift logs WARNING and keeps stored values —
  mutation is an admin op, not an init side effect.
- Migration 0005 (the sprint's only schema change): drop `uq_agents_name`, add
  `uq_agents_name_environment`. No backfill needed — `name` is globally unique
  today, so every row already satisfies the composite key. Revision id is
  abbreviated because `alembic_version.version_num` is varchar(32).
- `ManagedLocalIngestClient` (client.py): the postgres facade path has no
  caller-supplied AsyncSession, and `LocalIngestClient` never commits, so this
  opens a short-lived `AsyncSessionLocal` per handle() and commits — same
  per-call-session pattern as the HiTL poll loop. Every record is durable on
  return, which is what keeps `rootsign approve` / cross-process HiTL working
  under the facade.

Tests: 27 new (facade unit incl. concurrency isolation + monotonic parallel
sequences, postgres get-or-register + constraint assertion, all three tracers
driven with implicit context, HiTL-on-jsonl raising at first invocation).
Suite 479 green; coverage 91%. Two existing tests updated for the intended
contract changes: agent uniqueness is now per (name, environment), and
CrewAITracer no longer rejects a missing ctx at wrap time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread tests/unit/test_facade.py
with pytest.raises(RuntimeError):
async with rootsign.session():
raise RuntimeError("boom")
assert facade.current_session() is None

from alembic import op

revision: str = "0005_agents_name_env_unique"
from alembic import op

revision: str = "0005_agents_name_env_unique"
down_revision: Union[str, None] = "0004_approval_action_unique"

revision: str = "0005_agents_name_env_unique"
down_revision: Union[str, None] = "0004_approval_action_unique"
branch_labels: Union[str, Sequence[str], None] = None
revision: str = "0005_agents_name_env_unique"
down_revision: Union[str, None] = "0004_approval_action_unique"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
@oabolade
oabolade merged commit 5fbc39a into main Aug 7, 2026
21 checks passed
@oabolade
oabolade deleted the feat/init-facade branch August 7, 2026 19:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant