Grading in the channel

A design for Cerebrus and Agent Smith grading what happens in the apartment channels β€” every layer of a turn, not just the reply, and attached where it can actually be argued with.

2026-09-02 Β· draft for discussion Β· nothing built yet

Most of this already exists. Cerebrus grades whole threads today, GradeVerdict already captures a human πŸ‘/πŸ‘Ž, and the D8 nightly sweep already turns a πŸ‘Ž into a Cerebrus fixture β€” it ran successfully today. What is missing is the Slack surface, not the machinery.

1 Β· What already exists

The instinct that "Cerebrus already is a system that could grade stuff" is right, and it goes further than that β€” the disagreement loop is built too. Only the last row is new work.

CapabilityWhere it livesStatus
Grade a whole thread, not just one replycerebrus/src/conversationEngine.tsexists
A 4-value verdict vocabularypass Β· repair(span) Β· hold Β· blockexists
Capture a human πŸ‘/πŸ‘Ž plus principle chipsGradeVerdict (ADR-0128)exists
Turn a πŸ‘Ž into a regression fixture, nightlyD8 pipeline sweep β€” 6 of Cerebrus's 30 cases came from itexists
Post narrative updates into apartment channelsAgent Smithexists
Do any of it in Slack, where people actually areβ€”missing

2 Β· Why not one number for a conversation

The instinct to reach for 1–10 or 1–5 is the common one, and both the literature and our own measurements point away from it.

What the research says. Binary criteria yield the highest inter-rater reliability, because there are only two categories to distinguish. Likert scales' inherent subjectivity limits reproducibility, and decomposing a multi-dimensional Likert rubric into fine-grained binary criteria measurably improves agreement. Where a graded scale is genuinely wanted, the recommended ceiling is 0–3 or 0–4 β€” not 1–10.

There is a second problem with a scalar, independent of noise: it gives nobody anything to do. "4/10" does not say what to fix. missed_followup: FAIL does.

What to use instead

So a post leads with BLOCK Β· unbacked promise, not 4/10.

This argues against a single composite score for a conversation. Per-dimension numbers, each with a stated reason, are a different question and a better idea β€” see Β§6.

3 Β· What a post would look like

Concise by default. The dimensions are the payload; the prose is one line.

#apartment-camellia
Agent Smith APP Β· 9:14 AM
BLOCK Β· unbacked promise
Tour confirmation to Morgan β€” promised access instructions the reminder cannot carry (no arrival section on file for this property).
βœ“ followup_exists
βœ“ loop_closed
βœ— no_ungrounded_policy_claim β€” promised a send we cannot make
conversation Β· why this fired
πŸ‘ agreeπŸ‘Ž disagreeπŸ”§ fix this

Illustrative. The verdict, dimensions and reason above are the real shape of a Cerebrus conversation verdict; the message text is mocked.

4 Β· Grade the whole turn, not the reply

Rating only what Clara said is rating the last inch of a long pipe. The interesting failures happen upstream: she was handed the wrong facts, or she never called the tool that would have told her, or she looped. A verdict that says "the reply was bad" without saying which layer was bad sends an engineer to read a prompt when the real fault was an empty context block.

The data is already captured

This is not a "we'd need to start logging" project. Every claude_call step on an AgentTrace already persists:

Already stored per turnMakes gradeable
The full system prompt as sent to ClaudeContext β€” was the fact needed to answer actually in the block?
loopIterationLoop β€” how many passes, and did they make progress?
tool_execution steps with statusTools β€” were the mandated tools called? did one error and get quoted as policy?
stopReason, tokens, cache readsShape β€” truncation, runaway cost
The draft itselfOutput β€” tone, promises, numbers

There is already a viewer at admin/dev/agent-traces and a per-conversation trace endpoint. The grading surface would read what the trace tab already shows.

5 Β· Two kinds of dimension, and they route differently

"Context: 4, because we gave it too little context" is not a criticism of Clara. It is a criticism of us. That distinction is the most useful thing in the whole scheme, and it should be structural rather than left for a reader to infer.

Clara's β€” fix the prompt

tone Β· rude, cold, or cheerful about a complaint
pacing Β· replied when the conversation was over
output Β· invented a policy, quoted a wrong number
loop Β· didn't call a mandated tool, or looped without progress

A πŸ‘Ž here becomes a Clara fixture. The fix is a prompt change and it ships behind the gate.

Ours β€” fix the system

