ADR-0086 — Eval Platform: Morpheus, Sherlock, and the Replication


Context

PropFlow is an AI-agentic-native platform: Clara handles real tenant maintenance, leasing, renewal, and voice flows against real AppFolio, real Twilio, and prod DynamoDB. An agent is only as trustworthy as our ability to tell when it is lying to us — so the eval platform's job is to prove Clara actually did the thing, on real surfaces, with receipts, and to feed that proof into the loop that ships changes.

The problem this ADR solves is not a missing capability. The hard part — a prod-faithful, real-source-verified, ledger-captured harness with a machine-readable verdict — already exists and is excellent. The problem is that the knowledge is scattered: across ~6 planning docs, two aspirational ADRs (0064/0065), and a large body of hard-won session learnings that live nowhere durable. The vocabulary drifted (the UI credited "Sherlock" for grading that Morpheus's harness actually did), the create-vs-validate boundary was drawn backwards, and the failure modes that bit us building the generation loop exist only in session memory.

This ADR is the single governing reference. Someone six months from now who must modify or repair this system should read only this ADR + its linked sources and understand the whole thing: the first principles, the cast and their firewalled roles, the end-to-end flow, the invariants that must never break, the known failure modes and their fixes, and the operate/debug/extend playbook.

Naming and the two load-bearing firewalls were locked by Gera on 2026-07-08 (replication-morpheus-gate.md §0, decisions D1/D2/D5). This ADR does not re-litigate them.


Decision

1. First principles (the two load-bearing firewalls)

Everything below is downstream of these two. If a change would weaken either, it is wrong by construction.

FIRST PRINCIPLE — the create/validate firewall (D2). The thing that GENERATES tests (Morpheus) must NEVER be the thing that GRADES them (Sherlock). A self-improving eval loop's single biggest risk is that it learns to pass by lowering the bar. Once Morpheus generates scenarios (D1), fusing validation into him creates exactly that conflict of interest. So the platform is one system, two firewalled roles: Morpheus creates + drives; Sherlock validates. The gate consumes Sherlock's verdict, never Morpheus's self-report. (Same rule ADR-0065 §7 states constitutionally; this ADR makes it the platform's #1 invariant.)

SECOND PRINCIPLE — the human ✅ is the oracle guard (D1). Generated scenarios are born approved:false. Only a roster human's reaction stamps approval; Smith never approves its own work (the reviewer name is always the human who reacted — morpheus_author.py approval path). Receipts before review: a generated scenario must prove GREEN on the real bench before a human is asked to approve it. This is what keeps the oracle problem ("a vibe judging a vibe") from reappearing: Morpheus proposes, the bench proves, a human disposes.

2. The cast + their firewalled roles (vocabulary LOCKED — do not re-litigate)

Name Role (one line) What it owns
The Replication Agent Smith's build loop Morning board → "do N" → one Replica worker-per-card (a full Claude in a fresh worktree) → PR → merge-gate → merge. Workers are Replicas.
Morpheus Scenario supply + drive The golden corpus (hand-authored exemplars + net-new generated scenarios) AND executing them against the real bench. He builds the simulation and runs you through it.
Sherlock ALL validation (D2) Two passes: the in-run checkpoint grading + the post-hoc fake-detector. Decides whether Clara actually did the thing.
Maestro Clara's inbound routing brain package → precheck → reasoning → ordered action queue (ADR-0061). The subject under test, not a watcher.
The Construct (taken — ADR-0078) Smith's job scheduler. NOT part of the eval cast; do not reuse the name.

What a golden is. A golden = a predicted conversation script: the hard, predictable checkpoints of a flow (said X, called tool Y, and did not do the wrong thing) with the in-between deliberately fuzzy (many agents, many tool calls). That fuzziness is why it's Morpheus — "not a straight line." Clara's replies are produced live by the real agent; only the tenant's inbound beats are scripted. Morpheus grades whether the fuzzy path hits the predictable hard checkpoints.

Levels L1→L4: L1 single-party (created a WO / "hello") → L2 multi-turn → L3 multi-party (tenant + handyman) → L4 full investigation. Today: L1, text-lane maintenance only.

Morpheus builds and drives the simulation; Sherlock decides whether Clara actually did the thing; the Replication's merge gate consumes Sherlock's verdict. Today this sentence is true for the standalone loop (morpheus-run-one.ts ANDs Sherlock into its exit code — §4) but is [ASPIRATION] for the Replication merge gate: the connector (§4, Piece 3) validates + curates post-merge; it does not yet block a merge on a red. See §7 (open work).

3. Sherlock's two passes (the boundary, drawn correctly)

The confusion Gera flagged was real and was a boundary bug, not a role bug. The validation people credited to "Sherlock" was split across Morpheus's harness and a narrow post-hoc auditor. D2 redraws it: Sherlock = all validation, two passes.

Deferred: ADR-0065's investigator-Sherlock (strategy toolkit, halt authority, versioned wisdom config, per-gate LLM deputies) is [ASPIRATION], not now. The deterministic receipt-required auditor is sufficient for gate v1 and is trustworthy precisely because it is not an LLM. Until scenarios are generated at variety, there is nothing for a per-gate investigator to discover that the specs don't already enumerate.

4. The end-to-end flow

Two entry paths converge on the same authoring pipeline.

 ENTRY A — manual/curated (Slack)          ENTRY B — the connector (auto, #3587)
 "author scenario [slug]: <hint>"          Replica PR merges + Vercel build green
   in #agent-smith / DM / member chan        → SmithApprovalWorkflow.build_passed seam
   (roster members only)                      → path-map gate (clara-behavior-evals.yml,
        │                                        read at RUNTIME) — no match ⇒ not_applicable
        │                                      → MODEL_LOW hint derivation w/ honest carve-out
        │                                        (text-lane MAINTENANCE only ⇒ else not_coverable)
        └───────────────────┬──────────────────┘
                            ▼
        ┌──────────────────────────────────────────────────────┐
        │  AUTHORING PIPELINE (detached worktree off fresh main) │
        │                                                        │
        │  1. GENERATE  scripts/eval/morpheus-generate.ts        │
        │     Opus-4.8 authors ONE net-new L1 golden from an     │
        │     exemplar + the correctness rubric + HARD RULES 1-13│
        │     → static committed JSON, provenance.approved:false │
        │     (self-validates via validateGeneratedGolden)       │
        │                                                        │
        │  2. PROVE     scripts/eval/morpheus-run-one.ts         │
        │     Lane B: branch code on the mini, in-process        │
        │     Temporal, against the appfolio-45 bench (real      │
        │     Twilio/L4/prod DDB). --report-only for unapproved. │
        │     Bench lock (one-at-a-time). Writes                 │
        │     GOLDEN_EVAL_BRANCH_HEAD.                            │
        │     ── Sherlock in-run (checkpoints.ts → gridAllGreen) │
        │     ── Sherlock post-hoc (auditCapturedRun → clean)    │
        │     exit 0 ⇔ grid ALL GREEN **AND** verdict.clean      │
        │                                                        │
        │  3. PROPOSE   green → Slack proposal ("React ✅/❌")   │
        │     + maestro:<runId> pointer + Maestro-tab link       │
        │                                                        │
        │  4. HUMAN ✅  a roster human reacts → morpheus-approve │
        │     stamps approved:true + reviewedBy=the human        │
        │     → git add <artifact ONLY> → commit → push → PR     │
        └──────────────────────────────────────────────────────┘
                            ▼
        MERGED ARTIFACT joins the NIGHTLY RERUN (05:00 CT,
        co.propflow.morpheus-nightly-daily) — quality-regression watch,
        one-at-a-time, provenance.approved===true only.

Where each named component sits:

Stage Component File
generate the generator (Opus-4.8, once, at gen-time) scripts/eval/morpheus-generate.ts
load + gate gen-* loader + approval gate src/lib/maintenance/eval/golden/registry.ts:112-126
validate mechanical fences (a)–(e) src/lib/maintenance/eval/golden/golden-schema.ts:420
prove (drive) Lane-B harness + bench lock + branch-head + Sherlock-AND-exit scripts/eval/morpheus-run-one.ts (+ morpheus-run-support.ts)
drive (runner) the text-only golden runner scripts/temporal/run-golden-eval.ts
grade (in-run) Sherlock in-run pass src/lib/maintenance/eval/checkpoints.ts
grade (post-hoc) Sherlock post-hoc pass src/lib/domain/evals/fake-detector/detect.ts
approve stamp approved:true scripts/eval/morpheus-approve.ts
authoring loop Slack generate→prove→approve→PR tools/agent-smith/src/agent_smith/morpheus_author.py, smith_slack_actions/morpheus.py, morpheus_proposals.py
nightly rerun Lane-B regression watch (05:00 CT) tools/agent-smith/src/agent_smith/morpheus_nightly.py
connector post-merge validation receipt (#3587) tools/agent-smith/src/agent_smith/activities/morpheus_validation.py + workflows/approval.py:349-420

5. The two lanes (why Lane B is the gate lane)

Lane A — preset Lane B — golden
Runner maintenanceEvalWorkflow on the deployed Fargate fleet (main's code) scripts/temporal/run-golden-eval.ts in-process on the mini (branch code)
Scenario source evals/presets/* (needs merge + deploy) golden/*.ts + golden/generated/*.json from the local worktree
"Prod"-ness Temporal Cloud + prod DDB local Temporal dev server + prod DDB + appfolio-45 + real Twilio/L4
Gate-capable? No — structurally useless as a PR gate (only tests deployed code) Yes — the only lane that runs the PR's Temporal-worker code pre-merge

Lane B is the gate lane because only an in-process run from the PR's checkout exercises the PR's worker code. The honest nuance on "no staging": there is one AppFolio account (jpco), so the isolation boundary is the bench (appfolio-45 + fake personas), not the environment (ADR-0065 §9). Validation runs against prod services (prod DDB, deployed L4, real Twilio) but Clara's reasoning runs from the mini's main checkout — the same code as deployed.

6. The invariants (a future modifier must not break these)

  1. The D2 firewall. Morpheus never grades its own output. A generated scenario is DATA (turns + a checkpoint spec — it declares what to check, never how a lens reads). Grading code (checkpoints.ts / clara-judge / the fake-detector) reads only fixed source.
  2. The human-approval oracle. provenance.approved:false by default; only a roster human stamps approved:true; Smith never approves its own work. The nightly discovers provenance.approved === true artifacts by strict identity (morpheus_nightly.py:182).
  3. Lane-B-only for gate runs. Branch code on the mini vs the prod-fleet preset lane. Vercel cannot host an in-process Temporal dev server; the golden runner refuses --channel voice and refuses voice-PRIMARY goldens by name (run-golden-eval.ts:13, 420-421).
  4. appfolio-45 blast-radius firewall. EVAL_PROPERTY_ID = 'appfolio-45' is pinned; the drive/inject firewall throws on any other property (run-golden-eval.ts:124, 237, 477). Both *-eval queue overrides are REQUIRED — the runner refuses prod queues (run-golden-eval.ts:32).
  5. approved:false default + MORPHEUS_ALLOW_UNAPPROVED=1 only on the report-only lane. The registry refuses an unapproved artifact as a real gate; only --report-only (which sets the env) lets it run for observation (registry.ts:112-114, morpheus-run-one.ts:79).
  6. Static-DATA artifact — no LLM at run time. The LLM runs once, at generation time; the runnable artifact is static committed JSON, so run-to-run determinism is exactly the exemplar's (Clara's own variance absorbed by grace:'fuzzy' + clara-judge + retry-once).
  7. One-at-a-time bench. The bench lock (scripts/eval/bench-lock.ts) serializes the PR gate, the nightly, and manual runs; morpheus-run-one.ts exits 3 when the lock is held. precleanBench would wipe a concurrent run mid-grade — this is why the lock exists.
  8. Metered ANTHROPIC_API_KEY for generation + run. Clara's agent loop and generation must ride the metered key, never the subscription bearer's shared weekly cap — else a run 429s (morpheus_nightly.py:41-42, fail-loud when missing).
  9. No real phones / no PII, ever. Persona phones are the compiler tokens {{TENANT_PHONE}}/{{HANDYMAN_PHONE}} (never a real E.164, in personas OR message text — these messages are really SENT). The generator hard rule 1 + validateGeneratedGolden check (a) enforce it; the connector's hint derivation fails loud on any phone-like token.
  10. The generator's HARD RULES are the encoded contract. morpheus-generate.ts:311-326 (rules 1–13). The validator (golden-schema.ts) mechanically enforces the subset that is checkable; the rest bounce at human review.

7. What is LIVE vs what is NOT (honest current state + coverage boundary)

LIVE [2026-07-11]:

NOT live / coverage boundary — state precisely, no overclaiming:


Consequences

Known failure modes + fixes (the debug playbook)

These bit us building the generation loop; they exist nowhere else durable. Symptom → root cause → fix/where.

Symptom Root cause Fix / where
IntakeClassify dies on a transient Anthropic 401, WO never filed The text-lane WO writer had no retry while MaestroPrecheck did (asymmetric resilience) One-retry mirror added — PR #3583 (addf3274f, "one-retry resilience on the intake-classify LLM call")
Correct runs go RED on a fused judge criterion The generator's own calibration example DICTATED a fused clause ("…and that she will handle the follow-up herself") that contradicts its ONE-condition rule — reds Clara's correct voice when she says the handyman will handle it De-fused; calibration + HARD RULE fixed — PR #3584 (adbecc408). See the explicit calibration note at morpheus-generate.ts:306 ("Proven miscalibration, 2026-07-11: two consecutive generations copied that fused clause and both redded correct runs")
Generator asserts workOrderCountEquals:1 on the REPORT turn, reds a correct run Filing is ASYNC — the dispatcher's intake sweep files the WO after Clara's reply; at the report turn's settle the count is still 0 HARD RULE 13 (morpheus-generate.ts:326) + validateGeneratedGolden check (e) rejects workOrderCountEquals >= 1 at the earliest settle moment (golden-schema.ts:499-522) — PR #3584
appfolio:description settles late — passes nightly, bit once mid-afternoon AppFolio description write settles asynchronously; a single read races it Bounded settle-retry settleAppfolioDescription (~24s) — checkpoints.ts:489, 527, 680. Still an open watch item (flake, not fixed to zero)
AppFolio delete_work_order returns false success; WO persists SR-slot resolution + no post-delete verify JSON:API resolve + post-delete verify — appfolio-browser-agent PR #252. (Also: delete_work_order is broadly unreliable — prefer cancel.)
A Marcus-based scenario reds non-deterministically on a continuation-match Bench-tenant personId entanglement — Marcus (+12065551001) shares a personId with a synthetic sandbox WO (YSB-90001); the intake classifier intermittently continuation-matches it instead of filing fresh Option-A person split + morpheusPreRunReset (park YSB-90001 terminal during the run). The deeper iceberg (Yale tenants dual-bonded, duplicate-person sprawl) is documented not-fixed (morpheus-generation-design.md "Known follow-up") — an intermittent Marcus red is bench entanglement, NOT a real regression
A "✅ Re-review" Slack comment does NOT re-green a stale 🟡 PR verdict The Claude Review Recheck only recognizes 🟢/🟡/🔴/📝 verdict headings Cure = close/reopen the PR to fire a fresh full review. (Smith/CI plumbing, but it bit us landing these very PRs — worth the line)

Standing gotchas (not one-off):

Operate / extend playbook

Run one scenario manually (the Lane-B env contract). The proven shell contract (ported into morpheus_nightly.py:38-42):

source .env.local (OVERRIDING the process env)
unset TEMPORAL_API_KEY TEMPORAL_ADDRESS TEMPORAL_NAMESPACE   # force the local dev server
export DYNAMODB_TABLE_NAME=propflow-prod                     # .env.local defaults to stage
export APPFOLIO_EVAL_ACCOUNT_ID=jpco
# the metered ANTHROPIC_API_KEY MUST be set (fail-loud) — Clara must not ride the subscription cap
npx tsx scripts/eval/morpheus-run-one.ts --scenario <id> [--report-only] [--timeout-sec 1200]
# exit 0 ⇔ grid ALL GREEN AND Sherlock clean; 1 = fail; 2 = usage; 3 = bench-lock held

Operate the nightly. Runs as step 6 (last) of NightlyQueueWorkflow, the 02:00 CT Temporal batch (folded in 2026-07-12; the standalone 05:00 LaunchAgent co.propflow.morpheus-nightly-daily + its installer were retired). Ordered last so its appfolio-45 bench use never overlaps the earlier maintenance-eval + turnover-eval sub-jobs (the queue is strictly sequential); its per-job timeout is raised to 75 min. Still records its OWN AutomationRun under co.propflow.morpheus-nightly-daily (morpheus_nightly.py JOB_ID). On-demand: cd tools/agent-smith && uv run python -m agent_smith.morpheus_nightly, or fire the whole queue via uv run python -m scripts.register_nightly_queue_schedule --trigger. Local-ops cutover on the mini: launchctl unload ~/Library/LaunchAgents/co.propflow.morpheus-nightly-daily.plist + delete that plist so the old 05:00 job stops firing.

Deploy Smith-side changes. After merging any change to the nightly / authoring loop / connector, deploy with ~/.claude/scripts/smith-sync-from-main.sh (restarts the Temporal worker + Slack socket; the socket carries the trigger/reaction wiring).

The discovery contract. The nightly runs only provenance.approved === true artifacts (strict identity — an unapproved artifact never gate-runs; every skip is logged with its reason).

Extend coverage to a new flow lane. Today the honest carve-out gates it: the authoring lane proves ONLY text-lane MAINTENANCE tenant flows. To add voice / leasing / renewal / turnover you must (a) give that lane a runner that Lane B can drive (voice is EL-simulate, not the golden runner — which refuses voice-primary), (b) teach the golden schema that lane's anchor type (message-beat | event | programmatic — checkpoints must land at the right moments: message-turn for SMS, event/webhook/cron for voice), (c) author that domain's exemplar goldens, and (d) update the connector's coverage carve-out prompt so it stops returning not_coverable for that surface.

Open decisions (carry forward — do NOT resolve here)

From the gate doc §6; only Gera can call these:

Plus the unbuilt (labeled, not decisions): L2–L4 golden levels; the perturbation generator (P3a) + exemplar-guided flow-suite generation (P3b); and — most importantly — the Replica-PR-blocks-merge-on-red gate. Today the connector is post-merge/advisory: it VALIDATES + curates, it does not yet BLOCK. Turning it into a blocking pre-merge gate is future work gated on D3/D4/D6/D7.

What this ADR makes canonical vs what stays in planning


Alternatives considered