Skip to main content
Status: draft · Version 0.2 · Filed 2026-04-25

SPEC-038: Identity Resolution Guardrails — Bot Sentinel + Active-Session Exclusion + Reconnect & Operator-Gated Force

Version

0.2

Status

draft

Origin

v0.1 added the Bot sentinel and HTTP 409 active-session exclusion. v0.2 amends in response to operational gaps surfaced 2026-04-26:
  • A terminal restart within the 90s SessionStore TTL leaves the prior registration alive; the new Lafonda session was rejected with identity_conflict even though it was the same operator on the same machine. Pure UX friction — no security signal.
  • The backend already accepted force=true on the register payload (since v0.1 §2.3) but the verb signature didn’t expose it AND there was no authorization gate. Any agent with the API key could silently preempt any other session by claiming an identity. That’s not “operator-only”.
  • prism_start’s identity_conflict instruction text mentions a force path but the verb schema didn’t expose it, so agents got stuck.

§1 — Bot Sentinel Default (unchanged from v0.1)

[See v0.1 §1.]

§2 — Active-Session Identity Exclusion (amended)

The exclusion rule from v0.1 §2 stands, but the response now branches on the machine fingerprint of the conflicting registration:

§2.1 — Registration fingerprint

Every registration carries a fingerprint: (machine_id, process_pid).
  • machine_id already lives on controller_registrations (v0.1).
  • process_pid is added in this revision: the OS PID of the MCP process at registration time, sourced from os.getpid().
The fingerprint is not authentication (machine_id is self-claimed by the MCP and trivially spoofable). It is a UX signal — “is this likely the same operator reconnecting” — used only to choose between silent reconnect and structured conflict response.

§2.2 — Same-machine reconnect (silent)

When prism_start registers identity X and an active registration for X on the same project carries the same machine_id:
  • If process_pid matches: idempotent; refresh heartbeat, return existing registration. (Same MCP process re-entering — possible after a WS reconnect. No new session minted.)
  • If process_pid differs: silent release-and-replace. The prior registration is marked released_at = now, release_reason = 'reconnect'. The new registration is inserted. No 409, no force required, no log spam — this is the terminal-restart path and should be invisible.

§2.3 — Cross-machine conflict (structured)

When the conflicting registration carries a different machine_id, the backend returns HTTP 409 + identity_conflict body — same shape as v0.1 §2 — augmented with current_machine (the existing reg’s machine_id) so the caller can reason about what’s happening:
{
  "error": "identity_conflict",
  "identity": "Lafonda",
  "active_session": "<sid>",
  "registered_at": "...",
  "agent_surface": "claude_code",
  "machine_id": "mini1.home.lan",
  "same_machine": false,
  "suggestion": "Lafonda is active on mini1. Pick a different --as, wait for that session to wrap, or retry with force=true and operator credentials."
}
The caller decides: pick another identity, wait, or escalate to force.

§2.4 — Operator-gated force preempt

Force preempt is allowed only with operator credentials:
  • Request payload adds operator_id: str | None, operator_password: str | None (paired — both must be present when force=true).
  • Backend stores per-tenant credentials: tenants.force_operator_id + tenants.force_password_hash (bcrypt). One pair per tenant for the Personal Install; multi-operator tenancy is a future spec.
  • Validation: bcrypt-compare the presented password against the stored hash. The operator_id must equal the stored value (case-sensitive).
  • Outcomes when force=true:
    • Credentials valid + cross-machine conflict → preempt the prior registration (release_reason = 'preempted_by_force'); accept new one. Identical to v0.1 force semantics, just gated.
    • Credentials missing/invalid → HTTP 403 + {error: "force_denied", reason: "<missing|invalid>"}. New session is NOT registered.
    • No conflict to preempt → force is silently a no-op (registration proceeds normally).
  • Bot identity is NOT subject to force at all (Bot sessions don’t conflict).

§2.5 — Audit

Force-preempt events MUST emit a structured log entry naming the operator_id, target identity, victim session_id, victim machine_id, and new session_id. Operators are accountable for force events.

