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 (agent ⇄ confirm ⇄ tools) · 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-closedPolicyGuardrefuses any unapproved write as a backstop. See "Write confirmation" below andINTEGRATION.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_recordis single-record and a batch overMAX_AI_DELETES_PER_TURNis declined before the user is even asked. - System fields are visible.
get_object_fieldsreturnssystem_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, notfieldsrows, 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 withbulk_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 thebuildcredit 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.
| Group | Tools | Source |
|---|---|---|
| Discover (SAFE) | list_objects, get_object_fields, get_object_relations, get_calculation_info, smart_catalog_browse | new (ported from v1) |
| Record data (SAFE) | query_data, count_records, group_by_field, get_record_history | query_data co-located in tools/; counts ported from v1; history = per-record audit trail |
| Views/layout read (SAFE) | list_views, get_table_config | new (split from v1's action-multiplexed tools) |
| Create (WRITE) | create_object, create_field, create_relation, create_view | reused from workspace-builder |
| Record writes (WRITE) | create_record, update_record | ported from v1 (per-object create/update level) |
| Record delete (DANGER) | delete_record, bulk_delete_records | per-object delete level; both capped at MAX_AI_DELETES_PER_TURN (the "no mass delete" rule) |
| Record move | preview_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_config | update_object/update_relation close the create-but-can't-edit gap |
| Delete schema | delete_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_status | new (v1's toggle_entity_status split into three so each carries ONE policy permission) |
| Collaboration | create_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 |
| Tasks | list_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_records | many 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)
GuardedToolExecutor→PolicyGuardper call (fail-closed registrypolicy/tool-policy.registry.ts): reads are gated on the caller'sread.{slug}route (list_objectsis exempt and filters in-tool);update_field/toggle_field_statusneedobject.update;toggle_object_statusneedsobject.activate;toggle_relation_statusneedsrelation.update;update_view/update_table_configare gated onupdate.{slug}.- 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/chattransport — 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 +buildweight + budgetconfirmGate/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 (rosterOnceshares 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— theGuardedRundecorator that turns a name into an id before the policy guard sees it../../graph/focus-context.util.ts—focusObjectNamemaps the client's pinned slug to a name, so only a value from our own database reaches the prompttools/— the agent's own tools (one class per tool +__tests__/), incl.delete-record.tool.tsconstants/schema-permissions.ts—object.update,object.activate,relation.update../../graph/agent.graph.ts— theconfirmGateinterrupt node +WriteIntent(label/action)../../policy/confirmation.util.ts—tierNeedsConfirmation(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'scallId). - Field tools resolve names against ACTIVE fields only.
update_fieldanddelete_fieldlook 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 viatoggle_field_statuslikewise needs the slug). A deliberate constraint of the shared resolver, not a per-tool quirk.