tootsies

a discord bot for the tootsies server. ask, recap, discuss, ship features by typing.


Project maintained by mejasonmejason Hosted on GitHub Pages — Theme by mattgraham

Engineering rules register

The extensible home for engineering rules — curated from the strongest public practice (Anthropic’s agentic-coding guidance, the Karpathy CLAUDE.md line + community extensions, Sentry’s and OpenAI’s agent guides, Cloudflare’s template standards, HumanLayer) and adapted to this repo. Skim at the start of any non-trivial change. CLAUDE.md’s always-loaded judgment section stays tiny; rules land HERE.

The register’s constitution. A rule needs a provenance tag plus either a local incident or strong multi-source consensus; personality rules (“act like a senior engineer”) are banned as measured no-ops; a rule that measures as a wash gets cut (the docs/PROMPT_OPTIMIZATION.md discipline, applied to rules about ourselves); and promotion into CLAUDE.md usually trades against something coming down — the always-loaded file does not re-grow. Rules are kept to their load-bearing form: if a sentence only restates its rule, it goes.


Scope and diffs

Surgical changes — clean up only your own mess. [Karpathy] Touch only what the task needs: no drive-by reformatting, renames, or refactors of adjacent code. Cleanup is scoped the same way — remove only what YOUR change orphaned; pre-existing dead code gets mentioned (PR body / an issue), never silently deleted.

Ship big refactors in reviewable stages. [community] A multi-step migration lands as a sequence of individually green, individually revertible PRs — mechanical rename, then behavior change, then cleanup. A stage that can’t go green alone is a design smell in the split, not a reason to merge red.

Deciding and assuming

State assumptions; verify the cheap ones. [Karpathy] A load-bearing assumption gets verified (most are one query/grep away here) or stated where the reviewer will see it. “Assumed X, unverified” is fine; silence is not.

Surface conflicts — never average them. [community] When two parts of the codebase, a doc and the code, or two owner steers disagree, name the conflict and pick a side explicitly — owner for judgment, evidence for fact. Blending produces code that half-follows both and launders a decision nobody made.

