Corteksa

SchemaManager

The SchemaManager model — fields, relations, and API.

Transport: REST + WebSocket /ai-v2/chat (token streaming), both via the supervisor front door · Graph: ReAct loop with a human-in-the-loop write gate (agentconfirmtools) · Flag: AI_V2_ENABLED · Routing key: units (served by SchemaManagerAgentService; legacy keys crm_records/schema_manager alias to it)

PRD

Business problem

Users need to inspect and evolve the STRUCTURE of their workspace — objects (which users often call "units"), fields, relations, views, and table layout — without opening the settings screens or knowing the schema model. Before this agent, every schema request in chat was redirected to the Workspace Builder, which is shaped for whole-workspace builds, not "add one field to deals". The schema manager handles that day-to-day structural work inline, porting the v1 assistant's object/field capabilities onto the v2 policy-guarded rails.

User stories

  • As a CRM admin, I want to ask "what fields does the deals unit have?" and get the real schema (names, slugs, types), so I can understand my workspace without opening settings.
  • As a CRM admin, I want to say "add a budget field to deals" or "make phone required" and — after I approve the change — have it applied, within my permissions.
  • As a CRM admin, I want to create/update/delete records from chat, but only after I explicitly confirm each change, and I can never wipe out a lot of data at once.

Success criteria

  • Human-in-the-loop on every write. Create, update, AND delete (records AND schema) pause at the graph's confirm gate (interrupt) and run only after the user approves; the fail-closed PolicyGuard refuses any unapproved write as a backstop. See "Write confirmation" below and INTEGRATION.md §6.
  • Every write passes the fail-closed PolicyGuard (each tool has a registered tier + permission) and is idempotency-keyed, so a resume never double-applies.
  • No mass delete. delete_record is single-record and a batch over MAX_AI_DELETES_PER_TURN is declined before the user is even asked.
  • System fields are visible. get_object_fields returns system_fields (RECORD_SYSTEM_FIELDS) alongside the object's own fields — owner/assignee, created by, updated by, and the timestamps — each with its write path. They are record columns, not fields rows, so without this the agent cannot know they exist. There is no user field type and no relation to a user: the record owner (reassigned with bulk_assign_records) is the only person reference. A request for a second person field (e.g. "prepared by") must be declined with the honest alternative — a separate object to relate to — never a text field holding a name.
  • Schema turns complete within MAX_SCHEMA_TOOL_CALLS = 25 — a per-turn budget, measured from the user's latest message and spent fresh on every turn, never a running total for the conversation — and are billed once with the build credit weight.

How it works

Summary

SchemaManagerAgentService is the objects-module ("Units") agent and the single inline chat agent — it owns the whole objects module: object structure (objects/units, fields, relations, views, table layout) AND the record data inside those objects (read + create + update + delete). It composes the standard facade rails (model + pinned checkpointer + credit gate reserve → invoke → settle + conversation index) and DISCOVERS its tool roster from AiV2ToolRegistry (every tool tagged @AiV2ToolDef({ agents: ['units'] })) rather than hand-wiring it, plus the larger tool-call budget AND a human-in-the-loop write gate (below). The front door (AiGraphService) runs it inline for the units routing key — the default the LLM supervisor picks (or the client pins in Direct mode). It replaced the old read-only crm_records agent, whose one read tool (query_data) was a strict subset of this roster; the shared run types now live in agents/agent-run.contract.ts.

Record CRUD. create_record + update_record (WRITE) and delete_record (DANGER) are per-object tools — gated by the caller's create/update/delete level, idempotency-keyed, row-scoped in DataService, tagged source_type: 'ai'.

Write confirmation (human-in-the-loop). Every write is confirmed before it runs. createAgentGraph is built with a confirmGate, and the run's ToolRunContext sets writeGate: 'per-write', so a batch containing any create/update/delete pauses at an interrupt (WriteConfirmationRequest) BEFORE the tools node — nothing executes on the proposing turn. The facade detects the pause (pendingConfirmation via graph.getState) and returns a WriteConfirmation; resume/resumeStream continue the run with the user's decision (Command({ resume: { approved } })), threading the approved callIds so the guard lets exactly those run. Each WriteIntent carries a backend-derived label + action (ready to render — the client shows them verbatim, never mapping tool names). Because the pause lives in the pinned checkpointer, the checkpointer is required here (no in-memory degrade). Defence-in-depth: the shared tierNeedsConfirmation predicate drives BOTH the gate and the guard's fail-closed backstop, so the two can never disagree. A delete batch over MAX_AI_DELETES_PER_TURN is declined by the gate before the user is asked (no mass delete). Full event/REST contract in INTEGRATION.md §6.

