# Conversation Gate & `conversation_type`

*Verified against `conversation_gate.py`, `persistence.py`, and
`persistence/queue_helpers.py` 2026-08-17.*

The conversation gate (`conversation_gate.py`) is the ONE place that creates
`cx_conversation` and `cx_user_request` rows before AI execution. Downstream
persistence (`persistence.py`) only **updates** — it never creates them.

Structured execution errors are serialized deterministically before updating
the legacy `cx_user_request.error` TEXT column. Passing a dict directly is
forbidden because the ORM's resulting string-validation error masks the real
provider or schema failure that operators need to diagnose.

## Coordinator-owned persistence

**Every server-host conversation write goes through a Coordinator.** A
RequestLane uses its request Coordinator. Background and non-streaming calls to
`persist_completed_request()` open `standalone_coordinator(...)` around the
whole persistence unit, queue every write, and synchronously confirm durability
on scope exit. A plain `Session()` fallback is forbidden: it has transaction
mechanics but no Coordinator ownership authority.

`apply_authoritative_user_request_rollup()`, `_refresh_cache_state()`, and
`_hide_superseded_failed_turns()` obey the same rule. The AST regression in
`tests/test_persistence_coordinator_ownership.py` blocks any bare `Session()`
call from returning to `db/persistence.py`; its runtime test proves a
no-RequestLane save enters the standalone Coordinator before persistence starts.

`queue_conversation_create` stamps `visibility="personal"` when the caller does
not provide a stronger explicit value. The queued operation must be
self-contained: it may not depend on a generated ORM default that can lag a
live enum migration and make later replay reproduce the stale value.

## Persistence registry identity

The matrx-ai persistence registry is keyed only by exact `schema.table` names.
**Registration** (`register_table`) rejects a bare name outright, and every
built-in queue helper uses the canonical `chat.*` relation. This is a loud
boundary: a schema move must update the registration instead of silently missing
a rule or binding to a same-named relation elsewhere.

The release ownership audit treats each registry entry's `name` and
`write_owner` as the **only policy identity**. It never reconstructs policy keys
from ORM metadata or accepts `ModelMeta.table_name` as a second bare alias.
`tests/test_check_coordinator_write_firewall.py` blocks canonical-key drift,
missing protection, extra bare policies, wrong owners, and wrong managed modes;
its live-wiring test proves the bootstrapped registry and installed ORM policies
match.

**Lookup** (`get_model`) obeys the reconcile rule in root `CLAUDE.md` — *a guard
that CAN reconcile MUST reconcile.* An exact key is the contract; a key that is
bare or schema-wrong but matches **exactly one** registered relation
(`"tool_call"` → `chat.tool_call`) is RECONCILED behind a red once-per-key
banner plus an `ERROR` log on every occurrence. Ambiguous keys raise and name
every candidate; unknown relations raise as before. Reconciling is containment,
never permission: dropping the op instead fails the request's commit barrier
mid-turn and kills a live, paid chat over a spelling — which is exactly what
happened on 2026-08-15 (three bare call sites survived the 2026-08-13
schema-qualified registry change; ~8 hours of dropped `chat.tool_call` /
`chat.conversation` writes and dead chats).

Direct `Coordinator.queue(...)` consumers obey the same contract, and the call
site is still a defect the release BLOCKS on:
`python scripts/check_persistence_table_keys.py` scans every literal
`.queue(...)` / persistence `get_model(...)` key **repo-wide** (aidream,
packages, scripts) and fails when one is not an exact registered key — bare,
misspelled schema, or stale legacy name. The narrower in-package AST test
(`tests/persistence/test_registry_policy.py`) is the second, independent layer.

Every Coordinator drop path is durable. A drop that cannot be rebuilt into a
replayable op records `table`, `reason`, **`op_type` and `primary_key`** on the
`system_error` row, so an operator can identify and repair the exact row; a
LATE write (post-flush one-shot) for an unresolvable table is captured through
the same sink rather than printed to the console and lost.
`PersistenceBarrierError` also carries structured
`error_info` (`persistence_commit_failed`, barrier, lost-op count, request and
conversation ids) so the stream inspector identifies an internal saving failure
instead of advising the user to change an unrelated prompt or model setting.

Durable replay is schema-aware and idempotent. Before retrying preserved chat
inserts, it upgrades legacy `user_id` ownership to `created_by` and reconstructs
the current `organization_id` from the sibling request's authoritative
conversation. An insert whose primary-key row already exists, or a delete whose
row is already absent, is marked recovered without issuing a contradictory
second write. This covers the process-death window where the target write lands
but the `system_write_failure.recovered_at` acknowledgement does not.

**A Coordinator hard-deadline capture is auto-replayable.** Its timed-out
transaction has already been cancelled and every operation is preserved in
`system_write_failure`; leaving those rows parked would turn the durability
chain into storage without recovery. Semantic constraint and schema failures
remain human-gated.

## `conversation_type` — how a conversation began

**`cx_conversation.conversation_type` (TEXT, NOT NULL, default `standard`) is the
durable, queryable source of truth for how a conversation was initiated.** The
UI routes conversations by it (a user's chat list shows `standard`; everything
else is an internal/automation surface).

It **replaces three scattered, partial encodings** of the same idea:
- the `INTERNAL_SOURCE_FEATURES` frozenset (now *derived* from the canonical
  mapping, `conversation_title_backfill.py`),
- the `"Auto: "` title prefix,
- the runtime-only `AppContext.is_internal_agent` flag (persisted nowhere).

