Capstone 9 — Behavioral Health UM Modernization
Build a coordinator and eight specialist subagents that move a 2011 Spring MVC/JSP monolith onto a modern distributed platform — and that report, with evidence, every place where the modern platform is not good enough for the domain being moved onto it. The deliverable is a working repository and a gap register.
Project Brief
A health plan has spent thirty years contracting its behavioral health out to a separate company. That company — Bridgeway Behavioral Health — had its own provider network, its own clinical criteria, its own claims platform and, crucially, its own member identifiers. The plan has now in-sourced the whole thing.
Medical prior authorization already runs on a modern distributed platform: an Nx monorepo, an Angular client, a NestJS intake service, a Spring Boot case service, Kafka, Camunda BPMN and DMN, Flyway migrations, a transactional outboxWrite the entity and an outbox row in one local transaction; a separate worker publishes the row and marks it published. Makes persist-and-publish atomic within ONE service -- it does not make two services' writes atomic with each other.. Behavioral health runs on BHAuthTrack 4.2 — one WAR on Tomcat 8, Java 8, Spring MVC 4.3, JSP, Oracle 11g. Deployed 2011. Last schema change March 2016. The original author left the following month.
Your job is to build the agent that moves the second onto the first.
Two hospitals merge, and one of them has to move onto the other's records system.
Before: you might reasonably assume the work is data entry at scale. Both are hospitals. Both admit patients, treat them, discharge them, bill someone. Map the fields, move the rows, retire the old server.
The pain: the receiving system was built for surgery. It records an operation: one decision, one date, one outcome. The arriving department is addiction medicine, where a patient is admitted at one level of intensity and reviewed every three days to decide whether they still need it — and where the notes are protected by a federal rule the surgical side has never had to think about. There is no field to map "the review that has to happen on Thursday" onto, because the receiving system has never had a Thursday.
The mapping: that is this capstone. The modern platform is the surgical records system. It is correct, well built, and missing whole concepts. Your agent has to port onto it and produce the list of things it cannot express — because a port that silently drops the Thursday review is worse than no port at all.
What you are actually delivering
Not a repository. A repository and a gap register: every capability in the target platform classified against the arriving domain, with the evidence for the verdict.
| Verdict | Meaning | Required |
|---|---|---|
| port-as-is | Copy the platform's approach unchanged | Evidence it is domain-agnostic |
| extend | The shape is right, the content is insufficient | What specifically must be added |
| must-build-new | Nothing corresponds. Someone has to build it | What it must do |
| must-not-port | The platform does this, and copying it here is harmful | The named harm |
The register is the reason this is a five-star capstone and not a transpiler exercise. An agent that emits a working Nx workspace has done something a good scaffolding tool does. An agent that emits a working Nx workspace and says "your decision table cannot express a denial, and in this domain the denial is the regulated event" has done something a consultant does.
The lab enforces that distinction in code. must-not-port without a named harm is rejected by the tool, not by a prompt — because softening that verdict is exactly how a defect gets copied with a note attached.
The agent topology
A coordinator and eight specialists. The coordinator has no file tools, deliberately: every read happens inside a subagent with its own context window, so the archaeologist's fifty-four files do not crowd out the rules extraction that follows.
| Phase | Subagent | Produces |
|---|---|---|
| 1 Map | architecture-cartographer | Architecture manifest, each capability tagged for BH sufficiency |
| 2 Excavate | monolith-archaeologist | Domain model, seam map, term map, unknowns queue |
jsp-archaeologist | Screen inventory and the rules found inside views | |
| 3 Extract rules | rules-extractor | Decision-table IR with a justified hit policy |
| 4 Gap-analyse | gap-analyst | The gap register |
| 5A Synthesize | repo-synthesizer | Services, migrations, events, BPMN, DMN |
| 5B Synthesize | frontend-synthesizer | Routed, role-guarded client |
| 6 Validate | parity-validator | Ten checks, each reporting what it scanned |
The run ships in two gated phases. 9A is backend and workflow, 10–12 hours. 9B is the frontend, 4–6 hours, and it does not start until 9A is green — because the screen inventory is its input, and a client cannot supply an enforcement the backend does not have.
Prerequisites
This capstone assumes the whole agent track. It is the only one that uses
.claude/skills/, so that part is taught here from scratch — everything else
below is assumed knowledge, not revision.
| Module | What you need from it |
|---|---|
| M07 — MCP | Both source trees and the emitted workspace are reached through MCP tools. You need create_sdk_mcp_server and the @tool decorator to be unremarkable. |
| M13 — Planning | Six ordered phases with real dependencies, and a gate between 9A and 9B that refuses to open early. |
| M14 — Multi-Agent | A coordinator delegating to eight specialists, each reading a different slice of two codebases in its own context. |
| M15B — Build Lab | The .claude/agents/ pattern and .claude/settings.json. This capstone adds .claude/skills/ on top, and assumes you already know what a subagent is. |
| M16 — Input Guardrails | PreToolUse denial through can_use_tool. Four of the five hooks here are denials. |
| M17 — Output Guardrails & HITL | The finalization gate always denies. If you have not met a human-approval gate before, that will read as a bug. |
| M18 — Evaluation | 242 offline tests and a 24-scenario eval harness. Several scenarios score a refusal rather than an answer. |
| M22B — Deploy | Local Docker first, then GCP and AWS. Only needed if you do the deployment section. |
Tooling. Python 3.10+ and an Anthropic API key. Node 18+ only for phase 9B. Docker is optional and only for the deployment section — every test and eval in this lab runs offline without it.
Why Behavioral Health Is Not Clinical With Different Codes
Four differences, and each one breaks an assumption the medical platform is built on. If you take nothing else from this capstone, take these.
1. The criteria are a ladder, not a yes/no
Medical prior auth asks: is this procedure medically necessary for this diagnosis? One question, one answer.
Behavioral health asks: at what intensity of care should this person be treated right now? The answer is a rung on the ASAMAmerican Society of Addiction Medicine. Its criteria place a person at one of several levels of care, from outpatient through medically managed inpatient, using six independently scored dimensions. ladder — 1.0 outpatient through 4.0 medically managed intensive inpatient — chosen from six independently scored dimensions.
So an engine that can only approve or deny the level that was requested is missing the domain. It has to be able to grant a different level than the one asked for, in either direction, and say why.
Dimension 4 is readiness to change, and a LOW score argues AGAINST residential placement.
Every other dimension reads "higher means more care". Dimension 4 inverts, because placing someone with no engagement into a residential setting historically produces an against-medical-advice discharge within 72 hours — consuming a bed, achieving nothing, and often making the next engagement harder.
Treat all six as severity indicators and you will get this backwards and never notice, because the answer is still a plausible level of care.
2. Authorization is a series, not an event
A medical case is decided once. A behavioral-health case is decided, and then reviewed again on a cadence set by the level of care, until the member is discharged or steps down. This is concurrent reviewThe recurring continued-stay review that runs for the life of an authorization. Every three days at ASAM 4.0, every seven at 3.5, every fourteen at partial hospitalization..
| ASAM level | What it is | Review cadence |
|---|---|---|
4.0 | Medically managed intensive inpatient | 3 days |
3.7 | Medically monitored intensive inpatient | 5 days |
3.5 | Clinically managed high-intensity residential | 7 days |
3.1 | Clinically managed low-intensity residential | 14 days |
2.5 | Partial hospitalization | 14 days |
2.1 | Intensive outpatient | 30 days |
1.0 | Outpatient | 90 days |
Three consequences for any system design:
- An approval is not terminal. It must schedule its own next review.
- A next-review date is a regulatory deadline, not a reminder. A residential authorization not re-reviewed inside its interval is out of compliance, whether or not anyone was told.
- The process model needs a timer-driven loop. A workflow that terminates after the first decision cannot express the domain at all.
Note also that the cadence follows the level, not the units approved. A fourteen-day approval at ASAM 3.5 still comes back for review in seven days. Systems get this wrong by deriving the next review from the authorization's end date, which is a reasonable-looking mistake that quietly halves the number of reviews.
3. Two privacy regimes, not one
HIPAA covers everything. 42 CFR Part 2The federal rule protecting records from federally assisted substance-use-disorder treatment programs. Disclosure requires a consent that NAMES the recipient, states a purpose and scope, and expires. additionally covers records from federally assisted substance-use-disorder treatment programs, and it is much stricter.
| HIPAA | 42 CFR Part 2 | |
|---|---|---|
| Disclosure for treatment, payment, operations | Permitted without authorization | Requires consent |
| Recipient | A class of recipients is fine | Must be named |
| Scope | "Minimum necessary" standard | Consent states scope explicitly |
| Duration | May be open-ended | Expires; revocable |
| Downstream recipients | Bound if a business associate | Bound by the redisclosure notice |
| Accounting of disclosures | Required for some | Required |
The practical summary: under HIPAA the question is "is this use appropriate?". Under Part 2 it is "is this exact recipient named in an unexpired, unrevoked consent that covers this purpose and this scope?" — a question a system can only answer if it stores consents that way and checks them at the point of disclosure.
A system can be fully HIPAA-compliant and violate Part 2 on every request. That is the usual failure, and it is almost always plumbing rather than policy. You will build it, watch it happen, and then fix it.
4. Parity is a design constraint
MHPAEAThe Mental Health Parity and Addiction Equity Act. A limitation applied to behavioral-health benefits may be no more restrictive than the comparable limitation on medical/surgical benefits — both as written and as applied. requires that a limitation applied to behavioral health be no more restrictive than the comparable limitation on medical/surgical care. The hard ones are non-quantitative treatment limitationsNQTLs. Process-level limits — review frequency, step therapy, criteria strictness, network standards — that parity puts in scope alongside numeric caps. A BH-only limitation with no med/surg analogue is an exposure.: review frequency, step-therapy requirements, criteria strictness, network standards.
You will find these in the legacy code as rules that look entirely reasonable in isolation. When you do, neither port them silently nor drop them silently. Porting carries the exposure forward; dropping changes outcomes for real members. Escalate.
One more, and it is load-bearing throughout this capstone:
A nurse reviewer may approve. A nurse may never deny. Only a physician may issue an adverse determinationA denial, or an approval at a level lower than the one requested. The regulated event in behavioral health, and the one that must trace to a published, applied criterion. — and for substance-use or psychiatric level of care, a same-specialty peer reviewer.
It is a separation of duties required by accreditation. It is also why a PENDED status exists at all: that is the state a case waits in for someone licensed to deny it. A system without PENDED has either auto-denials or no denials.
You will find this rule implemented four times, in four places, none of which is a permission system.
The Donor, and Its Holes
The modern platform describes itself, in its own README, as a "clean-room learning rebuild." That is an honest description and you should take it seriously. It is deliberately thin, and the thinness is invisible until you point a new domain at it.
Here is what the architecture-cartographer subagent finds when it opens the files rather than the documentation:
| What the platform has | Fine for medical prior auth because… | Fatal for behavioral health because… |
|---|---|---|
Two tables. Zero foreign keys. member_id VARCHAR(32), opaque, no member table | One case, one decision, nothing to relate it to | Concurrent review has nowhere to live, and the carve-outBehavioral health contracted to a separate vendor with its own network, criteria, claims platform and member identifiers. Explains why a BH system keys on an identifier the health plan does not recognise. has two member identifiers that are not interchangeable |
notes is validated with @IsOptional @IsString @Length(0,2000) and then silently discarded — not a column, not an entity field, not in either event payload | Nobody reads it | It is simultaneously the medical-necessity evidence and the Part 2 protected content. The caller gets a 201 and believes it landed |
A decision table with hitPolicy="FIRST", three rules, inputs of procedure code and requested units. No rule can output DENIED. One row is dead code | Denials are rare and handled by a person | The denial is the regulated event, and parity requires each one to trace to a published criterion |
| A one-shot process: start → decide → gateway → maybe review → notify → end. The manual-review task has no assignee and no candidate group | Correct: a medical case is decided once | No continued-stay loop — and the licensure rule disappears with the missing candidate group, while the diagram still looks complete |
No audit table. No createdBy, no updatedBy, no transition history. transitionTo() is unguarded | Deferred; it is on their backlog | Part 2 requires an accounting of disclosures, and there is nothing to build one from |
um.security.enabled=false by default; even enabled it is authentication-only — no roles, no scopes, no method security | Deferred | No way to scope a consent-limited disclosure, and no way to express the licensure rule |
| PHI in cleartext logs; event payloads as plain JSON on an unauthenticated broker; an Elasticsearch index; no TLS anywhere | Member identifiers only, in a teaching environment | Decomposition turns one leak into several, and this content is federally protected |
Zero tests. CI runs npm test --if-present, so their absence never fails the build | — | Nothing catches any of the above |
A single unrouted form. app.config.ts provides only provideHttpClient(); @angular/router is a declared dependency that was never wired. Three shared components are exported and never imported | It demonstrates the intake call | A UM client is multi-screen, session-based, role-gated and worklist-driven. None of that is demonstrated |
None of that is a defect for the slice it teaches. Every one is fatal here. Detecting that is the capstone.
And the platform team knows. Their own enhancement backlog — vendored into the lab as reference-umlite/BACKLOG.md, and readable by the gap-analyst through ref_read_backlog — lists guarded status transitions (#1), persisting decision rationale (#2), extended DMN criteria (#6), an appeals path (#7) and SLA turnaround timers (#8) as planned and unbuilt.
When your register agrees with their backlog, that is your strongest kind of finding: two independent readings reached the same conclusion. Which is why the gap-analyst reports agreements and disagreements as separate lists. A register that reports only agreements has been confirmed, not checked.
@angular/router is in package.json. Nothing calls provideRouter.
An architecture manifest that reports "routing: present" because the package is on the classpath is the single most damaging mistake available to the cartographer, because everyone downstream trusts the manifest instead of re-reading. The same trap exists for a declared Kafka topic with no producer and no consumer, and for an enum value that is never assigned.
Report what is wired. List declared-but-unused separately.
The one trait worth copying wholesale
Capability layering behind feature flags: EVENTS_ENABLED, OUTBOX_ENABLED, WORKFLOW_ENABLED, CACHE_ENABLED, SEARCH_ENABLED, REPLICA_ENABLED, SECURITY_ENABLED. Seven flags, each gating one capability, so the stack stays runnable with any subset enabled. It is the platform's best structural idea.
Mirror the idiom. But classify each flag before you do, and the test is one question:
If this were false in production for a week, what would the consequence be?
A slow page → a flag is fine.
An unlawful disclosure, an unlicensed determination, or a missing audit trail → it must not be a flag at all. A regulatory control that can be switched off in configuration is not a control; it is a default.
Note that the donor ships SECURITY_ENABLED=false. That is a defensible default for a teaching platform and an indefensible one here.
The Monolith: A Guided Tour of BHAuthTrack 4.2
Fifty-four files. Java 8, Spring MVC 4.3, JSP/JSTL, one WAR on Tomcat 8, Oracle 11g, Quartz, Log4j 1.x. Read five of them, in this order, and you will have met everything that makes this hard.
1. db/schema_changes.txt — read this before the schema
There is no migration tool. DDL is applied by hand in each environment by whoever is doing the release, and this file is the only record of what was applied and when. It says so itself:
Where this file and 01_schema.sql disagree, THIS FILE IS CLOSER TO PRODUCTION.
It records two production drifts, one prod hotfix never back-ported, and — the important one — BHA-1180, the carve-out identifier problem. It also names two tables the application reads that no release ever created, both owned by other teams, both load-bearing inputs to a clinical decision. An inventory built from the schema file alone misses both.
2. db/03_PKG_LOC_RULES.sql — the level-of-care engine
Not a rule set. A stateful first-match ladder: it accumulates into v_score across branches, and returns at the first branch that commits. Several branches fall through deliberately. The order is load-bearing.
-- BRANCH 7 -- THE OVERLAP. Read carefully.
--
-- Both of the next two conditions can be true at once. A case with
-- v_score = 10 and v_d1 = 3 -- reached by C-SSRSColumbia Suicide Severity Rating Scale, 0-5. Scores of 4 and 5 are active ideation with intent -- a threshold, not a gradient. 4 (+6) and dimension 1
-- of 3 (+4) -- satisfies the 3.7 test AND would satisfy the 3.5 test
-- below it. Because this is a first-commit ladder, it lands on 3.7 --
-- the MORE intensive level -- and the 3.5 branch never runs.
--
-- Flatten these into an unordered decision table and the answer depends
-- entirely on the hit policy you pick.
IF v_score >= 10 AND v_d1 >= 3 THEN
r.granted_loc := '3.7'; ... RETURN r;
END IF;
IF v_score >= 8 THEN
r.granted_loc := '3.5'; ... RETURN r;
END IF;There is a second engine. LocRulesService.java layers three more adjustments in Java after the PL/SQL has already committed to an outcome — so it can only downgrade or pend, never upgrade. Neither layer alone is the rule set. Convert one and three of the twelve golden cases come back wrong, plausibly.
3. service/AuthCaseService.java — the transaction
submitAndDecide() is @Transactional and performs five writes: the authorization, the assessments, the Part 2 consent, the initial level-of-care review, and an outbound queue row. One Oracle transaction. All of them or none.
The class comment explains why, and it is a requirement rather than an accident:
This started in 2011 as three separate services. They were merged in 2013 because the transaction boundaries kept producing orphaned consent rows when the JTA config drifted between environments.
An authorization from a Part 2 program that exists without its consent record is protected content held with no record of who the member agreed it could be shared with. Under this design that state is unrepresentable. Any redesign has to say what makes it unrepresentable instead.
The same method also logs the clinical narrative, on purpose, so the appeals team can reconstruct a challenged determination. That log rolls to a file that is backed up nightly to the share the reporting team reads from. On this system that is one sink.
4. service/AuthStatusService.java — the workflow that is not one
There is no process engine. There is a STATUS column and a switch, and reading that switch is how you recover the process model. The thing to notice:
case "APPROVED":
// Continued stay. An approved authorization is not finished; it comes
// back around on its cadence. This is the single biggest structural
// difference from medical prior auth, and it is expressed here as a
// status that loops.
auth.setStatus("IN_REVIEW");
break;It also branches on LEGACY_OVERRIDE, which is where this capstone puts its deliberate dead end. More on that below.
5. webapp/WEB-INF/jsp/decision.jsp — rules in a template
Three business rules and two derived values, implemented in a view. The file's own maintenance note says:
The role checks below are the ONLY thing standing between a nurse reviewer and the deny button on most deployments. AuthCaseService.issueDenial() re-checks, but that was added after the fact and there are two other call paths that do not go through it. Treat this file as security-relevant.
And, further down, two scriptlets computing the continued-stay countdown and the regulatory turnaround clock — the only implementation of either rule in the codebase. Reporting reimplemented both in Crystal and the two have disagreed since 2015.
BH_AUTH.LEGACY_OVERRIDE, added under ticket BHA-2291 in February 2013. The ticket body reads, in full: "per DM request".
No design note. No acceptance criteria. No test. It is handled in two places and set on roughly 400 live rows. Nobody currently at Bridgeway can say what it means, which determinations it was meant to cover, or who "DM" was.
This belongs in the manual-review queue, not in a decision table. A run that reports 100% automated coverage has guessed at it — and the cost of guessing wrong is a changed determination for a real person. The evaluation suite scores a refusal here, and scores a confident interpretation as zero however reasonable it sounds.
Domain Glossary
Read this once before the build guide. Every term appears in the fixtures.
| Term | Meaning |
|---|---|
| Carve-out | Behavioral health contracted to a separate vendor with its own network, criteria, claims platform and member identifiers. Explains why BH systems key on an identifier the health plan does not recognise |
| ASAM | American Society of Addiction Medicine. Levels 0.5–4.0, placed using six dimensions |
| The six dimensions | 1 withdrawal potential · 2 biomedical · 3 emotional/behavioral/cognitive · 4 readiness to change (inverts) · 5 relapse potential · 6 recovery environment |
| LOCUS / CALOCUS | Level of Care Utilization System — the psychiatric analogue of ASAM. CALOCUS is the child and adolescent version |
| Concurrent review | The recurring continued-stay review that runs for the life of an authorization |
| PHP | Partial hospitalization, ASAM 2.5. Day treatment; the member goes home at night |
| IOP | Intensive outpatient, ASAM 2.1 |
| C-SSRS | Columbia Suicide Severity Rating Scale, 0–5. 4 and 5 are active ideation with intent — a threshold, not a gradient |
| PHQ-9 / GAD-7 | Depression (0–27) and anxiety (0–21) severity instruments |
| 42 CFR Part 2 | Federal rule protecting records from federally assisted substance-use-disorder treatment programs. Consent must name the recipient |
| Redisclosure notice | The notice that must accompany a Part 2 disclosure, binding the recipient too |
| NQTL | Non-quantitative treatment limitation. A process-level limit — review frequency, step therapy, network standards — that parity puts in scope |
| Adverse determination | A denial, or an approval at a lower level than requested. The regulated event |
| Step-down | Moving to a less intensive level. Normal and expected; not a denial |
PENDED | The state a case waits in for someone licensed to decide it. A separation of duties encoded as a status |
| TAT | Turnaround time. Expedited 72 hours, standard 14 calendar days. Missing it can force an automatic approval depending on line of business |
| X12 278 | The EDI transaction for a health-care services review request. Carries no clinical narrative and no assessment |
The Vocabularies Collide, and One Collision Is Silent
Both systems model utilization management. Neither was written with the other in mind, so the vocabulary diverged — in two ways, and they carry opposite risks.
notes and CLINICAL_NARRATIVE. outbox_event and BH_AUTH_QUEUE.
The risk is missing the mapping: you build a duplicate concept, or drop a field because nothing on the other side looked like it.
This kind announces itself. The names differ, so somebody goes looking.
APPROVED is a status in both systems.
The risk is assuming the mapping. A 1:1 map compiles, passes review, looks obviously correct, and is wrong.
This kind is silent, and it is the one that matters.
Four of five statuses do not mean what they look like
The two enums overlap on five names. Read them side by side:
| Value | Clinical platform | Behavioral health | 1:1? |
|---|---|---|---|
SUBMITTED | Initial state | Initial state | yes |
APPROVED | Terminal | Re-enters review on its cadence. The switch loops it to IN_REVIEW | no |
IN_REVIEW | Declared and never assigned — a dead enum value | The busiest state in the system | no |
DENIED | Unreachable — no DMN rule can output it | Reachable, but only for an administrative fact: a terminated provider | no |
PENDED | A generic hold | A separation-of-duties control — the state a case waits in for someone licensed to deny it | no |
A renamed field fails loudly. You go looking for notes on the behavioral side, do not find it, and have to decide something.
APPROVED → APPROVED is a mapping nobody writes down, because it does not feel like a decision. It compiles. It passes review. Every status name still matches. And it has deleted concurrent review — the single biggest structural difference between the two domains — with nothing anywhere reporting a problem.
Making the unexamined pair impossible to record
So TermMapping.same_semantics has no default. You cannot construct a mapping without answering the question:
# solution/term_map.py
@dataclass
class TermMapping:
kind: str
clinical: str
behavioral: str
#: REQUIRED, NO DEFAULT. The whole point of this module.
#:
#: A name-identical pair recorded without answering this is the failure
#: mode the map exists to prevent, so it cannot be recorded at all.
same_semantics: bool
evidence: str
divergence: str = "" # required when same_semantics is False
action: str = "" # required when same_semantics is False
@property
def silent_trap(self) -> bool:
# Same name, different meaning. The dangerous quadrant.
return self.name_identical and not self.same_semantics// solution/term-map.ts
interface TermMapping {
kind: TermKind;
clinical: string;
behavioral: string;
/**
* REQUIRED, NO OPTIONAL MARKER. The whole point of this module.
*
* A name-identical pair recorded without answering this is the failure
* mode the map exists to prevent, so it cannot be recorded at all.
*/
sameSemantics: boolean;
evidence: string;
divergence?: string; // required when sameSemantics is false
action?: string; // required when sameSemantics is false
}
/** Same name, different meaning. The dangerous quadrant. */
function silentTrap(m: TermMapping): boolean {
return m.clinical.trim().toLowerCase() === m.behavioral.trim().toLowerCase()
&& !m.sameSemantics;
}And a divergence must state what the port has to do about it. A divergence with no action is a note, and notes do not survive a refactor.
Note also that name_identical compares case-insensitively. member_id and MEMBER_ID are the same name in two conventions, and a comparison that missed that would let the most dangerous pair in the schema through as a harmless rename — one identifier on the clinical side, two on the behavioral side, with 31% of the second one null.
The rows with no counterpart
Usually the most interesting entries, because each names a capability the target platform has never needed:
| Behavioral only | Why the clinical platform never needed it |
|---|---|
BH_LOC_REVIEW | A medical case is decided once. Concurrent review has no analogue |
BH_CONSENT | HIPAA has no named-recipient requirement, so the concept never arose |
BH_ASSESSMENT | The clinical engine decides from a procedure code, not a six-dimension assessment |
ROLE_MASK | Security is off by default and authentication-only when on — there is nothing to map roles onto |
EXPIRED | Only reachable in a domain that has a cadence to miss |
LEGACY_OVERRIDE | No counterpart, and no surviving explanation on the side that has it. Recorded as do not map |
And one missing from both: APPEALED. The platform team's backlog lists an appeals path as planned-and-unbuilt; the legacy system handles appeals entirely outside itself, in a shared mailbox and a spreadsheet. Neither side has it and both need it — a finding the gap analysis only reaches by reading the other team's backlog.
Two of the ten traps in this capstone — the discarded narrative and the carve-out identifier — depend entirely on noticing that the two vocabularies collide. Before the term map existed, both rode on downstream checks: you would catch them eventually, at synthesis, when something did not fit.
Check 10 catches them at excavation, which is where they are cheap.
Animation 1: One WAR Becomes Three Deployables, and a Transaction Is Severed
The seam mapThe record of where a monolith is cut, which transactional units each cut crosses, and what replaces the atomicity the cut breaks. is where a modernization is won or lost. Watch what happens to submitAndDecide() — five writes, one Oracle transaction — as the monolith is cut apart.
Classify the pairs before you draw the seam
The usual failure is deciding in advance that everything decomposes, then finding a mechanism for each pair. Do it the other way round. Not every pair in one transaction needs the same guarantee:
| Class | Meaning | Mechanism |
|---|---|---|
| Must be atomic | One existing without the other is unsafe or unlawful | Keep in one service, one transaction. Do not split |
| Eventual, guaranteed | Order matters, the gap is tolerable, but it must close | Transactional outbox + idempotent consumer |
| Eventual, best effort | A delay or a loss is operational, not a correctness problem | Ordinary publish |
| Independent | No relationship | Anywhere |
In this system, writes 1 and 3 — the authorization and its Part 2 consent — are class one. So bh-case-svc owns both, and the seam moves.
The reference answer's seam map contains an entry for authorization | consent marked rejected, with the reason:
"The intermediate state — protected treatment content held with no record of consent — is one the organisation cannot be in, even briefly, even with a compensation queued. A disclosure does not compensate: you cannot un-hold content you have already held."
An analysis that decomposes everything has not asked the question. The SeamMap class in the lab refuses to record a must-be-atomic seam as cut — you either move it or record the rejection.
What replaces the atomicity you do break
For every seam you do cut, five fields. All required, and the data structure enforces it:
# solution/seam_map.py
sm.add_seam(Seam(
name="case | notification",
left="bh-case-svc", right="bh-notify-svc",
crosses=["AuthCaseService.submitAndDecide"],
coupling=EVENTUAL_GUARANTEED,
replacement=AtomicityReplacement(
mechanism="transactional outbox in bh-case-svc + idempotent consumer "
"keyed on (auth_id, review_seq)",
window="under 60s at the configured relay interval; the legacy cron "
"polled every 5 minutes, so this is tighter than what it replaces",
observable="SELECT count(*) FROM outbox_event WHERE published_at IS NULL "
"AND created_at < now() - interval '5 minutes'",
compensation="relay retries with backoff; rows past 3 attempts move to a "
"human queue, because the legacy FAILED state was where rows "
"went to be forgotten",
alarm="that count > 0 for 5 consecutive minutes pages the on-call")))// solution/seam-map.ts
sm.addSeam({
name: 'case | notification',
left: 'bh-case-svc', right: 'bh-notify-svc',
crosses: ['AuthCaseService.submitAndDecide'],
coupling: Coupling.EventualGuaranteed,
replacement: {
mechanism: 'transactional outbox in bh-case-svc + idempotent consumer ' +
'keyed on (authId, reviewSeq)',
window: 'under 60s at the configured relay interval; the legacy cron ' +
'polled every 5 minutes, so this is tighter than what it replaces',
observable: `SELECT count(*) FROM outbox_event WHERE published_at IS NULL
AND created_at < now() - interval '5 minutes'`,
compensation: 'relay retries with backoff; rows past 3 attempts move to a ' +
'human queue, because the legacy FAILED state was where rows ' +
'went to be forgotten',
alarm: 'that count > 0 for 5 consecutive minutes pages the on-call',
},
});
// All five fields are required by the type, not by a convention. An eventual
// consistency with no observable and no alarm is the same as no guarantee,
// implemented with more moving parts.
interface AtomicityReplacement {
mechanism: string;
window: string;
observable: string;
compensation: string;
alarm: string;
}An eventual consistency with no observable and no alarm is the same as no guarantee, implemented with more moving parts. That is why AtomicityReplacement.problems() returns a finding for every missing field, and why Seam.validate() raises rather than warning.
Animation 2: Fifteen Capabilities Resolve Into Four Verdicts
This is the deliverable. Watch the distribution, and notice how uncomfortable it is.
If your register comes out mostly port-as-is, you have read the architecture and not the domain. The reference platform is correct for medical prior authorization and thin everywhere behavioral health is demanding — that asymmetry is the entire premise, and a comfortable register means it was not tested.
The lab checks this itself. GapRegister.acceptance_problems() reports a failure when more than 60% of entries are port-as-is, when there are fewer than four must-build-new, or when there are no must-not-port at all — and the coordinator halts the run rather than advancing to synthesis on a register that was not really written.
Naming the harm
must-not-port is the verdict people soften, and softening it is how a defect gets copied with a note attached. So the tool refuses it:
# solution/gap_register.py
if self.verdict == MUST_NOT_PORT and not self.harm.strip():
raise RegisterError(
f"{self.capability!r}: must-not-port requires a NAMED HARM. "
f"If you cannot name what goes wrong and for whom, the verdict "
f"is 'extend'.")// solution/gap-register.ts
if (entry.verdict === Verdict.MustNotPort && !entry.harm?.trim()) {
throw new RegisterError(
`${entry.capability}: must-not-port requires a NAMED HARM. ` +
`If you cannot name what goes wrong and for whom, the verdict is 'extend'.`);
}
// Note that this is a THROW, not a warning. A register that accepts a
// must-not-port with no harm is a register whose most important verdict
// means nothing -- and softening that verdict is exactly how a defect gets
// copied forward with a note attached.Not "logging member identifiers is not ideal", but:
This content is 42 CFR Part 2 protected. The monolith had ONE log sink; decomposition multiplies it into one per service plus a broker plus an index. Copying the idiom produces unlawful disclosure at several sinks instead of one — and nobody decided to make it worse: fan-out is simply what the architecture does with a field.
Animation 3: One Case, Two Engines, Two Answers
Golden case 500001. C-SSRS of 4 contributes +6; ASAM dimension 1 of 3 contributes +4. Score reaches 10, dimension 1 is 3, and both branch-7 conditions are true.
There is no neutral choice
| Hit policy | On the overlapping row |
|---|---|
FIRST | 3.7 — only if row order survives translation |
UNIQUE | A runtime error: two rules matched |
PRIORITY | Whichever output the priority list ranks higher |
COLLECT | Both, and the caller has to choose |
A naive conversion under FIRST does not diverge. It reproduces the ladder exactly, on all twelve golden cases, today — because the rows happen to be in ladder order.
Then someone sorts the rows by id. A change with no semantic intent whatsoever, that nothing in DMN, in the modeller, in code review or in CI prevents. Ten of twelve cases change answer.
So the lesson is not "the naive conversion diverges". It is: the naive conversion is correct by luck, and the luck is an invariant nobody is checking. The lab has a test for each half — test_first_policy_passes_today exists so that test_first_policy_breaks_when_rows_are_reordered means something.
The reference answer, and what it costs
UNIQUE, with every lower row tightened by the negation of the rows above it. The exclusions were always there — they were encoded as position. Now they are encoded as conditions, the table means the same thing whatever order the rows are in, and a future edit that reintroduces an overlap errors loudly instead of silently returning whichever row sits higher.
It costs something, and the cost is instructive. A decision-table cell constrains exactly one input, so this condition cannot be written as a cell at all:
score >= 8 and not (score >= 10 and dim1 >= 3)
# ^^^^^ two inputs, one exclusion -- not expressible as a cellThe honest fix is a named derived input, overlap_upper, and the row tests overlap_upper < 1. That is better than a workaround: naming the overlap puts it on the face of the table a clinician reads, instead of leaving it implied by which row sits higher.
dmn_writer.to_feel() raises rather than guessing at a cell it cannot express honestly. A guessed cell is a wrong clinical rule that looks finished.
Animation 4: The Narrative Clears HIPAA, Then Fans Out
The clinical narrative passes every check the medical platform makes. Watch where it goes.
Nobody decides to leak protected health information. Each of those four sinks is what a normal distributed architecture does with a field: you log the thing you are processing, you put the entity in the event, you index it so it is searchable, you audit before and after.
Decomposing a monolith multiplies the sinks. One application log becomes one per service, plus a broker, plus an index — so a leak that was contained becomes a leak that fans out, without anyone making it worse.
The count going up is the expected shape of this finding.
The consent scope nobody checks
A Part 2 consent states a scope. The common one is AUTH_DECISION_ONLY: the determination may be disclosed and the narrative may not. A notification payload carrying both is a violation under the most common consent on file.
So the emitted event is built from the consent scope, not from the entity:
// libs/events/envelope.ts
//
// The decision event. Carries the determination and NOT the justification:
// the common consent scope is AUTH_DECISION_ONLY, which permits one and not
// the other. Building the payload from the consent scope rather than from
// the entity is the whole difference.
export interface BhDecisioned {
authId: number;
planMemberId: string | null;
outcome: 'APPROVED' | 'PENDED' | 'DENIED';
grantedLoc: string | null;
reasonCode: string | null;
nextReviewDue: string | null;
}The scan checklist
validation.check_protected_content_leak() walks every emitted file. Two things it had to learn the hard way, both of which are in the lab's tests:
- An audit table's narrative column spans lines.
CREATE TABLE bh_audit_eventandold_narrative TEXTare on different lines, so a line-by-line scan misses the single sink that accumulates one protected copy per update, with no consent scope and no expiry. - A comment naming the field is not a leak. Warning the next developer not to log the narrative is exactly what you want a developer to do; flagging it teaches them to stop.
Animation 5: Two Planes — Knowledge and Control
The design decision this capstone exists to teach. Watch which layer each concern lands in.
The rule of thumb, and it decides every case you will meet:
Does it decide, branch, parallelize, or block? Then it is an agent.
Is it the same steps every time? Then it is a Skill.
Animation 6: A Role Guard Lifts Out of a Template
Phase 9B. Three nested JSTL conditionals in decision.jsp are the reviewer-licensure rule. Watch where each piece has to land.
It is the same rule, in the same layer, with a different spelling. It looks like migration and is not.
ViewRule.validate() rejects template-conditional, *ngIf, client-side, v-if and css as proposed homes, outright. The permitted homes are a route guard, a server-side check, a computed field on the response, a decision-table input, an API omission, or a workflow candidate group.
A guard is not the enforcement
A route guard stops a reviewer reaching a screen they cannot act on. That is a real improvement to the experience and it is not a control, because anyone can call the API directly.
So route_writer.preflight() refuses twice: once when an action gate is proposed for a route guard alone, and again when phase 9A supplied no server-side check for a rule that needs one. The client cannot supply an enforcement the backend does not have — reporting that is correct; guarding around it and calling the rule migrated is not.
Field visibility is a server concern
The legacy controller loads the clinical narrative unconditionally and the template hides it with <c:if test="${sessionScope.roleMask ge 2}">. The guard controls rendering, not retrieval — the content is in the response body either way, one developer-tools panel from view.
The fix is that the endpoint does not return the field. Which is why that rule's proposed home is API_OMISSION and the emitted component has no visibility conditional at all: there is nothing to hide.
decision.jsp carefully hides the narrative from intake coordinators. SearchController offers a full-text search across every narrative in the database, to any authenticated user, with no role check and no consent check — the link is hidden below nurse in the header, and the URL is /search?mode=clinical&q=.
The control on one screen is undone by its absence on another. Reimplementing that search on an index without adding the missing check reproduces the flaw at higher throughput, with a second copy of the protected content in a second datastore.
The screen inventory finds twenty rules across seven screens, eleven of which have no server-side enforcement at all. Those eleven are the ones that vanish in a mechanical port, and each needs a gap-register entry as well as a route.
Skill, Subagent, or Slash Command?
This capstone is the first in the course to use .claude/skills/, so the boundary deserves stating plainly. All three are markdown files in .claude/. They are not interchangeable.
| Skill | Subagent | Slash command | |
|---|---|---|---|
| Lives in | .claude/skills/<name>/SKILL.md | .claude/agents/<name>.md | .claude/commands/<name>.md |
| Loaded | On demand, by description match | When delegated to | When a person types it |
| Context | Shares the caller's | Its own window | Shares the caller's |
| Can bundle files | Yes — references/, scripts/ | No | No |
| Can restrict tools | Advisory | Yes, enforced | No |
| Can block a tool call | No | No — hooks do that | No |
| Use it for | Knowledge, and runbooks | Work needing isolation or a narrow tool grant | An entry point a person invokes |
Why the domain is a Skill and not eight prompts
Six of the eight subagents need the ASAM ladder, the Part 2 rules and the code sets. There are two ways to give it to them.
Paste it into six system prompts, and: it drifts the moment one is edited; it costs tokens on every turn of every subagent whether or not that turn needs it; and there is no single place to correct it when the clinical policy changes.
Or write it once as .claude/skills/behavioral-health-um/, with the entry point short and four bundled references that stay out of context until something needs them:
---
name: behavioral-health-um
description: Behavioral-health utilization management domain knowledge -- ASAM
levels and the six dimensions, LOCUS/CALOCUS, concurrent review cadence,
42 CFR Part 2, MHPAEA parity, BH code sets, and the reviewer-licensure rule.
Load this before reading, classifying, or generating anything in a
behavioral-health prior-authorization system.
---
| Reference | Load it when |
|---|---|
| `references/asam-levels.md` | Classifying a level of care, or writing a decision table |
| `references/part2-redisclosure.md` | Anything touching consent, disclosure, logging, eventing, search |
| `references/bh-code-sets.md` | Validating or emitting a service, diagnosis or instrument code |
| `references/parity-nqtl.md` | A rule applies to BH that may have no med/surg analogue |
`scripts/validate_bh_codes.py` checks a code against the sets in the third
reference. Run it rather than reasoning about whether `H0018` is real.That table is the mechanism. The entry point is a router; the bulk arrives only when needed.
- The ontology in every prompt. Drift plus token cost. This is the one the lab tests for —
test_skill_loading.pyfails if more than four ASAM levels appear inline in any agent file. - A Skill doing orchestration. A Skill cannot sequence phases, cannot isolate context, and cannot block a tool call. Writing "then delegate to the validator" in a Skill produces a suggestion, not a control.
- A slash command where a Skill belongs. A command is an entry point a person types. If the agent should reach for it on its own, mid-run, it is a Skill.
Recipes are Skills too
Two of the four Skills in this lab are not knowledge at all — they are runbooks: rules-to-dmn and decompose-transaction. Same steps every time, run once per rules block and once per transactional method. Each bundles a script, because a bundled script is a thing that gets run rather than a procedure that gets recalled:
python .claude/skills/rules-to-dmn/scripts/dmn_overlap.py \
--ir artifacts/rules_ir.json --report artifacts/overlap.mdThat checker enumerates every pair of committing rows and reports pairs that can both match, with a concrete witness. Do not reason about overlap by inspection — the pairs that matter are the ones whose conditions are on different variables and therefore look disjoint. And when it cannot model a condition it raises rather than reporting no overlap: a false clean is the one answer that checker must never give.
No PHI in Prompts, Ever
Taken verbatim from the platform organisation's own AI ground rules, and elevated here to a first-class constraint rather than a footnote — because this agent reads a system whose most valuable content is substance-use-disorder clinical narrative.
The question the lab is really teaching: how do you point an agent at a regulated codebase without feeding it regulated data?
Three layers
Every row in bhauthtrack/ is synthetic, generated from documented seed 20260822. Codes are real and correctly formatted — the rules would not be worth reading otherwise. The people are not.
This is the control that actually holds. Everything below is defence in depth.
A PreToolUse hook matching every tool, plus a result filter at the tool-server boundary. It detects by shape, not by keyword: a narrative does not announce itself, and matching on "alcohol" or "opioid" catches the obvious cases and misses everything a clinician wrote in a hurry.
Content from an allowlisted synthetic fixture passes, but budgeted — an agent reading the whole seed file otherwise accumulates a clinical record in its transcript one tool call at a time. Content from anywhere else is redacted and tagged, so the model knows something was withheld rather than concluding the field is empty.
PostToolUse on every call, one JSON line, credentials and narrative redacted before the write.
The risk here is not the agent doing something dangerous. It is the agent being told something it must not be told.
Once protected content is in the context window it is in the transcript, in the provider's logs, and in every summary that follows. There is no taking it back. A PreToolUse hook runs before the tool and therefore cannot see what it returns — so the guarantee lives at the boundary where the data actually appears, in filter_tool_result().
Shape detection, and its limit
# solution/hooks.py
#
# THE LIMIT OF THIS APPROACH, STATED PLAINLY: shape detection is defence in
# depth, not a proof. A narrative written without any of these words will
# pass, and no regex closes that gap. The control that actually holds is that
# every fixture in this lab is SYNTHETIC.
_CLINICAL_REGISTER = re.compile(
r"\b(member|patient|client|individual|resident|he|she|they)\b.{0,100}\b("
r"present(s|ed|ing)?|report(s|ed|ing)?|ideation|withdrawal|relapse|"
r"treatment|therapy|counsel(ling|ing)?|episode|referral|admission|"
r"engagement|symptom|dose|medication|prescrib|diagnos"
r")\b", re.I | re.S)
# Several sentences of prose, not code and not a column list.
#
# The trailing \s* rather than \s+ matters. With \s+ the LAST sentence of a
# narrative goes unmatched -- there is no whitespace after its full stop --
# so redaction leaves one clinical sentence standing. That was a real leak,
# found by running the gate over the seed fixture rather than over a mock.
_PROSE = re.compile(r"(?:[A-Z][^.!?\n]{25,}[.!?]\s*){2,}")// solution/hooks.ts
//
// THE LIMIT OF THIS APPROACH, STATED PLAINLY: shape detection is defence in
// depth, not a proof. A narrative written without any of these words will
// pass, and no regex closes that gap. The control that actually holds is that
// every fixture in this lab is SYNTHETIC.
const CLINICAL_REGISTER =
/\b(member|patient|client|individual|resident|he|she|they)\b[\s\S]{0,100}\b(present(s|ed|ing)?|report(s|ed|ing)?|ideation|withdrawal|relapse|treatment|therapy|counsel(ling|ing)?|episode|referral|admission|engagement|symptom|dose|medication|prescrib|diagnos)\b/i;
// Several sentences of prose, not code and not a column list.
//
// The trailing \s* rather than \s+ matters. With \s+ the LAST sentence of a
// narrative goes unmatched -- there is no whitespace after its full stop --
// so redaction leaves one clinical sentence standing. That was a real leak,
// found by running the gate over the seed fixture rather than over a mock.
const PROSE = /(?:[A-Z][^.!?\n]{25,}[.!?]\s*){2,}/;tests/test_no_phi_in_prompt.py plants a realistic narrative and asserts the gate fires; it also walks every non-allowlisted file in the legacy tree and asserts that none of them would reach the model carrying narrative-shaped content. That is the one check in this capstone that must report zero.
Environment Setup
No database, no broker, no cloud account. This agent reads a repository and writes a repository, and everything under tests/ runs without an API key.
| Requirement | Version | Why |
|---|---|---|
| Python | 3.10+ | The solution uses X | None union syntax throughout. Developed on 3.11; 3.12 in the container |
claude-agent-sdk | >= 0.2.0 | Tier 3. query, tool, create_sdk_mcp_server, HookMatcher, PermissionResultDeny |
pytest | >= 8.0 | All 242 tests, offline |
| Node.js | 18+ (phase 9B only) | Only to npm install the vendored donor so its Angular workspace builds. Not needed for 9A |
| Docker | optional | Tier-1 deployment. Rancher Desktop works — Docker Desktop is not required |
Windows, macOS and Linux all work; there is no WSL requirement. The only platform-specific line is the venv activation path, shown below. Nothing here needs a paid subscription beyond an Anthropic API key, and the tests do not need even that.
cd labs/capstone-9-bh-um-modernization
python -m venv .venv
. .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
cp solution/.env.example solution/.env # add your ANTHROPIC_API_KEY
# The vendored donor ships without node_modules, and Angular was never
# installed in the upstream checkout either -- no @angular packages, no `ng`
# binary. Required before anything in phase 9B will build.
cd reference-umlite && npm install && cd ..
# Everything here runs offline.
pytest tests/ -v
python solution/evaluation/test_suite.py --self-check242 passed. To run the same suite against your own work instead of the reference, point BH_SOLUTION_DIR at the starter tree:
BH_SOLUTION_DIR=starter pytest tests/ -qOn a fresh starter/ that reports 95 passed, 147 failing, and every one of the failures is a NotImplementedError raised by a TODO you have not filled in yet — never an import error or a missing fixture. The 95 that already pass are the ones asserting on the two source trees, which you never modify. That number is your progress bar: it should only go up.
bhauthtrack/ needs no build. It is read, never run — there is no Oracle instance in this lab and no Tomcat. The SQL is a specification, not a database.
File Structure
Two trees you read, one you write, and the agent that does it.
labs/capstone-9-bh-um-modernization/
├── bhauthtrack/ # DOMAIN DONOR. Read-only, enforced in code.
│ ├── db/ # schema, the golden set, PKG_LOC_RULES, the drift log
│ └── src/main/ # controller service dao domain batch security ws + 9 JSPs
├── reference-umlite/ # ARCHITECTURE DONOR. Read-only, enforced in code.
│ ├── camunda/ # prior-auth.bpmn, pa-decision.dmn
│ ├── BACKLOG.md # the platform team's own planned-and-unbuilt list
│ └── VENDORED.md # what the donor does and does not have
├── spec/agent-spec.md # the 12-section contract
├── solution/ # THE AGENT
│ ├── .claude/
│ │ ├── skills/ # behavioral-health-um, umlite-architecture,
│ │ │ # rules-to-dmn, decompose-transaction
│ │ ├── agents/ # the eight specialists
│ │ ├── commands/ # modernize, validate, report
│ │ └── settings.json # five hooks, six matcher groups
│ ├── rules_ir.py # BOTH engines + the divergence diff
│ ├── gap_register.py # the deliverable, with its constraints in code
│ ├── seam_map.py # and its refusals
│ ├── hooks.py hooks_cli.py # five guards, one implementation, two entry points
│ ├── tools_reference.py # 6 read-only tools
│ ├── tools_legacy.py # 7 read-only tools
│ ├── tools_emit.py # 5 tools that produce output
│ ├── dmn_writer.py # refuses to emit a table that would be wrong
│ ├── bpmn_writer.py # refuses a process that cannot express the domain
│ ├── screen_inventory.py # phase 9B
│ ├── route_writer.py # phase 9B
│ ├── validation.py # the nine parity checks, as pure functions
│ ├── coordinator.py # sequences; has NO file tools
│ └── evaluation/ # golden cases, reference IR, 22 scenarios
├── starter/ # same tree, 31 numbered TODOs. Generated.
├── tests/ # 219 tests, all offline
├── expected_output/ # the reference run. Generated, not hand-written.
├── appendix/manual-loop.py # the only messages.create() in the capstone
└── deploy/{local,gcp,aws}/Note what solution/ and bh-um-lite/ are not: the agent's own subagents and skills are not part of its output. An agent that emits its own configuration into the workspace it is modernizing has confused the tool with the product, and confine_writes denies it.
Phase 9A: Backend and Workflow
Ten to twelve hours. Thirty-three numbered TODOs live in starter/; list them with grep -rn "TODO [0-9]" starter/. The order matters — TODOs 1–6 are the two rule engines, because until both engines run you cannot tell a correct conversion from a lucky one, and everything after depends on being able to tell.
What & why. evaluate_legacy() is a faithful Python transcription of PKG_LOC_RULES.EVAL_LOC plus LocRulesService. It exists so the divergence diff can run in CI without an Oracle instance — and, more usefully, so the classification of each branch is visible in code a student reads next to the original.
File. solution/rules_ir.py
The classification that matters. Branch 3's dim1 >= 4 arm commits and returns. Its dim1 == 3 arm accumulates and falls through. One source branch, two kinds. Getting this wrong is the single most common conversion error, and it produces answers that are wrong and plausible.
Run. pytest tests/test_rules_hit_policy.py -v
Expected. 14 passed, including test_reference_conversion_matches_the_ladder_exactly across all twelve golden cases.
You now have a reference implementation of the legacy rules that runs offline. Every later phase is measurable against it — and test_plsql_alone_gets_three_cases_wrong proves, concretely, that skipping the Java layer breaks cases 500002, 500008 and 500012.
Anticipated errors.
- All twelve cases diverge from the seed's stated outcomes. You applied the Java layer inside the ladder rather than after it. It runs on the committed decision, so it can only downgrade or pend.
- Case 500007 comes out at 3.5 instead of 2.5. Dimension 4 inverts. A low readiness score subtracts.
- Case 500010 approves instead of pending. The EDI case has all six dimensions at zero — including dimension 4, which fires the readiness penalty. Every EDI-submitted residential request pends.
What & why. evaluate_ir() does what a DMN engine does: match rows, apply a hit policy. It is the second half of the divergence diff.
File. solution/rules_ir.py
The one to get right. UNIQUE must raise when more than one row matches, and an unstated policy must raise too — DMN defaults to UNIQUE, so silence is a production error waiting for the first case that matches twice.
Run. python solution/evaluation/test_suite.py --self-check
Expected. Scenarios 1–3 pass: the overlap is declared with a witness, the policy is justified, and the accumulating branches are not rows.
Run diff_engines with a naive FIRST table and you get zero divergences. Sort the rows by id and you get ten. That is the whole lesson about hit policy, and you can now reproduce it on demand.
Anticipated errors.
UNIQUEsilently returns the first match. It must raise. The error is the table telling you the ladder’s ordering carried information it does not — swallowing it converts a loud failure into a wrong determination.- A table with no
hit_policyevaluates anyway. DMN defaults toUNIQUE, so silence is a production error waiting for the first case that matches twice. Raise on the missing field. - The score comes out wrong on every case. You applied the accumulating branches in the wrong order, or emitted one as a row. Order is load-bearing, and an accumulating branch is an input, not a decision.
What & why. The gap register is the deliverable, and its constraints belong in code. A prompt saying "must-not-port requires a named harm" is a request; a tool that returns an error is a rule.
Files. solution/gap_register.py, solution/seam_map.py
Run. pytest tests/test_flag_classification.py tests/test_consent_atomicity.py tests/test_term_mapping.py -v
Expected. 44 passed, including the three seam refusals and the term map’s
You moved three rules out of prose and into code. “must-not-port requires a named harm” is now something the tool returns an error for, not something a reviewer has to remember — and Seam.validate() will not let you cut a seam that silently loses a guarantee.
must-be-atomic pair someone tried to cut.
Anticipated errors.
- Your register accepts a
must-not-portwith no harm. Then the register's most important verdict means nothing. Raise. Seam.validate()passes amust-be-atomicseam. That seam cannot be cut. Either move it or recordrejected_because— recording a rejection is a result.
What & why. Four can_use_tool denials plus an audit hook. They run before the tool, so the dangerous call never happens — a PostToolUse hook would be an excellent post-mortem and a bad guardrail.
File. solution/hooks.py
Run. pytest tests/test_no_phi_in_prompt.py tests/test_hooks_readonly.py tests/test_hitl_gate.py -v
Expected. 50 passed. The one that matters is test_the_gate_reports_zero_against_the_real_fixtures.
That last test walks every non-allowlisted file in the legacy tree and asserts none of them would reach the model carrying narrative-shaped content. It is the one check in this capstone that must report zero — and it is now proving that on the real fixtures rather than on a mock.
Anticipated errors.
- Redaction leaves the last sentence of a narrative standing. Your prose pattern requires whitespace after the final full stop. One clinical sentence is a disclosure.
- The audit log will not parse. A greedy
\S+afterpassword=ate the closing quote and brace. Redact values, not serialized JSON. - The gate flags a Java file full of code. You are matching keywords rather than shape. Require prose and clinical register.
What & why. Five local tools and the two Camunda writers. Both writers refuse rather than emitting with a warning — a file that looks finished is worse, because the next person reads the file and not the warning.
Files. tools_emit.py, dmn_writer.py, bpmn_writer.py
Run. pytest tests/test_dmn_can_deny.py tests/test_concurrent_review_loop.py -v
Expected. 28 passed, and D.render(reference_ir) produces well-formed DMN with hitPolicy="UNIQUE", nine rules and a reachable DENIED.
Both writers now refuse. Feed bpmn_writer a one-shot process and it names the four things missing; feed dmn_writer a table with an unresolved overlap and it will not emit. A refusal that explains itself is worth more than a file with a warning at the top, because the next person reads the file.
Anticipated errors.
- The generated XML will not parse.
--cannot appear inside an XML comment, and your hit-policy justification is prose written for humans who use double dashes freely. Sanitise before interpolating. to_feel()produces a cell for a two-input condition. It should raise. A guessed cell is a wrong clinical rule that looks finished.
What & why. Ten checks, as pure functions, so they are testable without an agent run. A check nobody has ever seen fire is not a check.
File. solution/validation.py
Run. pytest tests/test_part2_leak.py tests/test_narrative_roundtrip.py -v
Expected. 17 passed, each one planting a specific flaw and asserting the check finds it.
Every check has now been seen to fire. That is the bar: a check nobody has watched catch its own flaw is a check you are trusting on faith, and these are the ten things standing between a plausible port and a correct one.
Four checks are the ones a naive port trips. A clean result from one of them is not a problem by itself — a good port comes back clean on all four, and a check that could never pass is a check people learn to ignore.
Clean is suspicious when the check could not have fired: it scanned nothing, or its inputs cannot exercise what it is for. Both are measured — scanned and could_have_fired — rather than assumed.
Anticipated errors.
- The leak scan misses an audit table’s narrative column. Two reasons, and you probably have both:
narrativedoes not match insideold_narrativebecause the underscore is a word character, and the table name sits on a different line from the column, so a line-by-line scan cannot see the pair. - Your leak scan flags a comment. Warning the next developer not to log the narrative is exactly what you want them to do. Skip comment lines, or the only way to pass is to stop explaining the mistake.
- The consent check passes on a schema with no enforcement. You checked current state. Ask instead whether anything prevents the bad state — a foreign key, a NOT NULL, a constraint. Clean today and reachable tomorrow is not the same as safe.
What & why. Everything above is a part. This is the first time the coordinator sequences all six phases against both source trees, with the hooks live.
Run. cd solution && python coordinator.py --phase 9a
Expected. Six phases, then a denial from finalize_modernization carrying the gap register, the parity summary and the manual-review queue.
The denial is the successful outcome. The agent does not get to decide that its own work is ready — and it cannot, because the approval flag is read from the environment and there is no code path by which the agent writes it.
Anticipated errors.
ANTHROPIC_API_KEY is not set -- no phase can run.Every phase calls the model, so the coordinator checks for the key before it starts rather than surfacing a transport traceback five frames deep. Either export it, or put it insolution/.env—config.pyreads that file on import, and an exported variable always wins over a stale one in the file.- The run halts after phase 4 saying the register does not meet acceptance. Working as intended. A register that is mostly
port-as-ismeans the architecture was read and the domain was not, and the coordinator checks that itself rather than believing the phase’s own report. - A phase reports success but wrote nothing. Also caught —
check_gates()looks at the artifact rather than the summary. If it did not catch yours, that gate is what needs the fix. - The run is far more expensive than you expected. Check the token budget in
config.pyand the circuit breaker. Three consecutive failures in one phase should halt; a phase retrying forever is the failure mode the breaker exists for.
Phase 9B: Frontend
Four to six hours, TODOs 28–31, and it is gated on 9A being green. The coordinator refuses to start it otherwise:
# solution/coordinator.py
elif args.phase == "9b":
# 9B is gated on 9A. Run it against a red 9A and the client ends up
# guarding around enforcement the backend does not have -- which looks
# like the rule was migrated and is not.
missing = [p for p in config.PHASES_9A if not session.is_complete(p)]
if missing:
print(f"9B is gated on 9A being green. Not complete: {', '.join(missing)}")
return 1What & why. Seven screens, twenty rules, eleven with no server-side enforcement. Detection is regex; judgement is the agent's — finding <c:if test="${sessionScope.roleMask ge 4}"> is a job for a pattern, and deciding it means "only a physician may deny" is not.
File. solution/screen_inventory.py
Run. pytest tests/test_view_rules_relocated.py -v
Expected. 24 passed, including the seven parametrised cases proving a template is refused as a relocation.
You now have a data structure that cannot represent the mistake. *ngIf, template-conditional, client-side and v-if are all rejected as proposed homes — so a rule cannot be recorded as relocated when it has only changed template languages.
Anticipated errors.
- A screen contributes no rules. Read it again. Every one of the seven has at least one, and
consentAdmin.jspis the easiest to miss because its role check is in the controller rather than the markup — two conventions coexist in this codebase. - You record a rule with
server_side_equivalentleft blank. Refused, deliberately. “NONE” is a finding and has to be said out loud; eleven of the twenty rules are NONE.
What & why. Routes, a guard, environment config, and two components. The reference platform contributes an equivalent for exactly one of seven screens.
File. solution/route_writer.py
Run. pytest tests/test_screen_coverage.py -v then python coordinator.py --phase 9b
Expected. 15 passed; check 8 reports zero over seven client files.
Seven screens, seven reachable routes, and every rule that used to live in a template now in a guard, a service check, a computed field, an API omission or a workflow candidate group. Run python coordinator.py --phase all and both phases go end to end — and still stop at the gate.
Anticipated errors.
- Your guard compares a number.
roleMask >= 4is the approximation JSTL was forced into because it has no bitwise operator — and it is the permissive side. Mask 33 passes it and failshasRole(MD). Test named roles. - Every screen reports as routed however you break the routes file. You are matching a substring;
memberappears inmemberLastNamein half the components. Match apath:declaration. - The check flags your own environment file. It warns about the donor's hardcoded URL by quoting it. Skip comment lines — otherwise the only way to pass is to stop explaining the mistake.
Track 2: Build It Twice, Then Diff
The brief asked a question you should now be able to answer for yourself: migrate the repo, or generate a new one? Do both and compare.
# Track 1 -- modernize. Reads both trees.
cd solution && python coordinator.py --phase all
# Track 2 -- generate. Reads the spec only.
/generate-from-spec spec/agent-spec.md # writes to generated/
diff -r bh-um-lite/ generated/| Track | Inherits | Misses |
|---|---|---|
| 1 — Modernize reads the donor and the monolith | Architecture fidelity, the platform's conventions, the flag idiom | Silently inherits the donor's holes — traps 2, 3, 4, 5 and 7 |
| 2 — Generate reads the spec | Clean intent, no clinical bias, every requirement stated once | No institutional knowledge — the branch-7 overlap rows, the two member identifiers, the 2013 merge comment explaining the transaction |
Porting carries architecture and its blind spots. Generating carries intent but not institutional knowledge.
The production answer is neither: it is port-then-spec-review. Run track 1, then read the spec against what it produced and ask what the spec knows that the port forgot — and what the port knows that nobody thought to write down.
That second question is where the value is. Nobody would have written "the transaction must be atomic because the JTA config drifted between environments in 2013" into a specification. It is only in the code, in a comment, because someone lived it.
Guardrails and the Human Gate
Five hooks, wired as six matcher groups in .claude/settings.json. Four are can_use_tool denials — they run before the tool, so the call never happens.
| Hook | Matcher | Does |
|---|---|---|
protected_content_gate | .* | Denies narrative-shaped content in any tool input; the result filter redacts on the way back |
enforce_reference_readonly | mcp__reference_src__.* | Denies path traversal out of the donor tree |
enforce_legacy_readonly | mcp__legacy_src__.* | Same, for the monolith |
confine_writes | write_artifact | Denies any path outside bh-um-lite/ |
hitl_finalization_gate | finalize_modernization | Always denies without human approval |
audit_log | .* (PostToolUse) | One JSON line per call, redacted |
The protected-content gate matches every tool deliberately. Scoping it to the legacy server would miss content arriving by any other path — a file read, a shell command, a tool added next month.
The agent cannot approve its own work
finalize_modernization denies unless BH_FINALIZATION_APPROVED is set. The agent reads that variable and has no code path by which it writes one. That asymmetry is the entire gate; the briefing it returns is presentation.
FINALIZATION REQUIRES HUMAN APPROVAL.
GAP REGISTER: port-as-is 2, extend 4, must-build-new 6, must-not-port 3
MUST-NOT-PORT cleartext PHI in logs, events and search
harm: decomposition multiplies one log sink into several...
MUST-NOT-PORT consent enforcement as a feature flag
harm: a week of CONSENT_ENABLED=false is unlawful disclosure...
must-build-new concurrent review
must-build-new 42 CFR Part 2 consent model
...
PARITY: READY FOR REVIEW
[1] rules divergence: 0
[2] protected-content leak: 0
...
QUEUED FOR HUMAN DECISION: 5
BH_AUTH.LEGACY_OVERRIDE: ticket body reads, in full, "per DM request"
LocRulesService adjustment B: the 2016 parity note, never actioned
...
A person must read artifacts/modernization_report.html and re-run:
python coordinator.py --phase finalize --approveThe denial is the successful end of the run. The briefing is assembled from the artifacts, not from the agent's summary of them — the agent's account of its own work is the thing under review.
In the cloud tiers, BH_FINALIZATION_APPROVED appears in no manifest: not the Cloud Run env vars, not the ECS task definition. The moment it lives in YAML it is on by default, forever, for every future run, and the gate is gone with nothing failing to signal it.
Approval is a separate job (gcp/approve-job.yaml) or an override someone types (aws/finalize-override.json) — which CloudTrail records and an environment variable does not. And the agent's own IAM role must not carry permission to invoke it.
Validation and Evals
Ten checks, written as pure functions so they are testable without an agent run. A check nobody has ever seen fire is not a check — every one has a test that plants the flaw and asserts the check finds it.
| # | Check | Catches |
|---|---|---|
| 1 | Rules divergence | The hit-policy artefact at the ASAM 3.5/3.7 boundary |
| 2 | Protected-content leak | Narrative in a log, event, index, audit column or error path |
| 3 | Narrative round-trip | Validated-then-discarded, asserted on the column |
| 4 | Consent atomicity | State and whether anything enforces it |
| 5 | Workflow | No loop, no timer, no escalation, an unassigned task |
| 6 | Decision table | Unreachable denial, no diagnosis input, unstated policy |
| 7 | Identity | One opaque member id where there should be two |
| 8 | Screen coverage (9B) | A screen with no route; a rule still in a template |
| 9 | Feature-flag classification | A regulatory control behind a flag |
| 10 | Term mapping | A donor status unaccounted for; a silent name collision |
Checks 1–4 are the four a naive port trips. The obvious design is to treat a clean result as blocking — and it is wrong, because then the reference answer can never pass. A check that could never pass is a check people learn to ignore.
What matters is whether the check could have fired. Every check reports what it scanned, and a clean result is flagged suspicious only when it scanned nothing, or when could_have_fired is false — a case set with no case at the overlap boundary, an empty emitted tree.
Both are measured rather than assumed.
Twenty-four scenarios
The tests cover mechanics. The evaluation suite scores judgement: did the run notice the overlap, did it refuse to guess at the undocumented flag, did it name the harm, did it flag the silent term collisions.
python solution/evaluation/test_suite.py --phase 9a # >= 20 / 22
python solution/evaluation/test_suite.py # >= 22 / 24Several scenarios score a refusal. Scenario 4 — REFUSES to interpret LEGACY_OVERRIDE — scores zero for a confident interpretation however reasonable it sounds, because nobody at Bridgeway can check the answer and the cost of being wrong is a changed determination for a real person.
Deployment
Three tiers. Tier 1 is the lab and needs only Docker — there is no database and no broker, because this agent reads a repository and writes a repository.
The image is python:3.12-slim plus this lab and its two dependencies. There is
no database driver, no Oracle client and no JDK — the agent parses SQL and XML with the
standard library — so this stays a small Python image rather than a multi-gigabyte one.
Check what you actually got with docker image ls after the build; it is worth
knowing the number for your own machine rather than trusting a number in a course page.
The official Python images are multi-arch, so Apple Silicon runs this natively — no
emulation, no --platform flag. Rancher Desktop, Colima and Podman all work;
Docker Desktop is not required.
You can skip this section entirely. Docker is for the deployment story only. Every test, every eval scenario and both coordinator phases run on the host with nothing but Python and an API key — that is what the previous nine steps did.
cp solution/.env.example solution/.env # add your ANTHROPIC_API_KEY
docker compose up --build
# Read artifacts/modernization_report.html, then:
docker compose run --rm agent python coordinator.py --phase finalize --approveSix phases run for minutes to hours and then finish. That is a job, not a request handler — behind an HTTP endpoint you fight a 60-minute Cloud Run ceiling for no benefit. Cloud Run jobs and ECS tasks have no such limit.
Most agent deployments worry about credentials and network reachability. Those matter here too. But the thing that shapes every decision is "no PHI in prompts, ever" — and it gets harder in the cloud:
| Local | Cloud | |
|---|---|---|
| Where a redaction miss ends up | one container's stdout | Cloud Logging, and whatever is subscribed to it |
| Who can read the audit log | you | anyone with project log-viewer |
| What a crash dump becomes | a terminal | a retained entry, on a policy someone else set |
So: the audit log goes to object storage rather than stdout; PHI_ALLOWLIST names only synthetic fixtures; log retention is set deliberately, because a never-expire default is a never-expire retention on whatever the redaction missed.
Docker Desktop is not required. Rancher Desktop with the dockerd runtime works unchanged — both source mounts are :ro, which is second-layer enforcement behind the hooks, and a guardrail that exists in exactly one place is one refactor from gone.
HIPAA and 42 CFR Part 2
The Part 2 and MHPAEA parity behaviour modelled in this lab is a simplified teaching version — enough to make the architectural point, not enough to build a compliance programme on. It is not legal advice, and a real implementation needs counsel.
What it is good for: recognising the shape of the problem. The failures modelled here — a field that fans out to four sinks, a consent scope nothing checks, a revocation with no register of what went out under it — are real failure modes, and they are architectural rather than legal.
The checklist for any component you generate
- Does any log statement interpolate the clinical free-text field? Check string concatenation and structured-logging fields.
- Does any event payload carry it? Check every event, not just the obvious one.
- Is it mapped into a search index? An index is a second copy with its own retention.
- Does the audit trail store it? A before/after copy on every update accumulates copies with no expiry and no consent scope.
- Is the transport authenticated and encrypted? A plaintext broker on an internal network is still a disclosure to whoever can read the topic.
- Is there a free-text search over it, and does it check a role and a consent? A careful guard on a detail screen is undone by an unguarded search over the same field.
- Does an error path leak it — an exception message, a stack trace, a request body echoed into a log?
When to escalate rather than decide
Four things in this lab are questions for a compliance officer, not an architect. The agent queues them and stops:
- The Part 2 program flag was backfilled from a spreadsheet in 2014 and its accuracy has never been audited — yet it gates the entire regime.
- Two independent code paths fabricate a consent on the submitter's behalf. Most requests arrive by one of them. Who consents when a machine submits?
- The audit table holds protected content because the appeals team asked for it in 2012 and privacy never reviewed it.
- A frequency-based pend with no med/surg analogue, flagged by compliance in 2016 and never actioned.
That last one is worth dwelling on. An unactioned compliance note is evidence — it means someone already reached this conclusion and the organisation lost track of it. That is a far stronger signal than one an agent inferred, and it belongs in the register with the note quoted verbatim and its date.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| All twelve golden cases diverge from the seed's stated outcomes | The Java layer is applied inside the ladder rather than after it | It runs on the committed decision, so it can only downgrade or pend |
| Case 500007 comes out 3.5 instead of 2.5 | Dimension 4 treated as a severity indicator | It inverts. A low readiness score subtracts |
| Zero divergences and you have not tightened anything | You used FIRST and the rows happen to be in ladder order | Correct today, by luck. Sort the rows and watch ten cases change |
| The generated DMN will not parse | -- inside an XML comment, from the hit-policy justification | Sanitise prose before interpolating it into a comment |
to_feel() raises on a tightened row | A cross-input exclusion cannot be one cell | Working as intended. Add a named derived input |
| Redaction leaves one clinical sentence | The prose pattern requires whitespace after the final full stop | \s*, not \s+. One sentence is a disclosure |
| The audit log will not parse | A greedy \S+ after password= ate the closing quote | Redact values, not serialized JSON |
| The leak scan misses an audit table's narrative column | \bnarrative\b does not match inside old_narrative | The underscore is a word character. And the table name is on another line |
| The run halts after phase 4 | The register does not meet acceptance | Working as intended. A mostly-port-as-is register means the domain was not tested |
| Phase 9B refuses to start | 9A is not green | Also intended. A client cannot supply an enforcement the backend lacks |
| Every screen reports as routed however you break the routes file | Substring matching — member appears in memberLastName | Match a path: declaration |
The flag check cannot see CONSENT_ENABLED | A leading [A-Z] consumed the first character | Make the prefix optional. The plainest spelling was the blind spot |
📂 Get the files: labs/capstone-9-bh-um-modernization on GitHub — or clone the course once: git clone https://github.com/varasrinivas/agenticai-course.git
Going Further [ALL OPTIONAL]
None of these is required. Each is a real piece of work that the lab deliberately left open.
- The appeals path. The platform team's backlog item #7, and the one gap our analysis missed. Behavioral-health denials are appealed at least as often as medical ones; the legacy system handles them in a shared mailbox and a spreadsheet. Design the sub-process, then ask what it does to the turnaround clock.
- LOCUS/CALOCUS alongside ASAM. The lab models substance-use placement. Psychiatric placement uses a different framework, chosen by diagnosis rather than by member. Add a second rules IR and a router, and watch the hit-policy question reappear one level up.
- Make the parity analysis a first-class artifact. Right now the three NQTL findings go to the manual-review queue. Produce the comparative analysis document a plan would actually need to have on file.
- Run the agent against the OTHER donor. Point it at a platform you have and see how much of the gap register is really about behavioral health versus about the specific thinness of this donor. That is the honest test of whether the analysis generalises.
- Idempotency for the EDI path. The X12 278 importer has no idempotency key and a duplicate guard that drops legitimate same-day requests. Design a real one — and notice that the transaction carries no identifier suitable for the job.
- A disclosure-accounting UI. The lab builds the
bh_disclosuretable. Nobody can read it. What does a Part 2 accounting of disclosures look like to a member who asks for one?
What You Built
A coordinator and eight subagents that read a 2011 monolith and a modern platform neither of them wrote, and produced a third repository plus an argument about it. Concretely:
| You built | And the hard part was |
|---|---|
| A rules IR with a justified hit policy | Discovering that the naive FIRST table is correct by luck — zero divergences today, ten after sorting rows by id. You made the table order-independent instead of trusting the golden set. |
| A gap register with named harms | A must-not-port verdict that cannot be filed without naming who gets hurt. If you cannot name it, the verdict was always extend. |
| A term map | Four of five status names survive the port spelled identically and meaning something different. Nothing downstream would ever have flagged it. |
| A seam map that refuses seams | Deciding that the auth/consent pair does not get a saga — because what is true during the window is unlawful, and no compensation un-holds a disclosure. |
| Five hooks, four of them denials | Pointing an agent at a regulated codebase without feeding it regulated data. |
| A gate that always says no | Building the one control your agent cannot talk its way through. |
Every other capstone in this course asks an agent to do something. This one asks it to report what it could not do — and then makes that report the deliverable. A migration agent that comes back saying "100% automated, no issues" has not finished the job; it has finished looking. The register, the queue and the refusals are not the exercise’s failure modes. They are its output.
You also used .claude/skills/ for the first time in this course, and drew the line
this capstone exists to teach: Skills carry knowledge and recipes; agents carry control
flow and safety. Six subagents shared one behavioral-health ontology instead of six
drifting copies of it.
Knowledge Check
Q1: A conversion flattens the level-of-care ladder into a DMN table with hitPolicy="FIRST", one row per source branch, conditions copied verbatim. All twelve golden cases match the legacy engine exactly. What is wrong?
Q2: Both systems have a status called APPROVED. The synthesizer maps it 1:1. What breaks, and when do you find out?
Q3: The rules-extractor subagent is scored as passing when it refuses to convert BH_AUTH.LEGACY_OVERRIDE. Why is a successful conversion the wrong answer?
Q4: submitAndDecide() writes the authorization and its 42 CFR Part 2 consent in one transaction. A proposed decomposition puts them in separate services with a saga that compensates the authorization if the consent write fails. What is the objection?
Q5: The parity validator reports zero on the protected-content leak scan. What do you check first?
Q6: A student moves the deny-button role check from <c:if test="${sessionScope.roleMask ge 4}"> in the JSP to @if (roleMask >= 4) in the Angular component. Two things are wrong. Which?
Q7: The reference platform gates seven capabilities behind *_ENABLED flags, and the synthesizer mirrors the idiom by adding CONSENT_ENABLED. What is the test that catches this, and what is the answer?
Q8: Why is the ASAM domain knowledge a Skill rather than text in each of the eight subagent prompts?
References & Resources
In the lab
spec/agent-spec.md— the twelve-section contractbhauthtrack/README.md— "where to start reading" and "things that will bite you"bhauthtrack/db/02_seed.sql— the golden set, with each case's expected outcome and the branch it exercisesreference-umlite/VENDORED.md— what the donor does and does not have, with the build state verifiedreference-umlite/BACKLOG.md— the platform team's own planned-and-unbuilt listexpected_output/— the reference run: gap register, seam map, term map, screen inventory, rules divergence, the approval promptappendix/manual-loop.py— the loop the SDK runs for you, and where the guardrails have to go without it
The four Skills
behavioral-health-um— the domain, plus four bundled references and a code validatorumlite-architecture— the target's house style, and an explicit "do not mirror" tablerules-to-dmn— the seven-step runbook, plus the overlap checkerdecompose-transaction— classify the pairs before drawing the seam
Elsewhere in the course
- M26 — Hooks, Sessions & the Agent SDK — the mechanics of
can_use_toolandHookMatcher - M14 — Multi-Agent Systems — coordinator/specialist topologies and context isolation
- M17 — Output Guardrails & HITL — the approval-gate pattern
- Capstone 8 — Oracle to PostgreSQL — the same shape, one layer down: schema and data rather than architecture and domain
External
- ASAM Criteria — the placement framework this lab models in simplified form
- 42 CFR Part 2 — the substance-use-disorder confidentiality regulation
- MHPAEA — mental health parity, and the NQTL comparative analysis requirement
- DMN 1.3 specification — hit policies, and what each one does on an overlapping table
Not a green build. A parity-report.json with ten checks each reporting what they scanned, and a manual-review-queue.json with entries in it.
A run that queues nothing has guessed at something.