Building AI Agents with Claude
Capstone Project 9
Capstone 9 — Bonus14–18 hoursBehavioral Health
← Capstone 8: Oracle → PostgreSQL 🏠 Home Course Home →

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.

Analogy

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.

VerdictMeaningRequired
port-as-isCopy the platform's approach unchangedEvidence it is domain-agnostic
extendThe shape is right, the content is insufficientWhat specifically must be added
must-build-newNothing corresponds. Someone has to build itWhat it must do
must-not-portThe platform does this, and copying it here is harmfulThe named harm
Why it matters

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.

PhaseSubagentProduces
1 Maparchitecture-cartographerArchitecture manifest, each capability tagged for BH sufficiency
2 Excavatemonolith-archaeologistDomain model, seam map, term map, unknowns queue
jsp-archaeologistScreen inventory and the rules found inside views
3 Extract rulesrules-extractorDecision-table IR with a justified hit policy
4 Gap-analysegap-analystThe gap register
5A Synthesizerepo-synthesizerServices, migrations, events, BPMN, DMN
5B Synthesizefrontend-synthesizerRouted, role-guarded client
6 Validateparity-validatorTen 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.

ModuleWhat you need from it
M07 — MCPBoth 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 — PlanningSix ordered phases with real dependencies, and a gate between 9A and 9B that refuses to open early.
M14 — Multi-AgentA coordinator delegating to eight specialists, each reading a different slice of two codebases in its own context.
M15B — Build LabThe .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 GuardrailsPreToolUse denial through can_use_tool. Four of the five hooks here are denials.
M17 — Output Guardrails & HITLThe finalization gate always denies. If you have not met a human-approval gate before, that will read as a bug.
M18 — Evaluation242 offline tests and a 24-scenario eval harness. Several scenarios score a refusal rather than an answer.
M22B — DeployLocal 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.

The one that catches people

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 levelWhat it isReview cadence
4.0Medically managed intensive inpatient3 days
3.7Medically monitored intensive inpatient5 days
3.5Clinically managed high-intensity residential7 days
3.1Clinically managed low-intensity residential14 days
2.5Partial hospitalization14 days
2.1Intensive outpatient30 days
1.0Outpatient90 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.

HIPAA42 CFR Part 2
Disclosure for treatment, payment, operationsPermitted without authorizationRequires consent
RecipientA class of recipients is fineMust be named
Scope"Minimum necessary" standardConsent states scope explicitly
DurationMay be open-endedExpires; revocable
Downstream recipientsBound if a business associateBound by the redisclosure notice
Accounting of disclosuresRequired for someRequired

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.

The reviewer-licensure rule

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 hasFine for medical prior auth because…Fatal for behavioral health because…
Two tables. Zero foreign keys. member_id VARCHAR(32), opaque, no member tableOne case, one decision, nothing to relate it toConcurrent 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 payloadNobody reads itIt 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 codeDenials are rare and handled by a personThe 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 groupCorrect: a medical case is decided onceNo 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 unguardedDeferred; it is on their backlogPart 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 securityDeferredNo 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 anywhereMember identifiers only, in a teaching environmentDecomposition 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 buildNothing 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 importedIt demonstrates the intake callA UM client is multi-screen, session-based, role-gated and worklist-driven. None of that is demonstrated
Why it matters

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.

Do not infer a capability from a dependency

@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:

The flag test

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.

The deliberate dead end

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.

