> ## Documentation Index
> Fetch the complete documentation index at: https://prism.ntecdev.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# SPEC-038 v0.2: Identity Resolution Guardrails — Bot Sentinel + Active-Session Exclusion

> Identity Resolution Guardrails — Bot Sentinel + Active-Session Exclusion

<Info>**Status:** `draft` · **Version** `0.2` · Filed 2026-04-25</Info>

# 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

| 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

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.
