13-ai-condorwave: W6filled37 citations

Condor HMS Tool Layer — Client, Id-Alias Proxy, Transport Port, Policy

Audiences: developer, internal

Condor HMS Tool Layer — Client, Id-Alias Proxy, Transport Port, Policy

How a Condor tool actually reaches patient data. Four directories under ddx-api/src/ai/condor/ (~134 files) sit between a tool's execute() and the HMS clinical services: hms-client/ (typed client), hms-access/ (transport port + adapters + the id-alias proxy), tool-execution/ (idempotency, retry, failure taxonomy), and tool-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

DirectoryOwnsEntry point
hms-client/the typed outbound HTTP boundary to HMS ddx-api + the error taxonomyhms-api.client.ts
hms-access/the transport port, its two adapters, the caller identity, and the per-session alias maphms-access.port.ts, alias/condor-alias-map.service.ts
tool-execution/retry context, durable idempotency, the tool-error envelope taxonomyidempotency-record.ts
tool-platform/the defineTool factory + the single approval-policy authoritytool-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 NestJS HttpModule.
  • HMS gateway header set only; never Authorization: Bearer toward HMS :6100.
  • Typed HmsErrorBase throws; 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 returns null, 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 by load(sessionId); flush(sessionId) persists once at turn end. A busy turn mints ~180 aliases, so a per-encode write 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-001 and patient-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 to null — 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 via isUuid/isFhirId, falling back to slug. The naive { uuid: id } form poisoned uuid with a FHIR id, which propagated into AiSession.linkedEntityId and 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-001task-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).

AdapterSelected byBehavior
InProcessHmsAccessdefaultCONDOR_HMS_TRANSPORT=in-processcalls ddx-api clinical services directly with a synthesized auth/tenant context
HttpHmsAccessfallback — CONDOR_HMS_TRANSPORT=httpone-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

Rendering diagram…

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:

ImplRole
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.

policyClassBaseline
readallow — no side effect
mutatetwo-axis gate: hitlTier × autonomy (see below)
execalways require-approval; deny unless the sandbox-exec env gate is on
commsoutward-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).

  • Condor Tools Registry — the tool inventory that calls through this layer; create-condor-tool.ts consumes the tool-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 + hitlTier are its inputs.