TermMeaning
Carve-outBehavioral 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
ASAMAmerican Society of Addiction Medicine. Levels 0.5–4.0, placed using six dimensions
The six dimensions1 withdrawal potential · 2 biomedical · 3 emotional/behavioral/cognitive · 4 readiness to change (inverts) · 5 relapse potential · 6 recovery environment
LOCUS / CALOCUSLevel of Care Utilization System — the psychiatric analogue of ASAM. CALOCUS is the child and adolescent version
Concurrent reviewThe recurring continued-stay review that runs for the life of an authorization
PHPPartial hospitalization, ASAM 2.5. Day treatment; the member goes home at night
IOPIntensive outpatient, ASAM 2.1
C-SSRSColumbia Suicide Severity Rating Scale, 0–5. 4 and 5 are active ideation with intent — a threshold, not a gradient
PHQ-9 / GAD-7Depression (0–27) and anxiety (0–21) severity instruments
42 CFR Part 2Federal rule protecting records from federally assisted substance-use-disorder treatment programs. Consent must name the recipient
Redisclosure noticeThe notice that must accompany a Part 2 disclosure, binding the recipient too
NQTLNon-quantitative treatment limitation. A process-level limit — review frequency, step therapy, network standards — that parity puts in scope
Adverse determinationA denial, or an approval at a lower level than requested. The regulated event
Step-downMoving to a less intensive level. Normal and expected; not a denial
PENDEDThe state a case waits in for someone licensed to decide it. A separation of duties encoded as a status
TATTurnaround time. Expedited 72 hours, standard 14 calendar days. Missing it can force an automatic approval depending on line of business
X12 278The 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.

A. Different name, same concept

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.

B. Same name, different meaning

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:

ValueClinical platformBehavioral health1:1?
SUBMITTEDInitial stateInitial stateyes
APPROVEDTerminalRe-enters review on its cadence. The switch loops it to IN_REVIEWno
IN_REVIEWDeclared and never assigned — a dead enum valueThe busiest state in the systemno
DENIEDUnreachable — no DMN rule can output itReachable, but only for an administrative fact: a terminated providerno
PENDEDA generic holdA separation-of-duties control — the state a case waits in for someone licensed to deny itno
Why this is worse than a rename

A renamed field fails loudly. You go looking for notes on the behavioral side, do not find it, and have to decide something.

APPROVEDAPPROVED 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 onlyWhy the clinical platform never needed it
BH_LOC_REVIEWA medical case is decided once. Concurrent review has no analogue
BH_CONSENTHIPAA has no named-recipient requirement, so the concept never arose
BH_ASSESSMENTThe clinical engine decides from a procedure code, not a six-dimension assessment
ROLE_MASKSecurity is off by default and authentication-only when on — there is nothing to map roles onto
EXPIREDOnly reachable in a domain that has a cadence to miss
LEGACY_OVERRIDENo 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.

Why it matters

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.

AuthCaseService.submitAndDecide() — five writes, one transaction
Press play. Each write moves to the service that will own it.

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:

ClassMeaningMechanism
Must be atomicOne existing without the other is unsafe or unlawfulKeep in one service, one transaction. Do not split
Eventual, guaranteedOrder matters, the gap is tolerable, but it must closeTransactional outbox + idempotent consumer
Eventual, best effortA delay or a loss is operational, not a correctness problemOrdinary publish
IndependentNo relationshipAnywhere

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.

Recording a rejected seam is a result

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.

gap-analyst — one verdict per capability, evidence on every one
Press play.
Expect an uncomfortable distribution

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.

Case 500001 — the ladder, and the flattened table
Legacy ladder — first commit wins
Flattened table — hit policyA DMN decision table's rule for what to do when more than one row matches: FIRST, UNIQUE, PRIORITY, ANY or COLLECT. On an overlapping table there is no neutral choice. decides
Press play.

There is no neutral choice

Hit policyOn the overlapping row
FIRST3.7only if row order survives translation
UNIQUEA runtime error: two rules matched
PRIORITYWhichever output the priority list ranks higher
COLLECTBoth, and the caller has to choose
The uncomfortable finding

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 cell

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

One field, four sinks, no consent scope on any of them
Press play.
Why this is plumbing, not policy

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_event and old_narrative TEXT are 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.

Skills carry knowledge and recipes; agents carry control flow and safety
Knowledge plane — .claude/skills/
Control plane — Agent SDK
Press play.