Object focus (object_slug). The client may send object_slug on the turn (the object page the user is on) — its own slug, unchanged. AiGraphService sanitizes it (normalizeObjectSlug), then focusObjectName looks it up in the permission-filtered roster and focusPreamble prepends a one-line soft default naming that object: unqualified requests target it, but an explicitly named object still wins. Only the NAME reaches the prompt, and only for an object this caller can read — so an unknown, unauthorised, or injected value drops the hint instead of being echoed into the model's context. It rides the user message (not the once-seeded system prompt) so it tracks navigation; the stored title/preview use the raw message. See INTEGRATION.md → "Focusing the AI on the current object".

Tool roster (40) — discovered via AiV2ToolRegistry, not hand-wired. Every WRITE/DANGER tool is confirmed by the user before it runs.

GroupToolsSource
Discover (SAFE)list_objects, get_object_fields, get_object_relations, get_calculation_info, smart_catalog_browsenew (ported from v1)
Record data (SAFE)query_data, count_records, group_by_field, get_record_historyquery_data co-located in tools/; counts ported from v1; history = per-record audit trail
Views/layout read (SAFE)list_views, get_table_confignew (split from v1's action-multiplexed tools)
Create (WRITE)create_object, create_field, create_relation, create_viewreused from workspace-builder
Record writes (WRITE)create_record, update_recordported from v1 (per-object create/update level)
Record delete (DANGER)delete_record, bulk_delete_recordsper-object delete level; both capped at MAX_AI_DELETES_PER_TURN (the "no mass delete" rule)
Record movepreview_record_move (SAFE), move_record (DANGER)relocate a record across objects; gated on the source's move level (derived from edit)
Edit (WRITE)update_field, update_object, update_relation, update_view, update_table_configupdate_object/update_relation close the create-but-can't-edit gap
Delete schemadelete_object (DANGER), delete_field (DANGER), delete_view (WRITE)delete_object/delete_field drop data (DANGER); delete_view is config-only (WRITE)
Toggle (WRITE)toggle_object_status, toggle_field_status, toggle_relation_statusnew (v1's toggle_entity_status split into three so each carries ONE policy permission)
Collaborationcreate_comment (WRITE), list_comments (SAFE), reply_to_comment (WRITE), delete_comment (DANGER)notes/threads on a record; reply/delete address a comment by slug, read-gated in-tool
Taskslist_tasks (SAFE), toggle_task_status (WRITE)agenda read + done/reopen toggle; exempt (task endpoints are workspace-scoped, not route-gated)
Bulk (WRITE)bulk_assign_records, bulk_update_recordsmany records in one confirmed call, capped at MAX_AI_BULK_RECORDS; row-scoped per record

All tools are @Injectable classes with build(context) → LangChain tool() + zod schema, resolving their object-module services per request via AiToolContextResolver — the same service paths the REST controllers use, so validation, cache invalidation, and row-scoping are inherited unchanged. Each is tagged @AiV2ToolDef({ agents }) and implements AiV2Tool, so AiV2ToolRegistry discovers and rosters it — adding a tool never touches the facade or its constructor (Open/Closed). That registry is the ONLY place Nest's DiscoveryService/Reflector are used; the facade depends only on the registry.

Authorization (two layers, same as everywhere in v2)

  1. GuardedToolExecutorPolicyGuard per call (fail-closed registry policy/tool-policy.registry.ts): reads are gated on the caller's read.{slug} route (list_objects is exempt and filters in-tool); update_field / toggle_field_status need object.update; toggle_object_status needs object.activate; toggle_relation_status needs relation.update; update_view / update_table_config are gated on update.{slug}.
  2. In-tool checks + service-layer scoping (row-level access, RLS-pinned connection) as defense in depth.

Routing

agent-key.ts + agent-route.ts describe the lanes; the supervisor prompt is generated from the roster:

  • anything about existing objects/records — read, write, or structural edit → units (default, inline),
  • whole-workspace/template builds → workspace_builder (also inline, on the same /ai-v2/chat transport — see its doc).

Build-intent routing. A whole-workspace build ("build me a real-estate system") must reach workspace_builder, not be answered inline. The routing descriptions + a supervisor tie-breaker (supervisor-router.service.ts) steer build phrasing — "build/set up a whole system, CRM, workspace, or template" — to workspace_builder even when phrased casually. Both agents run inline on the same conversation thread, so a misroute self-corrects on the next turn: the supervisor re-routes and the other agent picks up the SAME history (shared messages channel). There is no separate builder channel and no handoff hop — the old handoff_to_builder tool + assistant:handoff event were retired when the builder folded inline.

Addressing an object: the model speaks names, never ids

The rule: the model works in domain language; ids are an implementation detail it never sees. It says "العميل"; the backend turns that into 483920174852.

This is not a convenience. A CRM object's slug is twelve random digits (generateNumericSlug), so nothing in the name points at the id — a model handed only the user's words cannot derive, guess, or recognise it. Teaching it the ids instead would also couple the prompt to values that change whenever an object is recreated.

Every object-reference parameter takes a name. They are called object, source_object, target_object, action_target_object, objects — never *_slug — and their descriptions ask for the name as the user says it. withResolvedObjectRefs decorates the guarded runner and resolves each of them before the call runs, so the tool bodies still receive a canonical slug and keep their existing contracts.

It sits outside GuardedToolExecutor deliberately: PolicyGuard reads the object out of the raw arguments to check {verb}.{slug}, and the idempotency key hashes them — a name reaching either would be denied for the wrong reason, or let the same write through twice under two spellings.

Ids are still accepted, just never advertised. The client pins a real slug (focusObjectSlug from the page the user is on), so the boundary takes both forms.

Matching is Arabic-aware (object-ref.matcher.ts): tashkeel and tatweel are stripped and alef/ya/ta-marbuta variants folded, so "العميـل" and "الْعميل" both find "العميل". Two objects sharing a name resolve to ambiguous and the tool does not run — the model is told what distinguishes them (their descriptions) and asked to put the choice to the user, because guessing would read or edit the wrong records. An unmatched name returns the names that do exist. No failure message ever contains an id: handing one back would teach the model to address objects by id again.

The prompt carries names, not a mapping. The first turn's system prompt lists the objects the caller may read — names only. That is a shortcut, not the source of truth: it stops the model inventing near misses ("العملاء" for an object actually called "العميل") and saves the opening list_objects round-trip. It rides the system prompt because that message is the one thing windowHistory never evicts. Past AI_V2_LIMITS.MAX_LISTED_OBJECTS (60) it is replaced by a pointer to list_objects.

Where ids still appear. WriteIntent.objectSlug on the confirm card stays a real slug — the client builds its "view record" link from it (INTEGRATION.md). The gate runs before resolution, so resolveWriteObjects settles both forms in the facade: the label gets the display name, the field gets the slug.

One read per turn. PreparedRun.objectRoster (built by rosterOnce) is shared by all four consumers — prompt list, focus preamble, tool boundary, confirm labels. If that read fails, every consumer degrades to prior behaviour rather than failing the turn.

Key files

  • schema-manager-agent.service.ts — facade (rails + roster + build weight + budget
    • confirmGate/writeGate: 'per-write' + resume/resumeStream + pendingConfirmation)
  • schema-manager.prompt.ts — system prompt ("unit" vocabulary, name-first, inspect-before-write, "every change is confirmed by the user") + objectRosterBlock
  • ../../services/object-directory.service.ts — the one place "which objects can this caller see" is decided (rosterOnce shares one read per turn); list_objects, the prompt list, the focus preamble and name resolution all read it
  • ../../services/object-ref.matcher.ts — pure name/id matching, Arabic-aware
  • ../../graph/with-resolved-object-refs.ts — the GuardedRun decorator that turns a name into an id before the policy guard sees it
  • ../../graph/focus-context.util.tsfocusObjectName maps the client's pinned slug to a name, so only a value from our own database reaches the prompt
  • tools/ — the agent's own tools (one class per tool + __tests__/), incl. delete-record.tool.ts
  • constants/schema-permissions.tsobject.update, object.activate, relation.update
  • ../../graph/agent.graph.ts — the confirmGate interrupt node + WriteIntent (label/action)
  • ../../policy/confirmation.util.tstierNeedsConfirmation (shared by the gate + guard) + toolRiskTier
  • ../../policy/tool-policy.registry.ts — the policy entries (incl. delete_record = DANGER)
  • ../../graph/routing/{agent-key,agent-route,ai-graph.service}.ts — routing + resumeConfirmation(Run)

Deliberate limitations / follow-ups

  • Confirmation is per-batch, not per-write. The card approves/declines a whole proposed batch (echoing every callId). Per-write toggles are a client concern (the contract already carries each write's callId).
  • Field tools resolve names against ACTIVE fields only. update_field and delete_field look up the field by name/label among the object's active fields, so a deactivated field can't be edited or deleted by name — you need its exact slug (and re-activation via toggle_field_status likewise needs the slug). A deliberate constraint of the shared resolver, not a per-tool quirk.

On this page