Boston InterSystems Developers: AI Meetup & Mixer, August 2026

Table of Contents

Basics

Field Value
Event AI Meetup & Mixer for Developers and Startups
Date <2026-08-25 Tue> 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

  1. skill propose exists on the learning agent. What is the gate between proposed and installed?
  2. After the −19% result, how is load scope decided — manual annotation per skill, or a router?
  3. Policy categories are coarse (ten). Is there per-argument gating? query as a category permits SQL reaching patient tables; the gate is at tool granularity, the risk is at argument granularity.
  4. 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.

federation.png

# =============================================================================
# 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 @key fields leak. The _entities representation 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. Patient and DeidentifiedSubject are different types. Under RESEARCH there 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

  1. Ask Pallerla whether the IRIS + OpenMed integration will be published, and whether de-identification is enforced at egress or sequenced in the app.
  2. Ask Dyar about the skill propose promotion gate — closest external analog found so far to the skills-hub draft to eval to staging to promoted lifecycle.
  3. Evaluate CRIS-style structured-field dictionaries against pure NER on the local fixture; expect it to close the partial-mention coreference gap.
  4. 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.
  5. Calibrate the @quasiIdentifier weights 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-fuzzy double-metaphone, Luminare. Repo: oakmac/record-linking-talk. The same estimator as the linkage attack above, run with the opposite sign.
  • Healthcare NLP Summit 2024