context Β· the fact needed to answer wasn't in the block
tools Β· no tool exists that could have answered
truth Β· the ledger itself was stale or wrong

A πŸ‘Ž here is not a prompt bug and no prompt change fixes it. It becomes an integration or data ticket. Filing it as a Clara failure buries it.

This matters more than it looks. The replay found that 75% of failures were missing-fact β€” Clara not knowing something. Graded as "Clara was bad" that is a demoralising, unactionable number. Graded as "our context was thin" it is a roadmap. Same rows, different queue.

6 Β· The scale, per dimension

Earlier this page argued against a number. That argument was about a single composite score for a conversation, and it stands. Per-dimension numbers with a stated reason are a different and much better thing β€” but the noise still binds, so the two kinds of dimension get different treatment.

KindScaleWhy
Deterministic
tool gate, fact-present, loop count, numeric match
PASS / FAILComputable from the trace with no model call, so it is exactly reproducible. A number here would only add false precision to something already certain.
Judged
tone, pacing, helpfulness
0–3The research ceiling for a rubric scale. At a measured 25% label churn you cannot honestly distinguish a 6 from an 8 on a 10-point scale β€” but "harmful / poor / minor / fine" survives that noise.

Never average across the two. A deterministic FAIL and a judged 3 do not combine into anything meaningful, and the moment they are averaged, adding easy deterministic checks starts inflating the score.

7 Β· Where the feedback attaches

The real tension: one Slack message per Clara turn is precise but floods the channel; one post with a table of turns is compact but you cannot react to a table row. Numbering the rows and replying "message 4 is bad because…" solves it on paper and fails in practice β€” free text has to be parsed, and a reference to "message 4" breaks the moment anyone edits the post.

Doesn't work

One post per turn in-channel β€” a 6-turn conversation is 6 posts. The channel becomes unusable and everyone mutes it.

One post, numbered table, reply with references β€” "msg 4 bad because ABC" is free text someone has to parse, it breaks on an edit, and two people replying at once produce ambiguity about which row.

Works

Parent = conversation verdict, posted only if a turn was flagged. Worst dimension in the header.

Thread reply = one flagged turn, carrying its own dimensions. πŸ‘Ž on that reply targets that turn, structurally. A written reply under it is a comment on that turn and needs no reference at all.

Passing turns get nothing β€” most turns pass, and a thread of "fine" is the same noise problem one level down.

What a flagged turn looks like in the thread

Turn 3 of 6 Β· Clara β†’ Morgan Β· 9:14 AMBLOCK Β· unbacked promise
contextFAIL← oursno arrival section for this property, and nothing in the block said so
loopPASS3 tool calls, all mandated fired, no repeats
outputFAIL← Clarapromised a send the system cannot make
tone3/3
pacing3/3
what she was given Β· what she called Β· what she wrote β†’ trace

Illustrative. The dimensions and the two-kinds split are the proposal; the message is mocked. Note the header verdict names the output failure, but the row an engineer needs is context β€” which is why both are shown rather than one summary.

Some dimensions are conversation-scoped, not turn-scoped

"She replied when the conversation was already over" cannot be judged from one turn in isolation β€” it is a property of the thread. Same for "never followed up" and "the escalation never closed". Forcing every dimension to the turn level would make these unanswerable; forcing them all to the conversation level loses the precision that makes a πŸ‘Ž actionable.

So dimensions carry a scope, and the parent post shows the conversation-scoped ones while each thread reply shows the turn-scoped ones. pacing appears on the turn that shouldn't have happened; followup_exists appears only on the parent.

8 Β· How a grade should read, and how it shouldn't

Four pairs. Each left-hand version is a plausible thing to build and each one fails for a different reason.

A scalar tells nobody what to do

Don't

Camellia β€” conversation quality Score: 6/10

Nothing here is actionable. 6 of what? Which part was bad? And on a re-run this is a 4 or an 8 β€” the same conversation, a different number.

Do

BLOCK Β· unbacked promise βœ“ followup_exists βœ“ loop_closed βœ— no_ungrounded_policy_claim

Names the failing dimension. An engineer knows which rule fired and where to look before opening anything.

An average hides the only thing worth reading

Don't

3 of 4 checks passed β€” 75%

A conversation that invented a policy is not 75% fine. Averaging lets one serious failure be outvoted by three trivial passes, and the number goes up when you add easy checks.

Do

