Architecture

The Clinical Data Model: Client, Episode, Plan, Session — and Who Is Allowed to Read Any of It

August 19, 2026 · 11 min read · By Sudipta Sarkar, SignalEHR

SignalEHR is pre-revenue and the only two clinics in it are our own demo tenants, so nothing below describes a customer. This is engineering, not legal advice: every statute is how we read it while building, and a lawyer in your jurisdiction outranks all of it. Shipped and designed are marked separately throughout.

A worker in Alberta has an accepted compensation claim, and you are eight sessions into authorized trauma work. The same man carries a social anxiety that predates the injury by a decade, which the board will not fund and which he pays for out of pocket.

Two courses of care, two funders, two reporting obligations, one person, the same week.

Our software filed the trauma sessions' progress into the private-pay plan. Not occasionally — every time. When a client has two active plans, the code asks for "the client's plan" and takes whichever the sort order surfaces first. Nothing errored, nothing looked broken, and the notes are perfectly accurate. They are simply attached to a course of care they do not belong to, and every summary built downstream inherits that.

One missing object causes it.

1. The object model

Four objects carry a course of therapy, and it is easy to ship three and let the fourth dissolve into the client. That is what we did.

Client

The person and their chart. One per human being, and the thing every other object hangs from today.

Episode of care

A bounded course of treatment for a particular purpose, with a beginning, a reason and an end. The one that usually goes missing.

Designed, not built

Treatment plan

Diagnosis, modality and goals — each goal with a baseline, a target and measured progress.

Session

The encounter: the hour in the room, the note, and the progress that comes out of it.

Three of these exist in SignalEHR today. The second one does not.

The worker above has two episodes. "Trauma work under the accepted claim, twelve sessions authorized against the claim number, closing when the authorization closes" is one. "Ongoing therapy for social anxiety, self-funded, open-ended" is another. Same person, same clinician, same month, and almost nothing in common clinically, financially or legally — different funder, different consent surface, different reporting obligation, different end condition. Collapse them into one record and it is accurate for neither.

In the target model, a client has N episodes, at most one the default catch-all. An episode has one active plan plus superseded ones. A session belongs to exactly one episode, decided before or at the moment the encounter is created. Progress flows into the plan of that session's episode, never into "the client's plan," because once a second episode opens there is no such thing.

Today, SignalEHR hangs plans off the client directly. There is no episodes table. The function answering "which plan is this client's plan" falls through to ORDER BY created_at DESC, id DESC LIMIT 1, and nine call sites depend on that answer — every progress note the automation writes, every discharge summary, every outcome aggregation, and the progress justification the billing side leans on. The model above is approved. It is not built.

2. Cardinality: why "one active plan per client" is the wrong constraint

Three lines in the approved model: at most one open default episode per client, held by a partial unique index rather than a convention; N concurrent episodes, deliberately opened; one active plan per episode. Putting that last constraint on the client instead of the episode looks tidier and is wrong.

The Alberta forcing case

Alberta workers' compensation is authorization-gated. The board approves a course, a session count and a window, against the worker's claim number, before it will pay. And the worker never pays: no copay, no deductible, no balance billing for a compensable injury, with shortfalls and denials landing as provider liability. The single exception is a claim the board does not accept as work-related.

Now add an unrelated condition — a long-standing anxiety disorder predating the injury and outside the accepted claim. The board will not fund it, correctly, and the client pays privately. Two courses of care, two funders, two authorization stories, one chart, at once. Under one-plan-per-client that is unrepresentable, and the failure is not cosmetic: every session of authorized trauma work writes its progress notes and goal updates into whichever plan the sort order surfaces. Our demo seed data ships this as a fixture with two active plans, timestamped so the sort lands on the private-pay plan every time — reproducible rather than a heap-order accident, so a fix can be regression-tested. The goal-update hook, session prep, discharge summaries and the progress aggregation the billing side leans on for justification all read that same pick.

The binding invariant

Every completed clinical session belongs to exactly one episode, determined before or at creation of the encounter, never inferred at post-session processing time. Post-session automation resolves the plan through the session's own episode reference, never through the client, and never through the default episode.

That last clause is the one that survives careless review, so it is worth isolating.

ORDER BY created_at DESC LIMIT 1 is visibly arbitrary.

get_default_open_episode(client_id) is arbitrary with a justification attached.