The rule of thumb, and it decides every case you will meet:

The test

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.

decision.jsp → /auth/:id/decide
Press play.
Moving a rule from JSTL to *ngIf has moved nothing

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.

One finding, not two

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.

SkillSubagentSlash command
Lives in.claude/skills/<name>/SKILL.md.claude/agents/<name>.md.claude/commands/<name>.md
LoadedOn demand, by description matchWhen delegated toWhen a person types it
ContextShares the caller'sIts own windowShares the caller's
Can bundle filesYes — references/, scripts/NoNo
Can restrict toolsAdvisoryYes, enforcedNo
Can block a tool callNoNo — hooks do thatNo
Use it forKnowledge, and runbooksWork needing isolation or a narrow tool grantAn 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.

Three anti-patterns
  • The ontology in every prompt. Drift plus token cost. This is the one the lab tests for — test_skill_loading.py fails 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.md

That 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

Layer 1 — the fixtures

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.

Layer 2 — the gate

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.

Layer 3 — the audit

PostToolUse on every call, one JSON line, credentials and narrative redacted before the write.

Why the gate inspects RESULTS, not just inputs

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.

RequirementVersionWhy
Python3.10+The solution uses X | None union syntax throughout. Developed on 3.11; 3.12 in the container
claude-agent-sdk>= 0.2.0Tier 3. query, tool, create_sdk_mcp_server, HookMatcher, PermissionResultDeny
pytest>= 8.0All 242 tests, offline
Node.js18+ (phase 9B only)Only to npm install the vendored donor so its Angular workspace builds. Not needed for 9A
DockeroptionalTier-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-check
Expected output on a fresh checkout

242 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/ -q

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

Step 1 — TODOs 1–2 — transcribe the ladder

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.

What just happened?

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.
Step 2 — TODOs 3–6 — the decision-table engine

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.

What just happened?

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.

  • UNIQUE silently 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_policy evaluates anyway. DMN defaults to UNIQUE, 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.
Step 3 — TODOs 7–12 — the register and the seam map

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

What just happened?

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.

: a seam with no replacement, an incomplete replacement, and a must-be-atomic pair someone tried to cut.

Anticipated errors.

  • Your register accepts a must-not-port with no harm. Then the register's most important verdict means nothing. Raise.
  • Seam.validate() passes a must-be-atomic seam. That seam cannot be cut. Either move it or record rejected_because — recording a rejection is a result.
Step 4 — TODOs 13–18 — the five guards

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.

What just happened?

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+ after password= 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.
Step 5 — TODOs 19–24 — tools and writers

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.

What just happened?

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.
Step 6 — TODOs 25–27 — the parity checks

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.

What just happened?

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.

The semantics to get right

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: narrative does not match inside old_narrative because 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.
Step 7 — run it

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.

What just happened?

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 in solution/.envconfig.py reads 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-is means 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.py and 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 1
Step 8 — TODOs 29–30 — the screen inventory

What & 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.

What just happened?

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.jsp is 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_equivalent left blank. Refused, deliberately. “NONE” is a finding and has to be said out loud; eleven of the twenty rules are NONE.
Step 9 — TODO 31 — the route writer

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.

What just happened?

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 >= 4 is the approximation JSTL was forced into because it has no bitwise operator — and it is the permissive side. Mask 33 passes it and fails hasRole(MD). Test named roles.
  • Every screen reports as routed however you break the routes file. You are matching a substring; member appears in memberLastName in half the components. Match a path: 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/
TrackInheritsMisses
1 — Modernize
reads the donor and the monolith
Architecture fidelity, the platform's conventions, the flag idiomSilently 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 onceNo institutional knowledge — the branch-7 overlap rows, the two member identifiers, the 2013 merge comment explaining the transaction
The lesson

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.