BLOCK Β· unbacked promise (3 of 4 dimensions passed)

Worst dimension is the headline; the tally is context, never the verdict. Adding more easy checks cannot improve the grade.

Silence about what wasn't checked reads as a clean bill of health

Don't

PASS βœ…

This conversation may have quoted the wrong rent, been rude, or over-promised β€” none of which any deterministic predicate looks at. "PASS" invites everyone to stop reading.

Do

PASS on 3 checked dimensions not checked: tone, price accuracy, over-promise

States its own scope. A reader can tell the difference between "we looked and it was fine" and "we didn't look".

Every conversation is noise; only exceptions are signal

Don't

A post per conversation, all day, most of them PASS.

The channel becomes unreadable within a week and people mute it β€” which silently disables the whole system, because the loop depends on a human noticing.

Do

Post non-pass verdicts only, plus one daily line: 47 graded Β· 44 pass Β· 3 below.

The count proves the grader ran β€” a silent channel could otherwise mean "all good" or "the job died", and those must not look alike.

9 Β· The loop when we disagree

This is the valuable half, and it is well-founded: mining disagreement cases creates a natural priority queue for improving prompts and for focusing expensive human attention. Four of these five steps already exist.

01 Β· build
React in the thread
πŸ‘Ž on Smith's grade, with a reply saying what it should have done.
02 Β· build
Write a GradeVerdict
The reaction becomes the same row the in-app πŸ‘/πŸ‘Ž already writes.
03 Β· exists
Nightly sweep
D8 picks up the πŸ‘Ž, PII-gates and anchors it.
04 Β· exists
Becomes a fixture
A Cerebrus case that fails until the behaviour changes.
05 Β· exists
Gate holds it
The fix ships behind the same blocking verdict diff as everything else.

The only new pieces are a Slack event handler and a write into an existing store. No new pipeline, no second grading system, no parallel source of truth.

10 Β· What a useful disagreement looks like

A πŸ‘Ž is only worth collecting if it can become a test. The difference is whether the reply says what the right answer was.

Unusable

πŸ‘Ž  β€œthis is wrong”

πŸ‘Ž  β€œbad grade”

πŸ‘Ž  β€œClara was fine here”

No expected disposition, so nothing can be pinned. D8 would capture a row that no fixture can be built from, and it sits in the queue forever.

Usable

πŸ‘Ž  β€œShould be PASS β€” the arrival section does exist for Lampliter, so the promise was keepable. The grader read the wrong property.”

Names the expected verdict AND the reason. That is a fixture: same input, expected: pass, and it fails until the grader reads the right property.

Two different things arrive through the same πŸ‘Ž, and they must not be conflated:

11 Β· Worked example β€” a real defect, end to end

This one is real and shipped today, which makes it a fair test of the design. Nothing below is hypothetical except the grading steps themselves.

The defect. Clara's tour confirmation said "We'll text you access instructions about an hour before your tour." The T-1h reminder always sends, but carries arrival instructions only when the property has them on file β€” and renders silently without them when it doesn't. Across one client's 13 properties, exactly one had that content. So on 12 of 13 properties the promise could not be kept.

StepWhat the system would produce
1 Β· VerdictBLOCK Β· unbacked promise β€” no_ungrounded_policy_claim fails: the draft states a future action the system cannot guarantee. The other two predicates pass.
2 Β· PostLands in the property channel because it is not pass. One line of reason, links to the conversation and to the rule that fired.
3 Β· HumanReacts πŸ‘ β€” the grade is correct. No calibration case needed; this is a real defect.
4 Β· FixtureThe nightly sweep captures it: the draft, the property context showing no arrival section, expected: block.
5 Β· FixThe prompt stops promising contents it cannot guarantee. The fixture flips to passing, and the gate holds the line.

What actually happened instead: this was found by hand, in an eval-coverage audit, after it had been live long enough to be the largest single contributor to the hallucinated-policy rows in a client's conversation replay. It shipped as PR #6812. The system proposed here is the difference between finding that in an audit and finding it the first afternoon it happened.

12 Β· What Cerebrus can and cannot grade

That is the honest boundary. "Cerebrus grades the apartment channels" is true for a narrow, reproducible slice and misleading as a general claim. Everything past those three predicates needs the LLM judge lane β€” which is exactly the part carrying the 25% churn.

What fires, and what slips through

Real failure classes from the graded corpus, against what a deterministic grader would actually catch.

