Connect by JBRH Open Connect

Why an agent's tools should call domain services

A tool that writes to the database directly is quicker to build and removes every rule the domain service carries — autonomy, suppression, the daily allowance, the audit entry and the scope filters. Connect's 66 Assistant tools call the same services a person's click calls, and the voice worker holds no business logic at all: it reports what happened and the engine decides what it means.

Status
Available What this means
Audience
developer
In the app
#/data
Last verified
Product version
6.3.2

Why the shortcut is tempting#

An agent tool usually needs one small thing: mark this thread read, create this follow-up, move this deal. The domain service that already does it wants a workspace, a permission check, an actor and a reason, and returns something richer than the tool needs. Writing the row directly is four lines and works immediately.

It also works immediately in testing, in a demo, and for the first fortnight in production. What it does not do is refuse, hold, record or scope — and none of those absences produce an error.

What the bypass actually skips#

RuleWhere it livesWhat its absence looks like
Autonomy mode and scopeautonomy.py, four scopes, narrowest winsA reply that should have waited for approval goes out
Suppression, unsubscribe, do-not-contactOne compliance gate before any outreachA message to somebody who asked never to hear from you
Daily allowancemetering.py; work is held, and the read cursor deliberately does not advanceSilent overshoot, or messages lost when a cursor moves past held work
Provider evidenceoutbound.py — sent only on acknowledgement, otherwise uncertainA row that says sent with nothing behind it
Workspace scopeThe kernel and row-level securityA write that lands in the wrong workspace or with no stamp at all
Auditaudit.py — decision, rule, actor, outcomeAn action nobody can account for months later

Each of these is a rule somebody argued about once and then encoded. The bypass does not disagree with them; it simply never asks.

The shape that avoids it#

  • One send boundary. outbound.py is used by the engine and by a person, so an agent send and a human send obey identical rules and produce identical evidence.
  • One policy per capability, two thin routers. mailbox_console runs over one mailboxes.py policy for both audiences, and the operator's own handlers were rewired onto it rather than left as a second implementation. relationship_console answers one relationship for either audience the same way.
  • The grid is not a second store. Every change made in the Excel-like data view goes through the service that owns the record, which is what stops thirteen sheets becoming a parallel CRM with its own rules.
  • Tools are thin. The writing tools — send_email, approve_draft, create_followup, move_opportunity, data_update — are names for service calls, which is why the Assistant's rights can be narrower than a person's without a second rulebook.

The voice worker, as the extreme case#

The realtime voice worker runs in its own process, on its own host, holding a live call — the strongest possible temptation to let it decide things. It holds no business logic. voice_engine.brief() renders the persona, the line's purpose, the contact's facts, knowledge, the hard rules and the supervisor's guidance once per call; the worker posts every sentence to /api/voice/engine/spoken, events to /engine/event, and /engine/end runs the same voice.end_call that the carrier path runs — summary, lead, memory, follow-up, and model usage into costs.

Outbound dialling goes through comms_providers.dispatch_call with a placer, so memory blocks, consent, autonomy and budget gate a realtime call exactly as they gate a carrier call. The worker's own door is authenticated by a token and answers 503 when it is unset rather than falling open.

Where the shape has leaked#

Four mailbox routes answered for any workspace's row: the connection form, sync, and the OAuth start and disconnect took a mailbox id from a browser and looked it up with a bare db.get — which the kernel's query filter can miss entirely when the row is already in the identity map. They go through mailbox_console.row_for now. Only the SendGrid webhook still does not, because it resolves its mailbox before any workspace is known.

  • A bypass is easiest to introduce during an incident, when the service is the thing that seems to be in the way.
  • An ORM identity map can defeat a query-level scope filter, so *the kernel filters everything* is true of queries and not of every access path.
  • A tool surface grows faster than the rules do; 66 tools is a lot of doors, and the defence is that each is a name for a call rather than an implementation.
  • No measurement exists here of how often a tool call is refused by the service it goes through: UNKNOWN, and worth counting.

Questions#

Does routing every tool through a service not make the agent slow?

The service call is not the expensive part of an agent turn — the model is. What the service adds is a permission check and an audit write against a decision that already took seconds to reach, and it removes an entire class of failure where the agent does something a person could not have done from the same screen.

What about a tool that only reads?

Read paths still need the workspace scope, which is why they go through the same kernel and the same row-level security. The distinction that matters is not read against write; it is whether the code path can be reached with a scope nobody set.

How do you stop a new tool from taking the shortcut?

By having no plausible reason to. When the service is the only thing that knows how to create a follow-up correctly — with its reason, its duplicate check and its channel rules — writing the row directly is more work, not less. Where that is not yet true, the service is the thing to fix.