§3 — Operator Credentials Setup

§3.1 — Storage

  • Columns on tenants: force_operator_id varchar(128) NULL, force_password_hash varchar(128) NULL.
  • Both nullable; tenants without credentials reject every force=true call regardless of payload (force_denied: not_configured).

§3.2 — Setup verb

A new MCP verb prism_set_force_credentials(operator_id, password, current_password="") posts to POST /api/v1/operator/force-credentials:
  • First call (no credentials yet): current_password may be empty.
  • Rotation call (credentials exist): current_password MUST match the stored hash; otherwise HTTP 403.
  • The verb is bootstrap-skipped (callable before prism_start returns, so an operator can configure creds on a fresh install without first having to bootstrap a persona).
  • The verb NEVER echoes the password in its return; it returns {ok: true, operator_id, configured: true} or an error.

§3.3 — Plaintext handling

The plaintext password lives only in transit between the verb caller and the backend. The backend bcrypt-hashes immediately on receipt and stores only the hash. No log line, structured event, or response body MAY ever include the plaintext. This is the same contract bootstrap_personal.py holds for the dev API key.

§4 — Implementation Surface

LayerChange
Alembic022_spec038_collision_policy.py — adds controller_registrations.process_pid, tenants.force_operator_id, tenants.force_password_hash
ModelControllerRegistration.process_pid: int | None; Tenant.force_operator_id, Tenant.force_password_hash
SchemaControllerRegisterRequest adds process_pid, operator_id, operator_password; IdentityConflictDetail adds same_machine: bool. New OperatorCredentialsRequest, OperatorCredentialsResponse
Servicecontroller_service.register branches §2.2/§2.3/§2.4. New operator_service.set_force_credentials + validate_force_credentials
RouterNew /api/v1/operator/force-credentials endpoint. controller.register returns HTTP 403 on force_denied
MCP clientregister_controller accepts process_pid, operator_id, operator_password. New set_force_credentials
MCP serverprism_start accepts force, operator_id, operator_password; passes os.getpid() automatically. New prism_set_force_credentials verb (bootstrap-skipped)

§5 — Verification

  1. Same-machine, same-pid re-register → idempotent return.
  2. Same-machine, new-pid re-register → silent reconnect; old reg release_reason='reconnect'.
  3. Cross-machine no-force → HTTP 409 with same_machine: false.
  4. Cross-machine force=true, valid creds → HTTP 200, prior reg release_reason='preempted_by_force'.
  5. Cross-machine force=true, missing/invalid creds → HTTP 403 force_denied.
  6. prism_set_force_credentials first call sets hash; second call without current_password → HTTP 403.
  7. Bot identity force=true → no-op (no conflict to preempt).
  8. Migration up-down clean on a fresh DB.

§6 — Decisions

  • D1 — One operator per tenant: Personal Install pattern. Multi- operator force is a future spec; not in scope.
  • D2 — machine_id is UX, not auth: trivially spoofable; we don’t pretend otherwise. Real auth is API-key + bcrypt + tenant scoping. This spec narrowly addresses honest-mistake collisions and gives cross-machine preempt an explicit operator gate.
  • D3 — bcrypt on receipt: matches existing API-key handling; no new crypto primitive introduced.

§7 — Open Questions

  • Q1: Should prism_set_force_credentials require an existing bootstrap session to call? Currently bootstrap-skipped to allow pre-persona setup; alternative is to force a Bot-bootstrap first. Resolution: bootstrap-skipped (D — installer flow needs this before any persona exists).
  • Q2: Should the operator credentials live on tenant or on a separate operator table? Current design folds them onto tenant because Personal Install = one operator. A tenant_operators table is the right shape if we ever multi-operator; deferred.
  • Q3: Should force preempt also work same-machine (skip the silent reconnect)? Currently no — same-machine is always silent reconnect. Alternative: respect explicit force=true even same-machine for audit-log emission. Deferred — silent path is the common case.
Last modified on April 27, 2026