FailureExampleCaught by
Never followed upResident asks a question, thread diesfollowup_exists
Escalation never closedForwarded to a PM, no resolution ever recordedloop_closed
Invented a policy"We'll text you access instructions" with none on fileno_ungrounded_policy_claim
Quoted the wrong rent$1,950 when the ledger says $1,850turn-level only
Answered in the wrong languageResident writes Spanish, Clara replies Englishnothing
ToneCurt, or cheerful about a complaintnothing
Over-promised"I'll have someone there this afternoon"nothing
Blast buried a live questionA property-wide broadcast is the only "reply"nothing

Five of eight fall outside the conversation engine entirely. That ratio is the design constraint, and it is why every post has to state its own scope.

The practical consequence: ship the deterministic slice first and label it as such. A grade that is silent about what it did not check is worse than no grade, because it reads as a clean bill of health.

13 Β· Ways this goes wrong

Each of these has a specific, checkable early symptom. Worth agreeing them before building, because each is invisible from inside the system.

Failure modeWhat it looks likeEarly symptom to watch
Alert fatigueThe channel fills with grades, people mute it, and the loop silently stops β€” the grader keeps running and nobody reads it.Reaction rate per post falls. If nobody has reacted in a week, the system is off regardless of what the logs say.
Grade inflationAdding easy dimensions raises the pass rate without changing behaviour. Especially tempting if anyone reports the rate upward.Pass rate rising while the πŸ‘Ž count is flat. Never report a composite percentage as a health metric.
Right answer, wrong questionThe check is reproducible and confidently measures the wrong thing β€” exactly what the promise tracker does today.A dimension that has never failed. A predicate at 100% for a month is either unnecessary or broken.
Judge driftThe judged dimensions move because the model changed, not because Clara did.Re-grade a frozen sample on a schedule. If yesterday's conversations grade differently today, the instrument moved.
Calibration debt"Clara was fine, the grade was wrong" πŸ‘Žs pile up unfixed; people stop trusting the grades and stop reacting.Track grader-error πŸ‘Žs separately from Clara-error πŸ‘Žs. If the first is growing, stop shipping and fix the grader.
Scope creep into peopleThe grader starts scoring what a colleague wrote in a property channel.Any grade whose subject is not a Clara outbound. Make that structurally impossible rather than a rule.

14 Β· Open questions

  1. Can the grader even read the posts? Four of the six most recent Smith messages in #apartment-camellia came back with empty text through the Slack API, on both concise and detailed reads. If that is the known block-rendering behaviour, a grader reading via the API sees nothing. Worth confirming before designing around it.
  2. What is in scope? "Anything under apartment-*" includes calls, texts, bookings and human chatter. Grading a colleague's message is a different product from grading Clara's. Recommend: Clara's outbound only, at least to start.
  3. Who judges the non-deterministic slice, and against what floor? Given the measured churn, any judged dimension needs multi-sample or majority grading before it is allowed to colour a post β€” otherwise the same conversation grades differently on Tuesday.
  4. Does a Slack reaction carry the same authority as an in-app one? GradeVerdict.graderUserId is a foreign key to a real User. Slack identities must map onto that, or the provenance of a verdict gets weaker exactly where it matters.
  5. Cadence. Per-conversation posts will bury the channel. A daily digest with only the non-pass rows is the likelier shape.

15 Β· Recommendation

Build the surface, not the system. Wire Smith to read Cerebrus's verdicts and the AgentTrace that already sits behind every turn. Post one parent per conversation into the property channel, only when a turn is flagged, and one thread reply per flagged turn carrying its own dimensions. Let a πŸ‘Ž on a thread reply write the GradeVerdict row the nightly sweep already consumes.

Grade every layer, and say which one failed. Context, loop and output are separate rows, and each is marked as ours or Clara's β€” because a thin context block and an invented policy are different bugs with different owners, and merging them into one score buries the more common of the two.

Start where it is reproducible. The deterministic dimensions β€” was the mandated tool called, was the fact in the block, do the numbers match the ledger β€” need no model call and cannot drift. Add judged dimensions (tone, pacing) one at a time, each on 0–3, and only let one colour a post after its agreement with a human has been measured against the 0–3 point noise floor.

Explicitly do not: invent a 1–10 score, average across deterministic and judged dimensions, post a passing turn, or let a PASS go out without naming what it did not check.

Sources

PropFlow Docs