Boston InterSystems Developers: AI Meetup & Mixer, August 2026
Table of Contents
Basics
| Field | Value |
|---|---|
| Event | AI Meetup & Mixer for Developers and Startups |
| Date | 17:15–19:30 EDT |
| Venue | Venture Café Cambridge (CIC, 5th Floor), One Broadway, Cambridge, MA |
| Host | InterSystems Developer Meetups (Boston); Olga Zavrazhnova |
| Format | In-person; two technical talks + guided networking |
| Turnout | 91 registered, 53 first-time attendees |
| Source | https://www.meetup.com/boston-intersystems-developers-meetup/events/315804008/ |
Both speakers from InterSystems. The listed topics — Healthcare IT, Big Data, Database Professionals — describe the room better than the event title does. A parallel Boston AI Developers Group event ran at the same venue at 17:00.
Program
| Time | Item |
|---|---|
| 17:15–17:30 | Doors, security check-in |
| 17:30–18:30 | Tech talks |
| 18:30–19:30 | Guided networking |
- Don't Send That Clinical Prompt Yet — Vishal Pallerla, Sales Architect. A privacy-first workflow using OpenMed to detect and remove patient identifiers locally before text reaches a cloud model.
- AI with InterSystems IRIS: tools, skills, and graphs, oh my! — Thomas Dyar, Sr. Manager, AI Platform and Ecosystem.
Talk 1 — clinical de-identification
Pallerla: GitHub vishalpallerla, DC handle Devocado. Prior work is IRIS
vector-search RAG over clinical notes and the Health Evolve unstructured-data
demo.
Notes as taken
- strip the User details, preserve Profile and context
- OpenMed as filter / proxy
- fine-tune on the medical information
- filter and removals
- telemetry for audits over the systems
- events; identifiers; transcription service; UGC; private data
- audits over data access and injections
The gap the abstract leaves open
"Before any text is sent" is a claim about sequence, not a control. Nothing in that shape prevents a second call site, a retry, a logging sidecar, or an agent tool call from reaching the model unfiltered.
The question to put to the speaker: is de-identification enforced at egress, or is it a step in the application path? If someone adds a call site tomorrow that forgets the filter, what stops the request?
Open source underneath
maziyarpanahi/openmed — Apache-2.0, local-first, 55+ PHI types, MLX and
ONNX Runtime Mobile targets, MCP server and typed tool registry.
Paper: arXiv:2508.01630. The IRIS integration shown does not appear to be
published.
Prior art the approach sits on
| System | Contribution |
|---|---|
PhysioNet deid.pl |
Neamatullah 2008; ships a gold-standard re-identified corpus |
| CRATE | Cardinal 2017; cryptographic pseudonyms plus a consent-to-contact model |
| CRIS (SLaM) | Per-patient dictionaries built from structured identifier fields, then those strings masked in free text |
| Philter, pyDeid | Later open implementations; speed/recall trade-offs documented |
CRIS is the technique worth taking. Using the structured record as ground truth for that patient — rather than asking a model to guess — addresses both recall and the partial-mention coreference gap ("Rose Hodgkin" vs "Hodgkin") that pure NER cannot close.
CRATE pairs pseudonym generation with consent-to-contact, which is Matt Might's rare-disease matchmaking broker model already implemented in open source.
Talk 2 — agentic tooling on IRIS
Dyar: GitHub isc-tdyar, DC handle tomd. The title decodes onto three repos
under intersystems-community.
| Term | Repo | What it is |
|---|---|---|
| tools | iris-agentic-dev |
Rust, MIT, single-binary MCP server + CLI, 90+ tools over Atelier REST |
| skills | (same repo) | SKILL.md library, benchmark-gated contribution path |
| graphs | iris-vector-graph |
Graph + HNSW vector + lexical in one DB; SQL, openCypher v1.3, GraphQL |
Also iris-vector-rag, iris-ai-examples, iris-pgwire,
integratedml-demo-template.
A published negative benchmark result
objectscript-review is a 205-word checklist of the ten most common
ObjectScript mistakes; it moves a 22-task repair suite from 73% to 100%.
Buried in the skill inventory: objectscript-loop-patterns measured −19%
lift when loaded for all tasks.
A skill that degrades performance by being present is direct evidence that skill routing is structural rather than a convenience. The repo also states plainly that the +27% is one run, one model, and 22 tasks that may be in training data. The caveat is rarer than the number.
Privilege separation that splits authority from attribution
IRIS_SERVICE_USERNAME points the four arbitrary-execution tools at a
least-privilege account with the code database read-only, so code edits fail
with <PROTECT> at the IRIS privilege layer. Code-writing tools keep the
primary account "so audit stays attributed to the real user."
Executing principal downgraded, accountable principal preserved. Most implementations collapse those two and lose one or the other.
Declarative, introspectable gates
[policy.<server>] blocks in TOML restrict tool categories per connection;
blocked calls return error_code: "POLICY_GATE" with the permitted list.
Patient-data and system globals are gated on iris_global;
iris_message_body is blocked unless the connection permits patient data.
capability_matrix reports which tools are disabled and why.
Enforcement before the call — the property Talk 1's architecture may lack.
Architectural note
iris-ai-examples runs %AI.Agent + %AI.Provider inside IRIS, called via
MCP: agent inside the data boundary rather than data crossing out to the
agent. A real alternative to the filter-proxy model, and it relocates where
egress control has to sit.
Questions
skill proposeexists on the learning agent. What is the gate between proposed and installed?- After the −19% result, how is load scope decided — manual annotation per skill, or a router?
- Policy categories are coarse (ten). Is there per-argument gating?
queryas a category permits SQL reaching patient tables; the gate is at tool granularity, the risk is at argument granularity. - Any plan for held-out task generation, given the contamination caveat?
Projection is not redaction
The distinction both talks circle without naming, and the reason the schema below is worth writing.
Redaction is subtractive on the same type. "Rose Hodgkin, 92, CML on
imatinib" → "[NAME], [AGE], CML on imatinib". Same string, holes in it.
Failure mode is a missed span, and recall over an unbounded input space is a
statistical claim that can never be discharged.
Projection is constructive into a different type. to
{ageBand: A90_PLUS, dx: C92.10, medClass: TKI, daysSinceOnset: 21}. The name
was never in the output type. Failure mode is including a field you
shouldn't — which is enumerable. You audit a schema, not a corpus.
Redaction fails open; projection fails closed. Residual risk moves from "did the detector find everything" (unbounded, unprovable) to "is this field set disclosive" (finite, computable). The cost: projection needs a target vocabulary — HPO, ICD-10, RxNorm, LOINC. No approved code, no projection.
Channel asymmetry
| Direction | Surface | Available control |
|---|---|---|
| Inbound | Patient free text, transcripts, UGC — unbounded, untrusted, injection-bearing | Redaction only; weakest surface in the system |
| Outbound | Generated response | Projection from an approved corpus; response space closed by construction |
A system whose outbound channel can only emit what its approved corpus licenses behaves identically whether that corpus is current guidelines or Galen. Bad KB, coherent system. The register differs; the control does not.
Schema
GraphQL is the right substrate because a schema is a projection language: the field set is enumerable, which is precisely what redaction lacks. Four subgraphs, stitched at a gateway that is the only place identity and clinical fact can be rejoined.
# =============================================================================
# directives --- the policy vocabulary. Read by schema_policy.py; CI fails on a
# field added without one.
# =============================================================================
directive @phi(category: PHICategory!, safeHarbor: Int) on FIELD_DEFINITION
directive @quasiIdentifier(weight: Float!) on FIELD_DEFINITION
directive @requiresPurpose(anyOf: [Purpose!]!) on FIELD_DEFINITION | OBJECT
directive @minCohort(k: Int!) on FIELD_DEFINITION | OBJECT
directive @decisionOnly on OBJECT
enum PHICategory { DIRECT_IDENTIFIER CONTACT GEOGRAPHIC TEMPORAL CLINICAL }
enum Purpose { TREATMENT PAYMENT OPERATIONS RESEARCH PATIENT_ACCESS }
# =============================================================================
# subgraph: identity custody: registration system
# =============================================================================
type Patient @key(fields: "subjectKey")
@requiresPurpose(anyOf: [TREATMENT, PAYMENT, PATIENT_ACCESS]) {
# Opaque per-deployment surrogate, NOT the MRN. Federation puts @key fields
# in the _entities representation sent to every subgraph resolving this
# entity; keying on mrn would place it on the wire to subgraphs that have no
# business holding it.
subjectKey: ID!
givenName: String! @phi(category: DIRECT_IDENTIFIER, safeHarbor: 1)
familyName: String! @phi(category: DIRECT_IDENTIFIER, safeHarbor: 1)
mrn: String! @phi(category: DIRECT_IDENTIFIER, safeHarbor: 8)
ssn: String! @phi(category: DIRECT_IDENTIFIER, safeHarbor: 7)
birthDate: Date! @phi(category: TEMPORAL, safeHarbor: 3)
postalCode: String! @phi(category: GEOGRAPHIC, safeHarbor: 2)
phone: String! @phi(category: CONTACT, safeHarbor: 4)
email: String! @phi(category: CONTACT, safeHarbor: 6)
}
# =============================================================================
# subgraph: clinical custody: EHR
# =============================================================================
# A different TYPE, not a masked Patient. Under RESEARCH there is no path to a
# name --- not a denied one, none. That is what makes it a projection.
type DeidentifiedSubject @key(fields: "subjectKey") @minCohort(k: 11) {
subjectKey: ID!
ageBand: AgeBand! @quasiIdentifier(weight: 0.4)
postalPrefix: String @quasiIdentifier(weight: 0.7)
sex: AdministrativeSex @quasiIdentifier(weight: 0.2)
conditions: [Condition!]!
medications: [MedicationStatement!]!
observations: [Observation!]!
# Interval, not anchor. Dates shifted by a per-subject offset: durations
# survive, absolute position does not.
daysSinceOnset: Int @quasiIdentifier(weight: 0.1)
}
type Condition {
# ICD-10-CM. Weight is nominal --- see §Enforcement: a rare code and a
# common one are the same field and wildly different disclosure.
code: String! @quasiIdentifier(weight: 0.9)
display: String!
phenotype: [String!]! @quasiIdentifier(weight: 0.8) # HPO
onsetIntervalDays: Int @quasiIdentifier(weight: 0.1)
}
type MedicationStatement {
rxNormIngredient: String! @quasiIdentifier(weight: 0.5)
therapeuticClass: String! @quasiIdentifier(weight: 0.1)
}
type Observation {
code: String! @quasiIdentifier(weight: 0.2) # LOINC
valueQuantity: Quantity
intervalDays: Int @quasiIdentifier(weight: 0.1)
}
type Quantity { value: Float! unit: String! }
enum AgeBand { A0_17 A18_29 A30_44 A45_59 A60_74 A75_89 A90_PLUS }
enum AdministrativeSex { FEMALE MALE OTHER UNKNOWN }
scalar Date
scalar DateTime
# =============================================================================
# subgraph: messaging custody: patient portal
# =============================================================================
type PatientMessage @key(fields: "messageId") {
messageId: ID!
# UGC: unbounded, unschematized, injection-bearing. Never interpolated into
# an instruction position downstream.
body: String! @phi(category: DIRECT_IDENTIFIER)
@requiresPurpose(anyOf: [TREATMENT, PATIENT_ACCESS])
receivedAt: DateTime! @quasiIdentifier(weight: 0.3)
triage: TriageRoute! @quasiIdentifier(weight: 0.1)
draft: MessageDraft
}
# Abstention is a categorical decision the review loop later confirms or
# contradicts, which makes it a labeled event. A confidence float is not.
enum TriageRoute {
ADMINISTRATIVE
CLINICAL_QUESTION
URGENT
ABSTAIN_NO_GROUNDING
ABSTAIN_AMBIGUOUS
}
type MessageDraft {
# Every clinical sentence cites an approved chunk. Uncited sentences are
# blocked, not flagged.
citations: [KBChunkRef!]!
grounded: Boolean! @quasiIdentifier(weight: 0.0)
reviewedBy: ID @phi(category: DIRECT_IDENTIFIER) # clinician, not patient
editClass: EditClass @quasiIdentifier(weight: 0.0)
}
# Approved-corpus provenance. No patient data by construction, but declared
# explicitly rather than left undeclared --- the lint treats silence as safe.
type KBChunkRef @decisionOnly {
corpusId: ID!
chunkId: ID!
kbVersion: String!
approvedDate: Date!
}
# Style edits and semantic corrections are different events. Aggregate them
# and the metric looks stable while corrections rise.
enum EditClass { NONE STYLE ADDITION DELETION CORRECTION REJECTED }
# =============================================================================
# subgraph: audit custody: append-only ledger
# =============================================================================
# @decisionOnly: no field here may carry clinical text. Entity types and
# counts, digests, versions, outcomes. The redaction manifest with offsets
# stays under the same custody as the source note; this chain does not.
type AuditEvent @decisionOnly {
chainHash: ID!
prevHash: ID
occurredAt: DateTime!
governance: GovernanceTuple!
purpose: Purpose!
inputSha256: String!
inputLength: Int!
redaction: RedactionSummary!
residualBlockClass: Int!
policyVersion: String!
kbVersion: String
outcome: Outcome!
traceId: ID!
}
# [persona:agent:reviewer@env(project:workspace)]
type GovernanceTuple @decisionOnly {
persona: String!
agent: String!
modelDigest: String!
reviewer: String
env: String!
project: String!
}
type RedactionSummary @decisionOnly {
entityCounts: [LabelCount!]!
detectors: [String!]!
agreement: Float!
singleSourceLabels: [String!]!
corpusSha256: String
}
type LabelCount @decisionOnly { label: String! count: Int! }
type Outcome @decisionOnly {
status: OutcomeStatus!
schemaEnforced: Boolean!
thinkMode: Boolean!
latencyMs: Int!
droppedAttrs: Int!
}
enum OutcomeStatus {
OK
BLOCKED_RESIDUAL_PHI
SCHEMA_NOT_ENFORCED
POLICY_GATE
ABSTAINED
}
# =============================================================================
# root
# =============================================================================
type Query {
patient(subjectKey: ID!): Patient
@requiresPurpose(anyOf: [TREATMENT, PAYMENT, PATIENT_ACCESS])
subject(subjectKey: ID!): DeidentifiedSubject
# @minCohort enforced on the RESULT SET, not the schema: a filter narrow
# enough to return fewer than k subjects is refused rather than answered,
# because a count of one is a re-identification.
cohort(filter: CohortFilter!, purpose: Purpose!): [DeidentifiedSubject!]!
@minCohort(k: 11)
auditChain(traceId: ID!): [AuditEvent!]!
@requiresPurpose(anyOf: [OPERATIONS])
}
input CohortFilter {
conditionCodes: [String!]
ageBands: [AgeBand!]
postalPrefixes: [String!]
}
Enforcement, and where it fails
schema_policy.py reads the directives and applies four checks at the
gateway: purpose, category, a field-set disclosure budget, and a cohort floor.
Field-level authorization is not sufficient under federation. Every field in a
query may be individually permitted while the combination re-identifies —
postalPrefix, ageBand, sex and a rare condition code are each innocuous
and jointly a name. Per-field checks pass; the join is the disclosure.
The static budget does not work, and the failure is the finding
A legitimate research query — age band plus condition code — tripped the budget on first run. That is the shape of control people disable within a month.
The cause is not the threshold. Quasi-identifier weight is a property of the
value, not the field. Condition.code holding a common ICD-10 discloses
almost nothing; the same field holding a rare-disease code discloses nearly
everything. No static weight expresses that, because it is the same field.
So the budget demotes to a cheap pre-filter that runs before execution and
catches obviously reckless selections. The binding check moves to
check_result: equivalence-class cardinality over the rows actually
returned. Value-aware, computable, no guessed weights.
Two things easy to get wrong
- Federation
@keyfields leak. The_entitiesrepresentation payload travels from gateway to every subgraph resolving that entity.@key(fields: "mrn")puts the MRN on the wire to subgraphs with no business holding it. The natural key is the obvious choice and nothing complains. - Type-level separation beats field-level masking.
PatientandDeidentifiedSubjectare different types. UnderRESEARCHthere is no field to ask for.
Schema lint, and the lint's own bug
A field added without @phi or @quasiIdentifier is treated as safe by every
downstream check. Schemas grow by PR; the lint belongs in the docs gate. It
found undeclared fields on first run against this schema, which is the lint
working.
More usefully, it found a bug in itself. The SDL header regex required the opening brace on the declaration line, so a type split across lines by its directives —
type Patient @key(fields: "subjectKey")
@requiresPurpose(anyOf: [TREATMENT, PAYMENT, PATIENT_ACCESS]) {
— was dropped entirely. The dropped type was Patient, holding every direct
identifier in the schema. And the failure presented as success: a query
against a vanished type is denied as unknown type, which reads exactly like
the gate doing its job.
Fail-safe by accident is not fail-safe. This is the same class as a
contamination detector whose \bproton\b never matched "protons", or a notes
gate reporting PASS: 0 blocks because \+ means "one or more" in BRE. The
only reliable discovery method is negative-testing the checker: assert that a
known-bad schema fails, not merely that a known-good one passes.
Measurements
Against a synthetic 40-patient cohort with ground-truth spans.
Disclosure, assuming perfect redaction
| Generalization | k | reidentification |
|---|---|---|
| zip5 / exact age / sex | 1 | 95.0% |
| zip3 / 5-yr band / sex | 1 | 55.0% |
| zip1 / decade / sex | 1 | 20.0% |
| suppressed | 40 | 0.0% |
Perfect span redaction, 95% re-identification. Small-n inflates the numbers, not the shape — Sweeney's result, and the reason Safe Harbor has a zip rule. 32.5% of the cohort carries a rare condition, untouched by the ladder.
Redaction baseline
Regex-only detector: 1.000 recall on SSN, MRN, DATE, PHONE, EMAIL; 0.000 on every entity requiring a model; 0.381 micro. Structured identifiers are free. Any NER system not clearing 0.381 by a wide margin is not earning its inference cost.
Negative control
A sham reasoner predicting clinical fields from lunar phase and sun sign scores at its own per-feature permutation floor — and passes every guardrail in the stack: schema-valid output, clean spans, well-formed hash-chained audit record. Provenance machinery certifies that a decision was made under a declared policy. It says nothing about whether the decision was correct.
Per-feature floors matter: sun sign (12 levels) out-scores lunar phase (8) on pure noise, so a shared floor calls the bias signal.
Why
InterSystems IRIS sits under a large share of hospital systems, and the clinical-AI privacy angle is the same provenance-boundary question the wal.sh verification work asks elsewhere — where sensitive data is established versus where it is relied on.
The projection framing sharpens that. A redaction pipeline claims a property about a corpus it cannot enumerate. A projection claims a property about a schema it can. The first is a statistical assurance that degrades silently; the second is a contract that fails loudly. Both talks were about the first; neither named the second.
Follow-ups
- Ask Pallerla whether the IRIS + OpenMed integration will be published, and whether de-identification is enforced at egress or sequenced in the app.
- Ask Dyar about the
skill proposepromotion gate — closest external analog found so far to the skills-hub draft to eval to staging to promoted lifecycle. - Evaluate CRIS-style structured-field dictionaries against pure NER on the local fixture; expect it to close the partial-mention coreference gap.
- Pull PhysioNet's gold-standard corpus as a real labeled fixture. The local
one is synthetic, and its 900–999 SSNs will not fire a validating
detector — Oakman's conj code carries a
valid-ssn?check, which is exactly the case that breaks it. - Calibrate the
@quasiIdentifierweights against a real cohort, or drop them and rely on the result-time floor alone.
Related
- Events index — full conference calendar
- Clojure/conj 2019 — Chris Oakman, Probabilistic Record Linkage of Hospital
Patients. Fellegi-Sunter,
clj-fuzzydouble-metaphone, Luminare. Repo:oakmac/record-linking-talk. The same estimator as the linkage attack above, run with the opposite sign. - Healthcare NLP Summit 2024