Time-box the rabbit hole. [community] Past ~2× the effort you’d have estimated, stop digging: restate the problem, switch approaches or surface it. Sunk effort is not evidence the approach is right (#567: the step-back found a 10-line answer to a 300-line grind).

The second patch to the same abstraction is a redesign checkpoint. [OpenAI] Adding a second condition/branch/special case to something you already patched once means stop: re-derive the abstraction from the full case set — one redesign beats accreting compatibility branches (#824: banning crutch phrases one at a time never converged; the self-dedup redesign did).

A significant behavior change states its scope contract. [OpenAI] In the PR/issue: the required behavior, plus what is INTENTIONALLY unsupported and how it fails (skip? fallback? raise?). The unstated-unsupported case is where the next incident lives. (docs/INTEGRATIONS.md’s omitted-and-why column, applied at the behavior level.)

The deterministic / model-judged boundary

Repeatable behavior lives in code; model judgment is for taste. [OpenAI ext.; this repo’s architecture] Retries, routing, thresholds, caps, settlement math, dedup keys — same inputs, same outcome — are deterministic code with tests; the model judges language, relevance, quality. A number or branch decision living inside a prompt is a bug of this class. The mirror holds too: don’t hand-code brittle heuristics for a genuine judgment call (the classifier-vs-regex history).

A model’s DECLINE has to be recognised in every shape it arrives in, because a missed decline is published as content. [this repo: 2026-09-06] Every compose surface skips by returning the single word EMPTY, and is_empty_response decides. It matched a LEADING sentinel only, so a decline that put the reasoning first and the sentinel last read as a real post and shipped: four went to the live rooms over two days, each one the model’s private reasoning about why it was declining (“…same story as the last read.\n
nEMPTY”). The self-gate cannot save you here – it scored them 0.72 to 0.78 because every claim in them is true and grounded, which is all a gate grades. Two rules follow. Match the sentinel at BOTH ends (and at the tail, by SENTENCE, so a post ending “…and the room went empty.” survives). And when a sentinel is the contract between a model and the code, enumerate the shapes it actually arrives in before trusting one: replayed over 470 shipped posts, the widened match caught exactly the 4 bad ones and nothing else.

A compose self-gate may only grade against a claim that passed a truth check upstream. [this repo: #1890, #2035, #2043] Every compose surface self-gates by scoring the take against the ground-truth blob built FROM the claim, so the gate answers “is this take faithful to the claim” and can never answer “is the claim true”. When the claim carries a wrong number the gate scores the post high and is CORRECT to (#1890: the post said “back at No. 1” off a chart reading #2, gate 0.91; #2035: the wire said 6 songs, the verify judge said 7, the post shipped saying 6 at 0.90). The invariant a new claim path must satisfy: before compose, the claim is either a FIRST-PARTY read (we fetched the chart/market/score ourselves) or it passed a verify verdict (and a CONTRADICTED verdict drops the claim, it does not soften it). The gate is never the backstop for claim truth — do not respond to a wrong-claim incident by tuning the gate. Audit note (2026-08-09): music_news satisfies it (live-chart settle

A DRY RUN must exercise the real SHIP PATH, not the model calls alone. [this repo: #2662/#2671] A compose surface has three stages: the compose, the deterministic guards, and the self-gate. A dry run that calls compose_* and *_score directly exercises two of them and SKIPS the guards, which live in the cog between them. That gap is not theoretical: the takeover board’s dry run passed 4 of 4 at 0.95, and every one of those takes would have been dropped by _unshippable before the judge ever saw it, because the board’s headline matched has_row_tally. The feature would have shipped, gone silent, and looked like a quiet chart. Only the code review caught it.

So a dry run of a gated surface calls the guard the cog calls, in the order the cog calls it, or it says plainly which stages it covered. The stronger form, where the surface allows it, is to drive the cog’s own compose method with a fake delivery. The rule generalizes past this repo: a “works end to end” claim is only as wide as the narrowest stage the run actually touched, and the stage most likely to be skipped is the cheap deterministic one nobody thinks of as behavior.

Tests

Tests pin behavior, and a regression test must fail first. [community; generalizes the golden rule] A test written for a fix runs RED on the pre-fix code or it pins nothing (#1886: asserts on shapes the gate rejected anyway pass whether or not the filter works). Assert outputs, state changes, emitted events — not call counts or internal ordering. A test needing three mocks to reach its assert usually means the pure core hasn’t been extracted yet (utils/ split).

Rules, checks, and corrections

Mechanize any rule that can be a check; a repeated correction demands it. [Cloudflare / HumanLayer / claude-md-templates] A convention worth keeping becomes a test, lint, hook, or CI gate — prose rules decay under context pressure, checks don’t (the test_event_schema ratchet, output_checks, the repo-root allowlist). When the owner corrects the same CLASS of mistake twice, that session ships the durable artifact — a check, else a rule here — in the same breath; “noted” without an artifact is how the third occurrence happens.

Review findings stay scoped to the patch. [OpenAI] Complexity is a real finding only when the machinery isn’t required by the task or a verified risk — don’t accept speculative abstractions, knobs, or parallel paths a reviewer imagines wanting. Unrelated cleanup a review surfaces gets its own issue, not bolted onto the PR.

Workflow hygiene

No user content or PII beyond the surface it serves. [Sentry] Real user messages or usernames-plus-content never land in commits, PR bodies, issues, or logs — the constitution’s data-minimization rule covers our own dev artifacts too. Repro fixtures use synthetic text.

Leave a trail a cold session can follow. [HumanLayer; this repo’s doc culture] Owner steers, incident lessons, and “why it’s built this way” go in the durable homes (the surface’s docs/ARCHITECTURE.md section, the tracking issue, the PR body) — not chat. The test: could a fresh session pick this up from the repo alone?

A scheduled check-in points at state; it does not CARRY state. [owner steer 2026-08; observed] list_triggers showed check-in prompts grown to 2,000+ words, one carrying an incident narrative, a pending owner decision, an unpushed local branch name, and three Axiom queries. That state lives nowhere else: not the issue, not the PR, not the repo. If the trigger fires into a dead session, or nobody reads it, the knowledge is gone. So the durable state goes on the tracking issue and the prompt says which issue to read and what to check — one or two lines. Same test as the rule above: the repo alone must be enough.

A PR that does not target main gets NO CI, and its check list still looks green. [measured 2026-08-10, #2266] .github/workflows/ci.yml triggers on pull_request: branches: [main]. Three cinema PRs were split into a hand-rolled stack, each based on the branch below it. claude-code-review.yml has no branch filter, so it ran on all three and reported success — and a check list holding one green claude-review and nothing else is, at a glance, indistinguishable from a fully green PR. None of the ci.yml checks had run on any of them. Nothing was red, so nothing announced the gap; the only reliable signal is the presence of the CI check runs by NAME, which is why this rule is “count the checks”, not “look for red”.

ci.yml posts ELEVEN checks, and a green subset is not a green suite. Count all of them: lint-typecheck, test (1) through test (8), coverage, and fts-postgres. lint-typecheck runs ruff, mypy AND the repo-linting tests (LINT_STYLE_TESTS in tests/_sharding.py), so a red one there is a real test failure, not only a lint slip. The coverage job is the one that enforces the 50% floor: each test shard measures only its own eighth of the suite, so coverage combines the eight and gates on the total. A run whose shards are green while coverage is missing has NOT been gated.

The shard COUNT lives in one place, .github/workflows/ci.yml (the matrix and the --shards argument must agree). If you change it, change this line and the CLAUDE.md Commands section too – a count that drifts turns “count the checks by NAME” into a rule nobody can apply.

There are two safe ways to split a large change, and hand-rolled base chaining is neither:

  1. Sequential PRs. Land one, git fetch origin main, branch the next off the updated main. Every PR targets main, so every PR gets the full gate. This is the default and needs no tooling.
  2. A real GitHub STACK, via the gh stack extension (public preview since 2026-07-30). GitHub then treats the chain as a stack rather than as PRs that happen to point at each other: “CI checks triggered by pull requests on your default branch run for all pull requests in the stack, not just the bottom one,” and “branch protection rules … are enforced on every pull request in the stack, even mid-stack pull requests that don’t directly target your default branch.” Stacks merge bottom-up; merging mid-stack merges everything below it and the PRs above “stay open and automatically re-target the stack’s base branch”. Commands: gh stack add <branch>, gh stack push / gh stack submit, gh stack sync, gh stack rebase, gh stack modify.

Recovering a stack you have already hand-rolled: retargeting the base to main is NOT enough on its own. A base change fires the edited event, which ci.yml does not listen to, so the PR keeps its empty check list. Retarget and then push — merging main into the branch fires synchronize, which CI does listen to. Expect the review NOT to re-run (the workflow skips synchronize); the earlier review still stands if the merge changed no code it read.

Sources: Stacked pull requests, About stacked pull requests, Stacked PRs CLI commands.

A WORKFLOW THAT COMMITS TO main RESTARTS THE LIVE BOT. Give it a watch-path exclusion. [#3340, 2026-09-15] Railway auto-deploys every push to main, and a redeploy is a full bot restart: a fresh Discord login, a re-sync of 21 slash commands per guild, and an SGO circuit breaker that trips open on boot. A committing workflow therefore costs one restart per run, and the cost is invisible – each deploy reports SUCCESS.

.github/workflows/sleeper-fantasy.yml runs hourly and commits sleeper-fantasy/data, which no module in the bot reads. That was about 20 restarts a day for data the running process never opens. On 2026-09-15 the churn earned a Cloudflare block on the Discord login call: the 14:26 deploy got 429/1015, the 14:49 deploy got a Cloudflare 500, both failed their healthcheck, and /menu answered “This interaction failed”.

Two rules follow:

Source: Railway watch paths.

Shipping and closing out

These are the procedures behind CLAUDE.md’s always-loaded triggers. CLAUDE.md says WHEN; this section says HOW.

A NEW SCHEDULED SURFACE MUST BE GIVEN CALENDAR SLOTS, or it ships dark and silent. [owner decision 2026-08-31, #2315] Do this as part of shipping the surface, not after someone notices the silence.

schedule_calendar.calendar_hours treats a mood as calendar-managed the moment that mood’s key exists in the stored post_schedule JSON. For a managed mood it returns the surface’s assigned slots, and a surface with NO entry returns [] – which is deliberately distinct from None (no calendar at all, use the cog’s hardcoded pool). So in a guild that already has a managed calendar:

calendar_hours(...) -> []      # managed mood, no slot for this surface
due = []                       # ScheduledPoster._maybe_scheduled_post
return                         # every tick, forever

The cog’s own CHILL_TIMES / YAPS_TIMES are dead code there. Nothing raises, nothing is emitted, and no post is ever built, so the surface looks healthy at every other checkpoint: the cog loads, the channel is configured, the experiment resolves, the kill switch is off. sports_boards (#2293) sat like that from 2026-08-11, and the tell was zero rows in its own slot table, not an error anywhere.

So a new scheduled surface needs ONE of:

That is why the other recent surfaces were fine and this one was not: they are on the firehose list. Check which applies before you call the surface shipped, and put it in the PR’s test plan.

The fallback was proposed and REJECTED — do not re-propose it without new evidence. Making an absent surface fall back to its hardcoded pool would close the class, but it changes scheduling for every existing surface and could start posting something a mod believes is switched off. The owner’s call (2026-08-31) is that a silent-POST failure is worse than a silent-DARK one, and that the calendar stays the single source of truth for when a surface posts. The trap is closed by this written step, not by code.

The session-close miss sweep — the five questions. [owner steer 2026-08] Before handing a session back, run these in order and answer each in one line:

  1. The class sweep. Where else does this exact shape live? (The same question as “fix the class, not the instance” — this is the backstop for when it got skipped mid-task.)
  2. Scope I cut. What did I narrow, defer, or leave for later, and did I say so out loud?
  3. Coverage gaps. An untested branch, a fail-open path with no telemetry, a surface with no dashboard panel, a doc left stale.
  4. What surprised me. Anything the docs did not predict — usually the most valuable issue in the sweep.
  5. What I could not verify. A path the dry run did not reach, an assumption I shipped under.

Then file per the “Issues as a build log” bar: a real gap earns an issue, noise does not. Attach each to its epic with a p0p3 label at creation. The body says what is missing, why it was out of scope, and what “done” looks like; cross-reference the PR that surfaced it. Report what you checked and deliberately did NOT file, not only the hits — a sweep that reports only hits reads as thorough while hiding what it dismissed. A clean sweep is a complete answer; say so plainly. Never file a follow-up as a substitute for finishing in-scope work; when unsure which you have, ask rather than file.

Worked example: the sweep on #2060 found the eval report is discarded, then found the ops monitor discards its report the same way — a class caught within minutes of the instance (#2061).

The post-ship verification Routine. [owner steer 2026-08: “sometimes we ship big changes that dont work or go silent”] A merge is not a landing. Railway auto-deploys main and the shipping session ends minutes later, so a change that fails to deploy or goes silent has nobody watching it.

When it applies: a new surface or cog, a new outbound integration, a change to what a LIVE surface posts or when, a schema/migration change, and any bug fix whose symptom was silence. Not docs, not a pure refactor, not a test-only change, not a tunable nudge.

How to build it: pick the SHAPE first — see “USE A ONE-SHOT. THERE IS NO CRON OPTION.” below, which is the binding rule and gives the two allowed shapes and their timing. Never a cron, whichever you pick.

Whatever the shape, the prompt is a COMPLETE standalone instruction naming the PR, the issue, the surface, the exact check, and the pass condition — and it points at the issue for state (see the rule above). A fresh-session Routine (create_trigger with create_new_session_on_fire: true) needs that completeness most, since it starts from nothing; a send_later bound to the shipping session inherits that session’s context and only needs the delta. Allow time for Railway to build AND for the surface to reach a slot — about two hours after merge for a fresh-session one-shot, which gets a single attempt.

The fired session inherits Railway and Axiom access — do not override the environment. The verification steps below need both: Railway answers “did it deploy”, Axiom answers “is it alive”. The environment carries RAILWAY_API_TOKEN, RAILWAY_SERVICE_ID and AXIOM_API_KEY, and create_trigger gives the fired session the calling session’s environment when you leave environment_id unset. So leave it unset. Passing a different environment_id moves the Routine to an environment that may not hold these keys, and the verification then fails for a reason that has nothing to do with the change it is checking.

Verified 2026-08-09 from a session in this environment: the Railway GraphQL service query returned HTTP 200, and the Axiom _apl query returned HTTP 200 with 201,337 rows over 24h. Read AXIOM_API_KEY straight from the environment; it does not need a Railway round-trip.

The fired session is HEADLESS, so it must never meet a permission prompt. A fresh-session Routine starts with no person at the keyboard. A tool call that asks for approval gets no answer, so the session stops at that call and the check never completes. The Routine does not report a failure — it goes quiet, which is the same symptom the Routine exists to catch.

.claude/settings.json sets permissions.defaultMode to bypassPermissions, so Claude Code runs every tool with no prompt. Do not replace this with a list of allowed tools. A list only covers the tools we predicted; the first tool outside it stops the session, and it stops silently. That is what happened before 2026-08-09.

Two traps, both seen in this repo:

The named permissions.allow list stays in the file as a fallback, for the case where a policy turns bypass off. The guards that keep a headless session safe are not permission checks, and they still hold: no direct push to main, the owner merges protected-path changes, /debug/query is read-only, and the production database is unreachable from a session.

What to check, in order:

  1. Did it deploy? The Railway deployment for the merge commit is SUCCESS, not a build failure or crash loop.
  2. Is it alive? The surface’s events appear AFTER the deploy time — compared against the rate BEFORE the change, not against zero. A surface emitting nothing looks identical to a quiet hour, which is how a silent ship survives a naive check. ['tootsies'] | where surface == '<surface>' | summarize count() by bin(_time, 1h)
  3. Is it healthy? No new error rows from that source, ok rates steady, and the metric the change was supposed to move actually moved in the predicted direction.

It must reach a terminal state. A Routine that keeps firing trains everyone to ignore it. Verified → tell the owner what the numbers show. Broken → fix it or open the revert, then tell the owner. Inconclusive by ~48h after the merge → hand it over in plain words: what you checked, what you could not confirm, what you would look at next.

Every one of those is a REPORT, not a deletion. Under the one-shot rule below the Routine has already expired on its own, so nothing needs deleting — and a fired session normally could not delete anything anyway. delete_trigger belongs to the shipping session and to the close-out sweep, never to the Routine’s own prompt.

USE A ONE-SHOT. THERE IS NO CRON OPTION. [measured 2026-08-29] A Routine must expire without anybody calling delete_trigger, because the session that would call it usually cannot. create_trigger stores the calling session’s MCP connector grants, and a Claude-on-web session normally holds none to pass through (the tool says so in a warning on the response). The fired session then has NO mcp__* tools at all — list_triggers and delete_trigger do not exist in it — so a cron Routine fires forever no matter what its prompt says. [verified 2026-08-10, PR #2275]

The evidence that this is the NORMAL case, not the edge case: a sweep on 2026-08-29 found 11 stale verify Routines, every one of them cron, every one carrying a self-delete step, and not one of the fired sessions holding a single mcp__* tool. The oldest was 4 days past its own 48h deadline (#2547); others covered #2620, #2636, #2644, #2645, #2650, #2658, #2660, #2667, #2671 and #2680. They fired on schedule, correctly judged themselves “not terminal yet”, and re-armed — forever. Nothing was broken. The recipe was.

NO mcp__* MEANS NO GITHUB TOOLS EITHER — write every prompt for gh over Bash. The missing connectors break more than self-deletion, and this is the second thing to bite [independently reproduced 2026-08-29 shipping #2691, and visible in the sweep’s own data: the fired sessions’ allowed_tools is Task, Bash, Glob, Grep, Read, … with not one mcp__ entry, though Bash is there]. A prompt that reaches for mcp__github__* to read an issue, comment a finding or open a fix PR calls a tool that does not exist, and the headless run stops at that call — silently, which is the same symptom the Routine exists to catch. So say in the prompt that GitHub is reached with gh through Bash, and use gh api REST specifically: the GraphQL endpoint is blocked there, so gh issue view fails while gh api repos/{owner}/{repo}/issues/{n} works. The same goes for the owner handoff below — it has to travel by the Routine’s completion notification and the session transcript, not by an MCP call.

Cron is not offered even behind a “only if you confirmed the connectors” condition. That condition is exactly what the old recipe had, and it is what failed: an exception a creating session has to notice in a tool response is an exception nobody reads. The retry cron buys is small — a Railway build takes minutes — and it is not worth a watcher that cannot stop. Pick one of two shapes:

  1. send_later, bound to the SHIPPING session — use when you will keep that session open across the window (you are still working in it). It is a one-shot, so it disables itself after firing, and it fires into a LIVE session that does hold the MCP tools, so that session can re-check, re-arm another send_later while the answer is genuinely pending, and stop when it is not. That chain is the retry mechanism, and it terminates because a human-facing session decides when to stop. #2684 used it; it closed on the second firing. Measured over ~9h and ~10 firings: a self-bound send_later keeps reaching the same session while that session is ACTIVE, across container restarts — the tool documents that delivery survives them. It is a CLOSED session that breaks it: if the session is gone when the delay expires, the check is simply lost, and nothing reports the loss. So when you are not sure the session will still be there, use shape 2.

  2. run_once_at, fresh session — the default when the shipping session will end, and the safe answer whenever shape 1 is in doubt. It gets ONE attempt and terminates by construction. Place it LATE, about two hours after the merge rather than one, so a slow Railway build cannot burn it. The prompt must say three things: that the Routine is a one-shot, that it must NOT try to delete itself or call any mcp__* tool at all (there are none — see the gh rule above, which covers GitHub work too).

A one-shot that cannot conclude must HAND OFF, not just expire. One attempt means the surface may still have been quiet — which is the state this whole section exists to tell apart from a dead surface, so an expired trigger is NOT by itself a terminal state. The prompt must require the fired session to end by reporting to the owner in plain words: what it checked, what it could not confirm, and what it would look at next. Turn on the Routine’s completion notification so that report actually arrives. Deploy status and error counts are conclusive on a single attempt; only the “did the surface fire” half can come back unresolved.

And NOTHING catches that half for you, so an unresolved liveness check MUST become an issue. surface_dark is not the net. It reads JUDGE SCORES, gated on qs.n >= DARK_SCORE_MIN and below_rate >= DARK_SCORE_RATE — at least five SCORED posts, 90% of them under the ship floor. A surface producing NOTHING has no scores, clears no minimum, and trips nothing. scripts/ops_monitor.py says so about itself twice: “a desk refusing everything would show no scores at all and read as a quiet news day”, and “lane_dark and surface_dark both read judge scores, so neither can see it, and the lane just goes quiet like a slow chart week”. Zero events is exactly the shape both sentences describe. [PR review catch, 2026-08-29: an earlier draft of this paragraph claimed the monitor covered it, which would have told a future session it was safe to let the check expire — a false reassurance on the one failure mode this section exists for.] So when the single attempt could not confirm the surface fired, FILE THE FOLLOW-UP. Do not leave a watcher running, and do not assume anything downstream is watching. #2694 is the worked example.

Name it for one thing anyway. verify-pr-<number>, with the merge time as literal text in the prompt. Neither shape needs the name to delete itself, but the sweep below needs to recognise it, and so does anyone reading list_triggers later.

Keep the sweep as the backstop. Self-deletion cannot be guaranteed, because the agent that deletes the Routine is the same agent that might be broken. Run list_triggers during the session-close sweep and delete stale ones. If you find one you cannot account for, say so rather than deleting it silently.

Before deleting a verify Routine, CHECK ITS SUBJECT rather than clearing the list: confirm the PR merged and the surface is healthy (no unrecoverable errors, no surface_dark, the surface still posting). Deleting a watch on something actually broken is worse than leaving it. And honour any unresolved item the Routine’s own prompt names — the #2667 one could never observe its new album source inside a 9-day dedup window, and its prompt said to file a follow-up if it closed unconfirmed, so the cleanup filed #2694 instead of dropping the question.

A stale Routine is easy to spot in the list_triggers payload: it has a cron_expression, and its session_context.allowed_tools contains no mcp__* entry — that combination cannot terminate.

Dashboards and monitors are part of instrumenting a surface. [owner steer 2026-08] An event nobody graphs is a log line, not a metric. scripts/axiom_setup.py is the single source of truth for both — dashboards upsert by uid, monitors by name, so it is idempotent (python -m scripts.axiom_setup --dashboards --monitors). Every new event gets a PANEL (volume, ok rate, and the latency percentile when it carries duration_ms). Then ask whether it earns a MONITOR: a threshold worth waking someone for — a hard blackout, an ok rate through the floor, a quota about to run out — rather than a number worth looking at. Say which you added and why you skipped the other; “no monitor, the ops-monitor finding covers it” is fine, silence is not.

This applies to a CHANGED surface too. When a change moves what a surface emits, the panel and any monitor threshold reading the old shape are now wrong, and a stale panel reads as healthy while it measures nothing. Re-point them in the same PR and state which metric moves and in which direction. Worked example: #2052 drove has_link to False across ~23% of crossposts — an expected step down that would otherwise look like a compose fault.