`source_app` / `source_feature` are unchanged — they describe the *caller*, live
on the user request, and do not change.

### The vocabulary lives in ONE module

`matrx_ai.agents.conversation_type` — a dependency-free leaf (stdlib + `vcprint`):
- **`ConversationType`** StrEnum: `standard` · `subagent` · `scheduled` ·
  `workflow` · `research` · `podcast` · `auto` · `system`.
- **`SOURCE_FEATURE_TO_TYPE`** — the canonical `source_feature → type` map. The
  single source of truth for "which features are internal."
- **`coerce_conversation_type(value)`** — validation gate. An unregistered value
  is coerced to `standard` after a LOUD self-identifying banner (the column is
  NOT NULL, so the write still lands). Register new values here, not inline.
- **`derive_conversation_type(...)`** — the safe fallback (precedence below).
- **`is_internal_conversation(ctx)`** — `True` for any non-`standard` type OR
  `is_internal_agent` (duck-typed; honors both so neither regresses).

It is a leaf because **matrx-connect cannot import matrx-ai** — so
`AppContext.conversation_type` defaults to the literal `"standard"`, and the enum
is the matrx-ai-side authority.

### The "stamp everywhere" rule

**Every conversation-creation path sets `conversation_type`.** Two layers:

1. **Explicit at the context-construction site** — the strong signal:
   - `AppContext.fork_for_child_agent` → `subagent`; `fork_for_workflow_step` →
     `workflow` (`matrx-connect/.../app_context.py`).
   - scheduler `agent_runner_adapter.py` → `scheduled`; `workflow.py` /
     `workflow_worker.py` → `workflow`; `kg_clustering/name.py` &
     `content_processing/upload_hook.py` → `auto`.
   - **`stamp_source_context(...)`** (`agents/source_tracking.py`) classifies
     from the stamped `source_feature` whenever the type is still `standard` —
     so research / podcast / ner / rag / pdf-cleaner / agent-factory get the
     right type for free, plus any future stamp caller.

2. **Re-derived at the gate as a safety net** — `conversation_gate.py`'s
   `_resolve_conversation_type(ctx, parent_conversation_id)` runs
   `derive_conversation_type` in BOTH `create_new_conversation` and
   `ensure_conversation_exists`, so an internal run is **never silently written
   as `standard`** even if a site forgot to stamp.

**Derivation precedence** (`derive_conversation_type`): an explicit non-`standard`
type wins → else `is_internal_agent` + a `parent_conversation_id` ⇒ `subagent` →
else `source_feature` via the map (when non-`standard`) → else `is_internal_agent`
with no better signal ⇒ `auto` → else `standard`.

### Gate-bypass sites (no `derive` runs — stamp explicitly)

Three paths create `cx_conversation` directly, bypassing the gate:
- `conversation_fork.py` — copies **every** column, so it **inherits** the
  source conversation's type automatically. No change needed.
- `package_integration.py` NER sink → `conversation_type="auto"`.
- `tool_testing.py` test conversation → `conversation_type="system"`.

### Who reads it

- The conversation labeler skip (`orchestrator/executor.py`) and the
  Turn-Boundary Inbox `claim_pending` skip (`tools/dynamic_drain.py`) both gate
  on `is_internal_conversation(ctx)` — internal threads get neither a title nor
  an inbox poll.

### Adding a new conversation origin

1. Add a value to `ConversationType`.
2. Map the `source_feature`(s) → it in `SOURCE_FEATURE_TO_TYPE`.
3. Stamp it explicitly at the construction site (or rely on the stamp/gate
   derivation if `source_feature` is set). It's a TEXT column — no migration.

`workflow_decision_fallback` is registered as `workflow`: it is the inline
decider used when a workflow's human-decision wait expires, not a user-facing
standard conversation.

## Cost & identity (unchanged invariant, restated)

`cx_user_request` is ONE backend API call, keyed by `request_id`, owned by
`user_id`, carrying NO `conversation_id`. The request↔conversation link is M2M
and lives entirely on `cx_request` (which carries BOTH `user_request_id` and
`conversation_id`). One user click = one `cx_user_request` = the total cost of
everything it triggered (parent + every sub-agent).

## Guest identity registry

Fingerprint guests resolve through the host-injected
`guest_executions_manager`. Reads use the generated manager's canonical
`filter_all_guest_executions(...)` method. Do not invent a singular filter
alias.

The registry may contain a first-touch visitor row before that visitor spends
anything. `resolve_guest_uuid(...)` sets a missing `first_execution_at` only on
the first real authenticated product request and preserves it thereafter;
page-view capture never masquerades as AI activity.

**A synthetic guest UUID is forbidden.** `resolve_guest_uuid(...)` either
returns an identity backed by a real `auth.users` row or raises
`GuestIdentityUnavailableError`. Returning a random UUID only moves a guest
registry failure into the first personal-organization or ownership write.
Hosts stop that exception at their authentication boundary before any product
route runs.

## Change log

- **2026-08-19:** First-touch visitor rows now receive `first_execution_at`
  only when their first real guest request resolves identity.
- **2026-08-17:** Added Coordinator hard-deadline captures to the conservative
  auto-replay allowlist; preserved conversation writes now complete after a
  transient database lock freeze clears.
- **2026-08-17:** Removed the synthetic-UUID guest fallback. Registry/auth
  failures now refuse before product routing instead of crashing later during
  personal-organization resolution.
- **2026-08-17:** Removed the bare-Session background persistence bypass;
  conversation persistence and its related helpers now establish standalone
  Coordinator ownership when no RequestLane Coordinator exists.