The second is more dangerous precisely because it reads as principled. A reviewer sees a named function, a deterministic answer, no sort order to squint at, and approves it. Then a hook asking for "the client's default episode" resolves the private-therapy episode and files the compensation work into it — the same wrong answer as the sort order, now wearing a rationale. Both are prohibited at write time. With more than one eligible open episode, selection is required and the encounter does not start without it.

Funding is a separate axis from clinical scope

An episode says what course of care this is. It does not say who pays, and it must never say what has been authorized.

Concretely: opening a workers'-compensation episode does not authorize a single session. A separate authorization object does that, carrying the claim number, the injury date, the approved session count and the window, and it is checked before any claim is staged. Conflating the two would let a clinician open an episode and believe the board had agreed to something.

Who pays is a third thing again. Payer type already lives on the coverage row rather than on the person, so the episode carries only a small, closed, jurisdiction-neutral basis — self-pay, third-party, undetermined — explicitly set and never defaulted from data, plus a foreign key to the coverage row when a third party pays. Undetermined lets the system say I do not know instead of guessing. The tempting alternative, an enum with a WCB_AB value in it, fails on its own terms: Alberta is not a kind of funding, and the moment Ontario ships you are either adding WCB_ON forever or reclassifying rows.

The US analogues

External research carried into the design review and not re-verified in the repository. I would rather flag that than launder it into product fact. Washington's L&I permits a comp insurer to authorize time-limited treatment of an unrelated condition delaying recovery of the accepted injury — the same bounded-scope shape as Alberta, arrived at independently. EAP converting to insurance is a second case, with the honest limit that the sourced pattern is sequential with a hard boundary, not concurrent. And 42 CFR Part 2 scopes confidentiality to a federally assisted program that holds itself out as providing substance-use treatment: program-scoped by construction, against a chart-scoped implementation, which makes segmentation a US problem as much as a Canadian one. That cuts against us. The US billing package has no workers'-comp, auto/PIP, EAP or victim-compensation module, so our only funding-scope machinery is Canadian, and specifically Albertan.

The same error, one layer up

3. Guardians and proxies

Everything above is one mistake: a relationship was collapsed into an attribute. "Which course of care is this session part of" got answered by a property of the client instead of by an object that models the course. The authorization layer contains the identical mistake, and it is worth seeing the two together, because a team that fixes one and not the other has not learned anything.

There, the collapsed relationship is representation, and the attribute standing in for it is a date of birth.

A minor's chart carries a guardian's email, because that is the address the clinic reaches. Provisioning sees an email on a chart and mints a login, and the system has now asserted something legally specific and probably false without anyone deciding it: this person is the patient. Not the representative. The patient. Our own provisioning did exactly that. It wrote a portal account keyed on the guardian's address and an accepted practice link asserting the guardian was the patient. The fix shipped in August 2026, and with no real users nobody was affected. The function was working; the model underneath it was wrong.

What the law says, on both sides of the border

Under HIPAA, 45 CFR §164.502(g), a parent who is the minor's personal representative generally holds the minor's own right of access, portal included. Blanket-blocking every parent is the opposite overcorrection, and it withholds access the rule contemplates. There are four circumstance-based exceptions at §164.502(g)(3)(i) and (g)(5): the minor's own lawful consent, a court order or other authorized consenter, an agreed confidentiality, and endangerment in professional judgment. A fifth path sits at §164.502(g)(3)(ii)(B), where state law prohibits disclosure independently. Our first four-value enum could not express that fifth one, which was a US-side gap found in review.

Canadian regimes reach the same two-sidedness through capacity. Alberta's mature-minor doctrine turns on whether this young person understands this decision, not on a birthday. Ontario's PHIPA sets 16 as the age from which a capable person's own consent governs their health record. Quebec's consent-to-care age is 14.

Why a date of birth cannot be the authorization

Age is a trigger, a reason to look and a reason to schedule a review. It is never, alone, an access decision. DOB < 18 ⇒ guardian gets in is wrong in the US, wrong in Alberta, and wrong twice over in Ontario and Quebec. So the design constrains the query deciding which charts a proxy may reach: date of birth appears nowhere in it. Every age trigger is a review workflow that summons a human, and the reverse holds too, so a chart with no date of birth is flagged for review rather than refused.

Shipped versus designed

