ADR-0132 — The scorecard seam hands a low-graded turn to the Oracle through one injected hook

⚠️ 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, and the-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's ts" is superseded. There is no step 2 and no post() wrapper. The hook fires once per turn whose turn_checks_posthoc row 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_THRESHOLD and everything about grade-7/8 still stands.
  • Decision 2 — "nothing it does can reach turnsPosted, the watermark or the posting order": turnsPosted is 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 4turnLineTs is still in the payload and is still required, but it now always carries the ROOT ts. Verified safe against the consumer: agent-smith's workflows/oracle_turn.py posts with thread_ts=payload.rootTs and never reads turnLineTs. 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, summaryPosted and rootReaction, and the posting order is now card → watermark.
  • Alternatives considered, "Persist turnLineTs on 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

  1. postScorecardIfNew gains one optional dependency, onLowTurn, on ScorecardPostDeps. The seam calls it inside step 2, after post() returns the turn line's ts, when m.grade < ORACLE_THRESHOLD. ORACLE_THRESHOLD = 8 is an exported constant beside REASON_LINE_BELOW in turn-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 local post wrapper returns notify's ts instead of dropping it.
  2. 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 reach turnsPosted, the watermark or the posting order. A turn whose startedAt is older than 72 hours at post time is refused:backfill_floor before 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.
  3. The default implementation is src/lib/temporal/activities/oracle-door.ts — the only file allowed to start an Oracle workflow. It reads SMITH_TEMPORAL_ADDRESS/NAMESPACE/API_KEY at call time, holds its own connection (never the cached propflow-prod one), opens a 5-minute circuit breaker on a failed dial, and starts OracleTurnWorkflow by type name with id oracle-<conversationId>-<turnKey>, task queue propflow-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.py fails the deploy on an unreadable secret).
  4. 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.
  5. scripts/oracle-evidence.ts is the only thing the Oracle may quote. It rebuilds the turn with buildConversationTurns / runTurnChecks / computeTurnGrade / turnGradeFromRows / injection-snapshot, resolves the speaking agent from the call's transfer_to_agent results (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 explicit quotes[] list. It takes --conversation <id> --turn <ordinal|key> (prod, explicit DYNAMODB_TABLE_NAME, reads through @/lib/data only) 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's detail (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.ts is its sibling for the morning line: ONE property-less getMachineGrades(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

Alternatives considered