ADR-0132 — The scorecard seam hands a low-graded turn to the Oracle through one injected hook
- Status: Proposed
- Date: 2026-09-08
- Deciders: Gera (owner, 2026-09-08 ask); the Architect session
2708dff9fixed the eleven framing decisions; a three-lens judge panel settled the rest. Fleet-side relationship (grading → Smith) is agentflow ADR-0014 — this ADR records only the two product data contracts that change: the seam's input and the persisted workflow payload.
⚠️ SUPERSEDED IN PART — grades came off Slack, 2026-09-18
The Oracle's door, its payload and its one-door rule all stand. What changed is the MOMENT the door opens and the Slack surface around it, because the seam no longer posts a per-turn line at all (Fede: "I think just the transcript. The layer and grade doesn't seem dialed in."). Canonical description:
docs/architecture/per-turn-grading.md§ What piece 3 renders, andthe-oracle.md.Clause by clause, so nobody implements against a mechanism that is gone:
- Decision 1 — "The seam calls it inside step 2, after
post()returns the turn line'sts" is superseded. There is no step 2 and nopost()wrapper. The hook fires once per turn whoseturn_checks_posthocrow the attempt won, before the card decision and independent of it, because the ordinary pass now posts nothing and a hook gated on a post would open on a minority of low turns.ORACLE_THRESHOLDand everything about grade-7/8 still stands.- Decision 2 — "nothing it does can reach
turnsPosted, the watermark or the posting order":turnsPostedis retired. The guarantee is unchanged and now reads "the watermark or the posting order". The 2s race, the catch-all, the logged outcome union and the 72h backfill floor are all untouched.- Decision 4 —
turnLineTsis still in the payload and is still required, but it now always carries the ROOT ts. Verified safe against the consumer: agent-smith'sworkflows/oracle_turn.pyposts withthread_ts=payload.rootTsand never readsturnLineTs. The key is NARROWED, not removed, because it is a Temporal workflow argument that live histories carry.- Consequences, bullet 1 — "its outcome union, its posting order and the drift test's call counts are unchanged" was true of THIS ADR's change and is no longer true of the seam: the union retired
reacted,turnsPosted,summaryPostedandrootReaction, and the posting order is now card → watermark.- Alternatives considered, "Persist
turnLineTson the conversation" — the reasoning stands, and the value the CLI used to recover by thread scan is now simply the root ts.
Context
postScorecardIfNew (src/lib/domain/conversations/scorecard-post.ts) is the ONE seam every grading detector reports through (scorecard-one-seam.drift.test.ts pins it). Inside its per-turn loop the computed grade, the turn key and the Slack anchor are all in hand, and the per-turn notify call returns the line's ts — which the loop then drops. Nothing consumes "a turn graded below 8": no event, no outcome field, no hook (understand.md G-11/G-12).
The Oracle (agentflow ADR-0014) needs exactly that moment — the turn line just posted, grade known — to start one Agent Smith workflow per low turn in the tools-prod Temporal namespace. Every other way in (a second caller of the seam, a fourth trigger kind, a consumer of the conversation_turn_grade Slack metadata, Smith polling the grade rows) is either a second door the drift test exists to refuse, or a poll.
Decision
postScorecardIfNewgains one optional dependency,onLowTurn, onScorecardPostDeps. The seam calls it inside step 2, afterpost()returns the turn line'sts, whenm.grade < ORACLE_THRESHOLD.ORACLE_THRESHOLD = 8is an exported constant besideREASON_LINE_BELOWinturn-thread.ts(two constants on purpose: reasons render below 7, the Oracle fires below 8 — a grade-7 turn has no reason line, and the finding says so). The localpostwrapper returnsnotify'stsinstead of dropping it.- The hook can never harm the seam. It is wrapped in a 2-second race and a catch-all; every outcome is logged as
oracle-hook <started|already_started|refused:reason>with the conversation and turn key; nothing it does can reachturnsPosted, the watermark or the posting order. A turn whosestartedAtis older than 72 hours at post time isrefused:backfill_floorbefore any call (a backfill of days-old rows must not become a storm). Production passes the default implementation from the activity; tests inject a recorder; scripts and the promise-ledger caller pass nothing and behave exactly as today. - The default implementation is
src/lib/temporal/activities/oracle-door.ts— the only file allowed to start an Oracle workflow. It readsSMITH_TEMPORAL_ADDRESS/NAMESPACE/API_KEYat call time, holds its own connection (never the cachedpropflow-prodone), opens a 5-minute circuit breaker on a failed dial, and startsOracleTurnWorkflowby type name with idoracle-<conversationId>-<turnKey>, task queuepropflow-smith, conflict policy FAIL. Absent env →refused:no_tools_prod_client, no dial. The ECS task definition does not carry these variables yet; provisioning them is an infra act, and the template edit that declares the secret lands only after the secret exists (infra/temporal-worker/preflight-secrets.pyfails the deploy on an unreadable secret). - The persisted payload is a closed contract (
LowTurnEvent):conversationId, propertyId, turnKey, turnOrdinal, grade, gate, channel, rootTs, turnLineTs, startedAt, gradedAt, rubricVersion, postedAt, source. Primitives only. Never the turn text, a reason, the inbound, the response, a tool step, a judge detail, a name or a phone — the payload lives in Temporal history in plaintext (ADR-0026). The seam's unit test pins the key set and the value types. scripts/oracle-evidence.tsis the only thing the Oracle may quote. It rebuilds the turn withbuildConversationTurns/runTurnChecks/computeTurnGrade/turnGradeFromRows/injection-snapshot, resolves the speaking agent from the call'stransfer_to_agentresults (AGENT_ID_TO_SLUG, positional fallback to the entry agent), reads the voice catalog and the speaker's prompt file at the worktree's HEAD, decides the bucket with the panel's table, and prints a byte-deterministic bundle with an explicitquotes[]list. It takes--conversation <id> --turn <ordinal|key>(prod, explicitDYNAMODB_TABLE_NAME, reads through@/lib/dataonly) or--fixture <path> --turn <N>. It never re-reads ElevenLabs (the grader's live fallback is today's value, not the call's, and non-deterministic); it never prints the caller's inbound, any tool input or result, or any judge row'sdetail(those carry names); Clara's own sentence is name-masked with every name token the call's own tool inputs recorded, and withheld outright if any other capitalised token survives (the property's own name tokens are the only allowed exception); the residual — a caller whose name is a month or a weekday — is disclosed in the script header rather than papered over.scripts/oracle-nightly.tsis its sibling for the morning line: ONE property-lessgetMachineGrades(undefined, {since})read — the bounded GSI3 window across every partition, UNASSIGNED included (the per-property form of the same call is a full partition scan filtered in memory) — grouped client-side by property, then the same grade arithmetic. The bucket is decided in the TypeScript script and only rendered by Smith; there is no second decider.
Entity classification
No new persisted entity. LowTurnEvent is a workflow argument (Temporal history), derived from TurnThreadMessage + the anchor + the checks row; rebuilt by the seam on every pass; drift tolerance none (the workflow re-reads everything from DynamoDB and treats the payload grade as "what was posted", stating both numbers when they differ).
Consequences
- The seam has one more injectable dependency and one more log line per low turn; its outcome union, its posting order and the drift test's call counts are unchanged.
- The worker deploy gains two env vars and one secret, provisioned by hand once; until then every low turn logs
refused:no_tools_prod_clientand the Oracle is reachable only throughagent_smith.oracle_cli(the same door, the same workflow id). config/automations.tomlgainsoracle-turn(detector, tools-prod,OracleTurnWorkflow) andoracle-nightly(cron, a morning-queue step).docs/architecture/the-oracle.mddescribes the whole loop for a human.- What this does not cover, on purpose: a stale injected price with no lookup behind it grades 10 and never reaches the hook (
run-turn-checks.tsheader); a grade-7 turn reaches the hook with no reason line, and the finding names the two soft deductions instead.
Alternatives considered
- Read the seam's
ScorecardPostOutcomein the activity and start from there — duplicates the seam's turn selection and loses the per-turnts; a fourth caller shape. - A fourth
ScorecardTriggerkind — the threshold is a consequence ofturns_scored, not a new trigger; the drift test pins the literals. - Compute the evidence bundle in the worker and pass it in the payload — grounds the finding in the payload instead of an independent read, and puts utterances into Temporal history.
- Persist
turnLineTson the conversation — a new column for a value the payload already carries; the CLI recovers it with one thread scan when the start was refused.