Shipped. A chart that looks proxy-shaped — whether flagged as a minor, carrying a date of birth under 18, or naming a guardian by name, relationship or phone — is provisioned nothing. Both the create path and the edit path check, and the quarantine writer sets blocked rather than deleting the row. An earlier round let a quarantined link return automatically once its chart was corrected. Review killed it, because blocked is also where staff put a legal hold, and the two are indistinguishable at the row level. The mechanism was deleted rather than guarded: a security control that unblocks itself is strictly worse than the defect it was built to fix. Termination shipped next, with its residual exposure written into the source — the video layer has no token revocation, so the sweep ends the live seat and a token lifetime of four hours or less bounds a rejoin. A limit stated with its bound is a different sentence from "everything is revoked."

Designed, not built. The guardian relationship table, access levels, the consent flag, the restriction-basis field, the jurisdiction snapshot, the chart picker, person-level login. Which makes the honest sentence today: no guardian can reach any portal route in SignalEHR. The answer to "may this proxy see this chart" is to refuse to create the identity at all, which is a safe failure rather than a feature.

Jurisdiction

4. Jurisdiction is data, not an assumption

The design's answer is a versioned in-code registry keyed by country and region, each entry carrying a list of age triggers and stamping a rule-version string onto every relationship row, so any decision can later answer which version labelled this. The list is the point. One threshold per jurisdiction could only express majority. Ontario's capable-16 and Quebec's 14 arrive earlier, so under a single-threshold registry they pass in silence and a parent keeps access until 18. Alberta's mature-minor doctrine is not a threshold at all; it is a capacity determination, which is why capacity is a first-class field.

"Fails safe" here does not mean "default to the stricter rule." It means refusing, and summoning a human. An empty country/region pair refuses creation, with no silent fallback to the US or to Alberta, as does a region that cannot be normalized to a code (Alberta → AB). A pair the registry does not know needs staff acknowledgment and triggers review at 18, flagged as safe only relative to the majority trigger. One hazard is documented rather than hidden: the clinic country column defaults to 'US' in Python, so a Canadian clinic created without an explicit country is silently stamped US. Wrong but non-empty, which the empty-pair refusal cannot catch.

Now the honest part. None of that registry exists in code; the file does not exist. No country, province or state is read anywhere in the portal authorization path. What exists is one number, an age-of-majority constant set to 18, used only as a provisioning-refusal threshold, with a comment above it saying jurisdiction genuinely varies, that Canadian minor access turns on capacity rather than a birthday, and that this number must not grow a second meaning. One province is not Canada. One state is not the United States. We are one province, and not even that, in the portal.

5. What is actually different here

Most of this is not exotic, and it would be dishonest to pretend otherwise. Letting a client carry several treatment plans at once is ordinary; plenty of systems do it. Giving a parent a set of portal permissions separate from the patient's is ordinary too, and per-capability toggles are a common shape. Nothing below depends on being first at anything.

The difference is not the count. It is the binding.

Several plans hanging off a client is a list. Something still has to decide which one this session belongs to, and if the schema does not decide it, the code will — by sort order, or by a helpfully named function that picks a default. An episode is a container that owns the session, so progress, goal updates, session prep, discharge summaries and payer justification all inherit one unambiguous course of care, because they resolve through the same reference. That is a schema property, not a screen.

Same move one layer up. Permission toggles answer what may this person do. They do not record why this person may do anything at all. A relationship object carrying a consent basis, a restriction basis, a capacity determination, a jurisdiction snapshot and the rule version that labelled it is a different kind of thing, because it can be reviewed, audited and re-decided when a rule changes or a birthday passes. A toggle can only be flipped by someone who remembered to go and flip it.

And jurisdiction. Operating in two countries is common. Encoding jurisdiction as a versioned registry keyed by country and region — emitting a list of age triggers and review labels, and never a predicate that grants access — is what makes Ontario's 16 and Quebec's 14 expressible as data rather than as special cases buried in a branch. In most systems we are aware of, the usual approach is that jurisdiction lives in the help documentation and lands on the clinician to apply.

To be exact about our own position: of those three, the registry is a document and the first two are designs. What is shipped is the refusal.

6. Three walkthroughs

A worker in Alberta

Workers' compensation authorizes twelve sessions of trauma-focused work; partway in, the social anxiety that predates the injury becomes worth working on, and the board will not fund it. Target model: two episodes, one funded through a coverage row carrying the claim number and injury date, one self-pay, the session bound at booking. Current model: the sort order picks the private-pay plan, so the goal updates and every summary derived from the active plan describe the wrong course of care.