HookMatcherDoes
protected_content_gate.*Denies narrative-shaped content in any tool input; the result filter redacts on the way back
enforce_reference_readonlymcp__reference_src__.*Denies path traversal out of the donor tree
enforce_legacy_readonlymcp__legacy_src__.*Same, for the monolith
confine_writeswrite_artifactDenies any path outside bh-um-lite/
hitl_finalization_gatefinalize_modernizationAlways 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 --approve

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

Approval is not a config value

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.

#CheckCatches
1Rules divergenceThe hit-policy artefact at the ASAM 3.5/3.7 boundary
2Protected-content leakNarrative in a log, event, index, audit column or error path
3Narrative round-tripValidated-then-discarded, asserted on the column
4Consent atomicityState and whether anything enforces it
5WorkflowNo loop, no timer, no escalation, an unassigned task
6Decision tableUnreachable denial, no diagnosis input, unstated policy
7IdentityOne opaque member id where there should be two
8Screen coverage (9B)A screen with no route; a rule still in a template
9Feature-flag classificationA regulatory control behind a flag
10Term mappingA donor status unaccounted for; a silent name collision
The semantics that took two attempts to get right

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 / 24

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

Before you pull anything

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 --approve

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

The constraint that shapes every tier

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:

LocalCloud
Where a redaction miss ends upone container's stdoutCloud Logging, and whatever is subscribed to it
Who can read the audit logyouanyone with project log-viewer
What a crash dump becomesa terminala 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

Educational model, not legal advice

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

SymptomCauseFix
All twelve golden cases diverge from the seed's stated outcomesThe Java layer is applied inside the ladder rather than after itIt runs on the committed decision, so it can only downgrade or pend
Case 500007 comes out 3.5 instead of 2.5Dimension 4 treated as a severity indicatorIt inverts. A low readiness score subtracts
Zero divergences and you have not tightened anythingYou used FIRST and the rows happen to be in ladder orderCorrect 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 justificationSanitise prose before interpolating it into a comment
to_feel() raises on a tightened rowA cross-input exclusion cannot be one cellWorking as intended. Add a named derived input
Redaction leaves one clinical sentenceThe prose pattern requires whitespace after the final full stop\s*, not \s+. One sentence is a disclosure
The audit log will not parseA greedy \S+ after password= ate the closing quoteRedact values, not serialized JSON
The leak scan misses an audit table's narrative column\bnarrative\b does not match inside old_narrativeThe underscore is a word character. And the table name is on another line
The run halts after phase 4The register does not meet acceptanceWorking as intended. A mostly-port-as-is register means the domain was not tested
Phase 9B refuses to start9A is not greenAlso intended. A client cannot supply an enforcement the backend lacks
Every screen reports as routed however you break the routes fileSubstring matching — member appears in memberLastNameMatch a path: declaration
The flag check cannot see CONSENT_ENABLEDA leading [A-Z] consumed the first characterMake 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. A disclosure-accounting UI. The lab builds the bh_disclosure table. 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 builtAnd the hard part was
A rules IR with a justified hit policyDiscovering 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 harmsA 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 mapFour 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 seamsDeciding 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 denialsPointing an agent at a regulated codebase without feeding it regulated data.
A gate that always says noBuilding the one control your agent cannot talk its way through.
The thing worth carrying out of here

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 contract
  • bhauthtrack/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 exercises
  • reference-umlite/VENDORED.md — what the donor does and does not have, with the build state verified
  • reference-umlite/BACKLOG.md — the platform team's own planned-and-unbuilt list
  • expected_output/ — the reference run: gap register, seam map, term map, screen inventory, rules divergence, the approval prompt
  • appendix/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 validator
  • umlite-architecture — the target's house style, and an explicit "do not mirror" table
  • rules-to-dmn — the seven-step runbook, plus the overlap checker
  • decompose-transaction — classify the pairs before drawing the seam

Elsewhere in the course

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
A last word on what "done" looks like

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.