Condor HMS Tool Layer — Client, Id-Alias Proxy, Transport Port, Policy
Audiences: developer, internal
How a Condor tool actually reaches patient data. Four directories under
ddx-api/src/ai/condor/(~134 files) sit between a tool'sexecute()and the HMS clinical services:hms-client/(typed client),hms-access/(transport port + adapters + the id-alias proxy),tool-execution/(idempotency, retry, failure taxonomy), andtool-platform/(the tool-definition factory + the one policy engine). The layer's defining safety property is that the LLM physically never receives a raw id — see The id-alias proxy.
Purpose — the four responsibilities
| Directory | Owns | Entry point |
|---|---|---|
hms-client/ | the typed outbound HTTP boundary to HMS ddx-api + the error taxonomy | hms-api.client.ts |
hms-access/ | the transport port, its two adapters, the caller identity, and the per-session alias map | hms-access.port.ts, alias/condor-alias-map.service.ts |
tool-execution/ | retry context, durable idempotency, the tool-error envelope taxonomy | idempotency-record.ts |
tool-platform/ | the defineTool factory + the single approval-policy authority | tool-policy.ts |
The client — hms-api.client.ts
HmsApiClient is the sole outbound HTTP boundary between Condor and HMS
ddx-api; no other Condor module talks to HMS directly
(hms-client/hms-api.client.ts:1-10). After the god-object refactor it is a
delegating facade: shared HTTP plumbing (lazy env, request seam, error
mapping) lives in HmsHttpCore, and the ~98 resource methods live in 12
per-resource *ApiClient collaborators — but every method stays a real declared
own-instance delegator, so the ~73 importers and the existing spec see an
unchanged surface (hms-api.client.ts:6-13).
Four invariants it preserves (hms-api.client.ts:15-20):
- Raw
fetch()only — no axios, no undici, no NestJSHttpModule. - HMS gateway header set only; never
Authorization: Bearertoward HMS:6100. - Typed
HmsErrorBasethrows; success bodies are Zod-validated. - Lazy env — env is read on first request, never in a constructor. Module-eval instantiation happens before dotenv loads; a constructor read is a documented boot-crash regression.
Errors map by status class (hms-client/hms-errors.ts:3-8): 401 → HmsAuthError,
other 4xx → HmsValidationError, 5xx → HmsServerError. Each carries a
diagnostic envelope (statusText, method, path, clinicSlug?, userId?,
requestId? from a case-insensitive x-request-id) so a failure correlates back
to its call site (hms-errors.ts:14-17). HmsApiClient is the only place
these are constructed; tool execute() bodies catch and translate them
(hms-errors.ts:23-24).
The id-alias proxy — the defining safety property
CondorAliasMapService (hms-access/alias/condor-alias-map.service.ts) is the
single owner of the AiSession.aliasMap column and the layer's security
centerpiece. The Condor LLM never sees a raw {uuid, fhirId, slug} — it sees
an opaque alias <prefix>-NNN (e.g. patient-001)
(condor-alias-map.service.ts:4-6). Tool responses are alias-rewritten on the
way out; tool arguments are alias-decoded on the way in, at the adapter, before
any HMS call.
Why this replaces a prompt guard. Tucan tools depend on a "NEVER show UUIDs" instruction in the system prompt — a probabilistic guard that a model can ignore. Condor removes the failure mode structurally: the raw id is not in the model's context at all, so there is nothing for it to leak, echo back, or hallucinate a neighbour of. Id hygiene becomes a property of the transport, not of prompt compliance.
Four mechanisms make that guarantee hold:
- Fail-closed decode.
decode()of an alias not in this session's loaded map returnsnull, never a real id. There is no global or cross-session registry, so a foreign or never-minted alias can never resolve — this is the anti-hallucination and IDOR guard in one (condor-alias-map.service.ts:19-22,:277-286). - Buffered minting.
encode()mutates an in-memory map hydrated byload(sessionId);flush(sessionId)persists once at turn end. A busy turn mints ~180 aliases, so a per-encodewrite is never correct (condor-alias-map.service.ts:15-17,:232-234). - Reverse-dedup across id spaces. One entity can arrive through different HMS
projections (a create response exposing only a FHIR id, a later DTO exposing
the Prisma UUID plus the same FHIR id). Reuse by any asserted member so one
patient never becomes both
patient-001andpatient-002; conflicting asserted members mean a corrupt cross-entity projection and fail closed (condor-alias-map.service.ts:173-194). - Bounded growth. Per-kind monotonic counters plus reverse-dedup mean a
re-fetch never mints. Past
MAX_ALIASES_PER_SESSION(5000)encode()returns a sentinel that decodes tonull— fail-closed, never a real id (condor-alias-map.service.ts:24-27,:80-83).
Two supporting details worth knowing:
- Id-form routing.
buildRealIdRef(id)routes an id of unknown form to the correct slot viaisUuid/isFhirId, falling back toslug. The naive{ uuid: id }form poisoneduuidwith a FHIR id, which propagated intoAiSession.linkedEntityIdand silently broke the UUID-only linked-patient card (condor-alias-map.service.ts:56-72). - Near-miss repair, structurally bounded. Small models drift an issued alias
by inserting a redundant noun infix (
task-001→task-group-001). The repair strips a known infix and re-looks-up the same session map, so it can only resolve to an alias this session actually issued — it never invents an id (condor-alias-map.service.ts:256-275).
Prefix→kind resolution uses longest-prefix match with a digits-only tail guard,
so patient-appt-001 resolves to APPOINTMENT rather than PATIENT
(condor-alias-map.service.ts:307-323); the taxonomy itself lives in
alias/alias-taxonomy.ts:17 (AliasKind) and :98 (ALIAS_PREFIXES).
The transport port — one interface, two adapters
IHmsAccess (hms-access/hms-access.port.ts:215) is the single transport seam
for Condor's HMS data access. Every method mirrors its HmsApiClient counterpart
1:1 (67 total) with the same typed input and the same HmsCallContext; no new
shapes are invented at the port (hms-access.port.ts:9-12).
| Adapter | Selected by | Behavior |
|---|---|---|
InProcessHmsAccess | default — CONDOR_HMS_TRANSPORT=in-process | calls ddx-api clinical services directly with a synthesized auth/tenant context |
HttpHmsAccess | fallback — CONDOR_HMS_TRANSPORT=http | one-line passthrough per method to HmsApiClient (the HTTP-loopback path) |
The knob defaults to in-process (mastra/env.ts:324).
In-process migration policy. Methods move to true in-process one family at a
time (p1-*, p2-*, p3-*). A not-yet-migrated method transparently delegates
to the HTTP adapter and logs a one-time WARN, so the default transport is always
correct rather than a runtime landmine
(in-process-hms-access.adapter.ts:8-18). In-process calls bypass
ResponseTransformInterceptor — services return raw DTOs projected directly to
port types, with no {data,meta} re-wrap
(in-process-hms-access.adapter.ts:20-22). The HTTP adapter is never removed;
it is a deliberate flagged escape hatch (http-hms-access.adapter.ts:4-9).
The in-process path also carries the alias seam: a shared support service
supplies decodeId (alias → real id, inbound), requireRole/requirePermission,
auditMutation, buildServiceContext, and aliasResult (real id → alias,
outbound — it rewrites only id values, leaving the mapped shape intact)
(in-process-hms-access.adapter.ts:667-668, :1157-1166).
Caller identity is hms-access/identity-context.ts: IdentityContext (:109)
projects to a system context (:154) and to the client's HmsCallContext
(:185), where the tenant travels as clinicSlug: identity.orgSlug (:189) —
the clinic slug, not a UUID.
Request path
Idempotency and retry — tool-execution/
The request-context ledger records a mutating success only after the result
returns. If the mutation commits downstream but the response is lost (process
death, dropped connection) before that cache write, a retry re-executes the side
effect — a double-create (tool-execution/idempotency-record.ts:4-8).
The fix is a durable record keyed by the logical step ({runId, stepId} from
the retry context) that survives a lost response; a same-key retry replays
the recorded result instead of re-executing
(idempotency-record.ts:9-13). Two implementations sit behind the
IdempotencyStore port:
| Impl | Role |
|---|---|
InMemoryIdempotencyStore (:47) | request-scoped optimisation — avoids a Redis round-trip; explicitly not the guarantee (a process death between commit and record loses it) |
RedisIdempotencyStore (:77) | the durable guarantee — survives a lost response |
The durable store is consulted first when present; the in-memory ledger stays
layered on top (idempotency-record.ts:19-20). Scope boundary: a Redis KV record
is a durable result record, not a message queue or broker, and no Prisma schema
change is involved (idempotency-record.ts:21-23). tool-error-envelope.types.ts
carries the companion failure taxonomy tools translate HMS errors into.
Policy — tool-platform/
tool-policy.ts is the one approval authority for the tool layer: a pure,
synchronous, Mastra-free verb × domain × env matrix that replaced two prior
sources of truth (scattered per-tool requireApproval callbacks and a mutating
metadata flag) (tool-policy.ts:3-8). It is Mastra-free on purpose — ddx-api jest
does not exempt @mastra/*, so a spec transitively importing a file that
statically imports @mastra/core/* dies with an ESM SyntaxError; the factory
owns the Mastra binding instead (tool-policy.ts:10-15).
Every tool authored through define-tool.ts MUST declare a policyClass —
omission is a compile error (define-tool.ts:4-11, :88); requireApproval is
then derived from it (define-tool.ts:124, :134), so a v2 tool never
hand-writes an approval callback.
policyClass | Baseline |
|---|---|
read | allow — no side effect |
mutate | two-axis gate: hitlTier × autonomy (see below) |
exec | always require-approval; deny unless the sandbox-exec env gate is on |
comms | outward-facing send (email/SMS/message) — require-approval |
(tool-policy.ts:26-40)
The mutate gate is the load-bearing subtlety: HIGH tier (or a destructive
op:delete/void) always suspends, even under autonomous operator chat;
LOW tier resolves to allow when autonomy:'autonomous' — the human who typed
the request is the approval — and suspends under supervised
(tool-policy.ts:31-34, :199-204).
execution-policy.service.ts is the runtime entry point the tool-call and
approval leaves call. It does not re-implement the matrix: it builds an
immutable ExecutionPolicyContext and delegates to evaluateToolPolicy
(tool-policy.ts:147), threading the run's real autonomy plus a conservative
mutating-only default posture (execution-policy.service.ts:4-9, :38-40).
The earlier leaf gate branched on autonomy === 'supervised' directly, which
made an autonomous run skip the suspend for every tool — the fix lives here
(execution-policy.service.ts:11-16). The verdict carries {ruleId, reason}
naming the matrix row that fired, for the audit trail
(execution-policy.service.ts:42-46). Like the engine, the service is pure,
synchronous and Mastra-free — functional, not @Injectable, because the leaf
compiler has no injector (execution-policy.service.ts:22-25).
Related Topics
- Condor Tools Registry — the tool inventory that
calls through this layer;
create-condor-tool.tsconsumes thetool-platform/factories. - Condor Engine — the runtime, flow router, and Mastra plan
compiler whose leaf builders invoke
execution-policy.service.ts. - Condor HITL Approval — the suspend/resume loop the
policy verdict feeds;
policyClass+hitlTierare its inputs.