A US client whose EAP runs out

Six covered sessions through an employer program, then continued care under the health benefit. Two authorizations, two counters, a hard boundary, one clinical thread across both. The model that handles it is two episodes that must never be commingled — and today the EAP module exists only on the Canadian side, so a US client here has no path.

A fifteen-year-old and a parent's email

Today the chart looks proxy-shaped, so provisioning creates nothing: no account for the parent, none for the teenager, and the refusal lands as an audit row a human can see. Nobody gets in. Under the design, a guardian relationship is its own object carrying a jurisdiction snapshot, an access level, a consent flag and a restriction basis, with review triggering at the ages the jurisdiction specifies — 18 in Alberta, 16 and 18 in Ontario, 14 and 18 in Quebec.

7. Why this is worth the effort

Records outlive the software that made them. Charts get exported, subpoenaed, migrated, and read by people who never met the clinician, and whatever binding was in place when the note was written travels with it.

A wrong episode binding is a clinical-record integrity problem rather than a UI bug. It does not render incorrectly. It renders perfectly, describing work that happened, filed against a course of care it does not belong to. Then it propagates, because goal updates, discharge summaries, outcome aggregations and payer appeals are all derived from that binding — the same golden thread an auditor follows — and rebinding stops being a free edit the moment any of those exist. The portal version of the same problem is an ex-representative reading an adult's chart, where the timer runs on a birthday the software may never have been told about.

8. Where we actually are

Shipped, running in code today

  • Proxy-shaped charts are provisioned nothing, on create and edit paths, from five signals, with the removal threshold narrower than the refusal threshold and every refusal landing a readable audit row.
  • Quarantine writes blocked only. A block never lifts itself; restoration is one named human, one link, with actor and reason, audited.
  • Termination cuts every self-service channel a terminated chart holds, through one shared helper wired at five call sites covering six termination outcomes, with the residual exposure and its four-hour bound written into the source.
  • One multi-tenant authorization predicate in one module, its three spellings in SQL, ORM and Python pinned against each other across the full truth table.
  • Two-country billing separation at directory level: twenty modules under us/, twelve under ca/, twenty-two shared under core/. The portal link gate exists and is dark, its flag defaulting off.
  • Test files named for the gates and ratchets they enforce, including a probe written to refute its own implementer — one of whose tests makes the ratchet fail, to prove it can.

Designed, reviewed, not built

  • There is no episodes table, and no episode column or foreign key on any plan, appointment or session, so neither unique index exists. The seeder writes a plain-text episode label into a plan's JSON extras so a future backfill is a join rather than an archaeology exercise; that label is not a reference and nothing reads it as one.
  • Guardian relationships are not objects yet: no access level, consent flag, restriction basis or effective dates — and no chart picker or person-level login to reach them through.
  • The jurisdiction registry is a document, not a file. No country, province or state is read anywhere in the portal authorization path.
  • Confidential-care segmentation has no substrate in the schema at all, and the design says so in those words rather than implying a partial one.

Next is the session-to-episode binding, and it is gated on something duller than a design question: sessions carry no appointment reference, appointments carry no funder, and funding is still a client-level singleton. Until those three change, an episode model would have nothing to bind to.

The honest bottom line

Two clinics exist in this system and both are ours. The seeded demo roster is thirty-two charts, sixteen per clinic. Zero real patients. The right word is pre-revenue.

The evidence of seriousness is not a coverage percentage. It is that a review of the guardian access design found six blockers and thirteen highs, and every one was resolved in the body of that document rather than appended as a banner. That a security capability was deleted when review showed it could unblock itself. That residual exposures are written into the code that carries them, with their bounds, instead of being described as solved.

Records outlive the software that made them. You can ship a correction to a pricing page. You cannot ship one to a chart that has already been exported, subpoenaed, or migrated into somebody else's system — and a wrong episode binding does not announce itself there, because it renders perfectly. It just describes work that happened, filed against a course of care it never belonged to.

See the model in the product, gaps included

SignalEHR is an AI clinical intelligence and practice management platform for licensed therapists in the US and Canada — living treatment plans, AI clinical notes, Amelia for scheduling and billing, and cross-border insurance claims at $199/practitioner/month. 14-day free trial, no credit card required. The episode model on this page is designed and approved, not shipped; we would rather you read that here than discover it later.

Related