Status:
draft · Version 0.2 · Filed 2026-04-25SPEC-038: Identity Resolution Guardrails — Bot Sentinel + Active-Session Exclusion + Reconnect & Operator-Gated Force
Version
0.2Status
draftOrigin
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_conflicteven though it was the same operator on the same machine. Pure UX friction — no security signal. - The backend already accepted
force=trueon 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 aforcepath 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_idalready lives oncontroller_registrations(v0.1).process_pidis added in this revision: the OS PID of the MCP process at registration time, sourced fromos.getpid().
§2.2 — Same-machine reconnect (silent)
Whenprism_start registers identity X and an active registration for X
on the same project carries the same machine_id:
- If
process_pidmatches: idempotent; refresh heartbeat, return existing registration. (Same MCP process re-entering — possible after a WS reconnect. No new session minted.) - If
process_piddiffers: silent release-and-replace. The prior registration is markedreleased_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 differentmachine_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:
§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 whenforce=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_idmust 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).
- Credentials valid + cross-machine conflict → preempt the prior
registration (
- 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=truecall regardless of payload (force_denied: not_configured).
§3.2 — Setup verb
A new MCP verbprism_set_force_credentials(operator_id, password, current_password="") posts to POST /api/v1/operator/force-credentials:
- First call (no credentials yet):
current_passwordmay be empty. - Rotation call (credentials exist):
current_passwordMUST match the stored hash; otherwise HTTP 403. - The verb is bootstrap-skipped (callable before
prism_startreturns, 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 contractbootstrap_personal.py
holds for the dev API key.
§4 — Implementation Surface
| Layer | Change |
|---|---|
| Alembic | 022_spec038_collision_policy.py — adds controller_registrations.process_pid, tenants.force_operator_id, tenants.force_password_hash |
| Model | ControllerRegistration.process_pid: int | None; Tenant.force_operator_id, Tenant.force_password_hash |
| Schema | ControllerRegisterRequest adds process_pid, operator_id, operator_password; IdentityConflictDetail adds same_machine: bool. New OperatorCredentialsRequest, OperatorCredentialsResponse |
| Service | controller_service.register branches §2.2/§2.3/§2.4. New operator_service.set_force_credentials + validate_force_credentials |
| Router | New /api/v1/operator/force-credentials endpoint. controller.register returns HTTP 403 on force_denied |
| MCP client | register_controller accepts process_pid, operator_id, operator_password. New set_force_credentials |
| MCP server | prism_start accepts force, operator_id, operator_password; passes os.getpid() automatically. New prism_set_force_credentials verb (bootstrap-skipped) |
§5 — Verification
- Same-machine, same-pid re-register → idempotent return.
- Same-machine, new-pid re-register → silent reconnect; old reg
release_reason='reconnect'. - Cross-machine no-force → HTTP 409 with
same_machine: false. - Cross-machine force=true, valid creds → HTTP 200, prior reg
release_reason='preempted_by_force'. - Cross-machine force=true, missing/invalid creds → HTTP 403
force_denied. prism_set_force_credentialsfirst call sets hash; second call withoutcurrent_password→ HTTP 403.- Bot identity force=true → no-op (no conflict to preempt).
- 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_credentialsrequire 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
tenantor on a separateoperatortable? Current design folds them ontotenantbecause Personal Install = one operator. Atenant_operatorstable 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=trueeven same-machine for audit-log emission. Deferred — silent path is the common case.

