Skip to content

main onto dev: the branch carries the workflows its gate needs - #353

Open
henokteixeira wants to merge 72 commits into
devfrom
main
Open

main onto dev: the branch carries the workflows its gate needs#353
henokteixeira wants to merge 72 commits into
devfrom
main

Conversation

@henokteixeira

Copy link
Copy Markdown
Contributor

Carries main onto dev so that dev holds the two workflows the gate is made of: joaozinho-review.yml with the verdict mode chosen by base branch, and request-joaozinho-on-dev.yml, which asks the reviewer on every head that reaches a pull request here.

The order matters. dev's protection cannot require joaozinho / review or request-joaozinho before the workflows that produce them live on dev, so this merge comes first and the protection is applied straight after. Once it is applied, a direct push to dev is refused, which is why this is a pull request and not a push.

This pull request is also the first live exercise of the new workflow: its base is dev, so request-joaozinho runs here, the reviewer is requested with nobody clicking, and joaozinho / review reports in block mode. That is where the seventh required context gets its name read off the API rather than guessed.

🤖 Generated with Claude Code

joaocarvoli and others added 29 commits September 8, 2026 17:08
The ladder counted the assessor's own failures — three in a row and the room voiced
category E, asked for a person, and stopped. The component it was counting is being
removed, and a count of calls to something that does not exist cannot be what decides
a session is over.

A failed call still degrades the turn through the unrepairable fail-safe. What asks
for a person lives outside the turn and is unchanged: the tablet raising a halt, the
retelling ceiling on a stretch, the back-translation ceiling, the device halt.

The tests that drove the ladder go with it, over the service and over HTTP. The
durability property the pause test held — a landed turn releasing NEEDS_PERSON in the
same request — is already read from the tablet's own halt in
`test_facilitator_attends_a_halt.py`.

Falsificado: fazendo o mesmo ramo devolver a linha E com needs_person, o teste dos
seis turnos falhando volta a falhar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015kREqtsHhqBXCUPMTvaYvB
…ped to where they live today

Marcia's ruling (04/09) bans six mechanisms from code — word ceilings, probe/station
contracts, memory windows, "say less" notes, a non-frontier model on the voice, app-owned
conversation modes — and her own guard, check-doctrine.mjs, already fails
Tripod-Internalization's build if any of them returns. Ours is Python, so her regex walking
.ts/.tsx/.mjs/.js files never saw app/services/internalization_room or
app/api/internalization_room, and every one of the six sat there unguarded while a ladder
of separate tickets prepares to remove them.

check_doctrine.py carries the same six rules and the same message per rule, in the Python
spellings the room's code actually uses, scanning only the four locations the voice path
lives in — the two app/ trees the ladder is stripping, and the two model files carrying
bridge_mode as session state. doctrine_allowlist.py names every (file, line, rule) the scan
finds there today, generated from the scan itself rather than typed by hand, so day one is
exactly caught up with the real tree. A hit outside the allowlist is a violation, and an
allowlist entry the scan no longer confirms is reported too, so a removal ticket that
deletes the code but forgets its allowlist rows does not pass by accident.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JvqTvy6R4L5GHLKv3UvmzW
Nothing in the repo runs pre-commit today — no .pre-commit-config.yaml, no active hook —
so the ticket's "where the tests and linter already run" means two things: a CI job, and
new opt-in local infra. The job lives inside lint.yml rather than a dedicated workflow, so
it inherits the push/pull_request trigger tests/test_ci_gates.py already pins by job
subset, instead of needing that file's GATES table extended for a new workflow file.

doctrine now runs alongside ruff, boots and mypy on every PR and every push to
integration/**. .pre-commit-config.yaml — the repo's first — wires the same script as a
local, opt-in hook for anyone who installs it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JvqTvy6R4L5GHLKv3UvmzW
The room measured every spoken turn against a word-and-sentence ceiling and threw away
whatever went over it: forty-five words on an ordinary turn, ninety on a panorama, and a
wider one for an opening told in two movements. A draft that busted it was redrafted twice
and then replaced by a fixed line, so the turn a team had just answered came back as a
canned sentence — and a fail-safe carries no movements, so the opening that was meant to
arrive as two clips arrived as one. Length is prompt style, never a reject: brevity is
asked for in the Guide's own prompt now, and the ceilings, the `budget` parameter the three
entry points carried them in, `speech_budget_for` and every caller of it go.

The two redraft notes that told a Guide to redo it "dizendo menos" lose that clause here
rather than in the move that will give them a file of their own. A request to be understood
is answered whole, and carrying the sentence into a module created today would plant a
forbidden mechanism in a file with no history to explain it.

`test_a_turn_after_the_opening_still_answers_to_the_budget` was green on the wrong path all
along: it built its `HeardSpeech` with `transcript=` and `is_substantial=`, neither of them
a field on that model, so the turn fell to the inaudible fail-safe and never reached a
ceiling at all. What replaces it drives the same seam with `text=` and watches the speech
come out whole.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gp2EMCdkt43sLZjE8JNV1Z
…a module

`run_turn.py` held eight unrelated jobs, and a wave admits one ticket per file, so every
change that touches the turn queued behind every other one no matter what its dependencies
said. The coverage status and the meaning map are the first seam out: they are what fills
the two `{{...}}` slots the Guide's system prompt is rendered with, they read the canon and
the coverage spine and nothing else, and no test names them.

The two functions move byte for byte, docstring included, and `run_turn.py` imports them
back, so every caller and every test still reaches them where they always were. The three
canon and coverage imports go with them; this module is the only place that needed them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gp2EMCdkt43sLZjE8JNV1Z
…f its own

The second seam out of `run_turn.py`: what the Guide and the Validator are told every turn
that is neither the map nor the coverage state. The three opening instructions, the mark
that divides an opening into its two movements and the splitter that reads it, the sentence
that stands where the team's utterance would on a turn that had none, and the line that
tells the Validator a block does not apply to this turn — all of it is prompt text, and none
of it knows anything about drafting or gating.

The Validator's user message was the last literal still written inline at the call, so it
comes out as `VALIDATOR_USER_MESSAGE` and the engine references it: a prompt string reads as
prompt text next to the others, not as an argument. It is the only line here that is not a
byte-for-byte move. The window `recent_conversation_block` slices with stays out — the whole
conversation is meant to go into context, so what belongs in a module about rendering a
conversation is never a slice of one.

`OPENING_MOVEMENT_MARK` is the first name `run_turn.py` now imports only to hand on, so
`__all__` starts here; the tests that import it from this module keep working unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gp2EMCdkt43sLZjE8JNV1Z
…s own

The third seam: turning the Validator's reply into a verdict and its `issues` into rows
every reader can ask for a `problem`. Both are pure readers of a model's output — they
take a string and give back a shape, they decide nothing about the turn, and they are the
half of the loop that has to keep working when the reply is not what anyone expected.

The two functions and the unparseable-verdict fallback move byte for byte, with `json`,
`re` and `Any` following them. Nothing outside `run_turn.py` reached for them, so no
import line anywhere else changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gp2EMCdkt43sLZjE8JNV1Z
…dule of its own

The fourth seam: the note the room writes back when a draft is refused, in the session's
own language. Two note bodies, the autonyms they name a language with, and the function
that picks between them — eighty-eight lines that read nothing but `issues` and a language
code, and are the last thing in the file that is only text.

They move byte for byte, and because `tests/test_internalization_room_redraft_note.py`
imports three of them by their private names from `run_turn`, `__all__` grows to name them:
ruff's F401 is satisfied by nothing else, and rewriting the test's import line is work this
ticket does not ask for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gp2EMCdkt43sLZjE8JNV1Z
…ver gets a module

The fifth and last leaf: seventeen phrases across three languages and the one line that
looks for them. It reads the validated speech and answers a single question — does this
turn give the floor to the team — and it is the only part of the file that is a heuristic
rather than a rule, which is worth having somewhere a reader can see whole.

Both move byte for byte. `detects_peer_cue` is read from `run_turn` by `live_turn.py` and
by the comprehension tests, and it keeps being read from there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gp2EMCdkt43sLZjE8JNV1Z
An allowlist entry keyed on (file, line, rule) goes stale on any edit above it, not just
on the removal it exists to track: eighteen tickets in the ladder touch live_turn.py
alone, and every one of them would have shifted dozens of unrelated sites into looking
both "removed" (stale) and "reintroduced" (a fresh violation) at once — a tax on every
sister PR from B02 on, not a signal about the doctrine.

evaluate() now matches (file, rule, text) as a multiset via collections.Counter, where
text is the offending line's own content — an edit that only moves a site leaves its
text and its allowlist row untouched, while an edit that changes the wording is
correctly both a new violation and a stale row, since neither name matches the other
kind's key. AllowlistEntry drops line entirely; Hit keeps it, since a violation still
has to be reported at a real address. The full allowlist is regenerated from scan()
against the same tree, so it is still exactly what the guard finds today, just addressed
by what a site says instead of where it currently sits. Verified by hand: inserting an
unrelated line above run_turn.py's existing sites left the guard at exit 0, where the
line-keyed version would have failed it.

pyproject.toml gains one ruff per-file-ignore: doctrine_allowlist.py carries source text
verbatim, and a handful of those lines exceed the 100-column limit no reflow can fix
without breaking the very match the row exists to preserve — the same treatment
alembic/versions/ already gets for being generated rather than authored.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JvqTvy6R4L5GHLKv3UvmzW
…ery is gone

A team said "é difícil explicar tudo isso em português" and the room answered with a
line about the microphone. The Assessor classified the turn, the planner picked the next
station, and the Guide was handed a contract authorizing nothing but the scripted
micro-check — so a problem about language was answered as a problem about sound. The
whole path goes: the assessor call and its parser, the deterministic planner, the
question-shape contracts, the STT recovery ladder with its offer to keep a point for
Refine, the no-usable-report bookkeeping, and the fixed sentence the app spoke when the
Guide had not invited a rehearsal.

What the Guide is handed now is information and nothing else: the bridge-mode line and
the comprehension status. It checks the retelling item by item against the pinned map,
with the whole conversation in context, and it invites the rehearsal in its own words.

`ProbePurpose` keeps one member. The app still voices its own recording-handoff consent
question, and a probe is what says the next answer belongs to that parser rather than to
the passage — so `ActiveProbe` is an id and a purpose, and the checkpoint scope, the
evidence method and the practice scope go with the contract that used to read them.

`is_semantically_empty_answer` moves to `practice.py` rather than dying with the file it
lived in: it never asked a model anything, it is the room reading words it already has,
and the practice reader is its remaining caller. `rehearsal_readiness.py` loses two
clauses that read probe fields which no longer exist.

The guard file becomes what the ticket asks of it: no module of this can be imported, no
field of a session remembers it, one purpose is left, no block of it reaches either
model, and no run of failed calls ends an interview. The prompts still describe the
contract in prose — a prompt is reviewed by the person who writes it, not by a grep — so
the guard reads only what the app appends beyond them.

Falsificado: uma por uma. Devolvendo question_contract.py, o campo assessor_failures e o
membro initial_check, as três guardas de símbolo falham; acrescentando o bloco de sonda
ao app_context, uma fala de oferta ao ramo de incerteza, e a frase fixa do ensaio como
fala do app, falham as três de comportamento.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015kREqtsHhqBXCUPMTvaYvB
The reader for "pronto" hung off the practice probe: the app said its fixed sentence,
the probe carried the scene it was about, and the word came back against both. With the
probe gone the word landed on nothing, and a team that rehearsed and answered with
exactly what the Guide asked for would have been asked to rehearse again.

The telling and the closing word are the same report by two routes, and the scene
pointer is the scope for both — the Guide opens the scene the pointer names and invites
for that one. Nothing new is read: the same matcher, against the Guide's own sentence
instead of the app's.

Falsificado: com `closing_word` fixado em False, um "done" depois do convite deixa a
cena sem marca.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015kREqtsHhqBXCUPMTvaYvB
…up into run_turn

The draft-gate-voice loop is the job that dominated the file: `_voiced_after_validation`
alone is a third of it, and every session type funnels through it. It goes into
`validated_turn.py` with `TurnOutcome`, `_draft`, the two log helpers and the rendering of
the conversation, so the three entry points can import downward from it and nothing has to
import back — leaving the graph acyclic instead of one that resolves only when `run_turn`
happens to be imported first, with a worker that reaches for a session module directly
raising at boot while the whole suite stays green.

What could not move is the binding of the model call. Twenty-seven sites across thirteen
test files install their fake by writing over `run_turn.call_agent`, one writes over
`run_turn.strays_from`, and the model-failure test asserts on the logger named
`app.services.internalization_room.run_turn` — and a monkeypatch reaches a function only
through the globals of the module it was defined in. So `call_agent`, `strays_from`,
`MAX_REDRAFTS` and the logger are read off `run_turn` at call time, through
`importlib.import_module`, and not one of those tests changes. `import ... as` will not do
it: the package's `__init__` binds the function `run_turn` over the submodule of the same
name, so the alias hands back the function and every attribute read off it raises. The
reason lives in the module's docstring, where whoever tries to tidy this into a top-level
import will find it.

`_draft` names its result before returning it — `warn_return_any` is on and the shim's
attributes are untyped. Its temperature and token budget are untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gp2EMCdkt43sLZjE8JNV1Z
`run_turn` assembles a passage session's two prompts — the map, the coverage state, the
opening instruction the session's history asks for — and hands them to the engine. It calls
no model itself, so nothing pins it to the module the fakes are installed on, and it is the
single place the Guide's system prompt is rendered: every ticket that adds a block or
reorders one lands here, and now lands here alone.

It moves byte for byte and `run_turn.py` imports it back, so `prepare_opening.py`, the
package `__init__` and every test that calls `run_turn` reach it exactly where they did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gp2EMCdkt43sLZjE8JNV1Z
… open

`critical_unit_unchecked` was raised for every critical checkpoint with no evidence
against it, and the only writer of that evidence was the classifier that read the team's
answers for the room. With it gone the blocker could never clear: the consent question
would never be offered, no interview would ever finish, and nothing would reach the
Refine package — the room would work forever and hand over nothing.

The ledger informs, it does not end the conversation. Who checks the retelling is the
Guide, item by item against the pinned map, with the whole conversation in front of it.
An empty ledger is a session nobody wrote a note about, not a session nobody understood.

What is left of the gate is what a person can see happening: the coverage floor met,
every scene rehearsed, and the team saying yes to the recording. A conflict someone did
record still blocks, and an open point still has to be carried.

Falsificado: devolvendo o ramo, a sala com o piso cumprido e as cenas ensaiadas volta a
nunca receber a pergunta de gravação.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015kREqtsHhqBXCUPMTvaYvB
The same job as a passage turn against a different standard of truth: the book's material
stands where a passage session puts its map, and there is no coverage spine because a
panorama never completes. It shares the engine and shares nothing else with the passage
path, so it shares no file with it either.

It moves byte for byte, including the nine-line inaudible early return that `run_turn` also
carries — the duplication is real and is not this ticket's to remove. `run_turn.py` imports
it back for `app/api/internalization_room/sessions.py`, which reaches it through the package.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gp2EMCdkt43sLZjE8JNV1Z
…l opens

Seventeen sessions in progress on the machine that drives the room hold an `active_probe`
whose purpose went with the probe machinery. `active_probe` is a typed submodel, so
loading one of them raises and the turn route answers 500 — on every turn, forever.

The tablet is what makes that permanent. It keeps the session id on disk with no expiry
and reopens the passage by that id, skipping session creation entirely; the only thing
that clears the saved id is a 404 from the server. A 500 leaves the id in place, so the
passage would be stuck on that tablet at every opening with no way out through the app,
and there is no way to tell how many of the seventeen will be opened again — the status
of a session in this database almost never closes.

This is the only tolerant `model_validate` in the repository and it should stay the only
one: it drops the probe, and only when the whole state refuses to load. Everything else
that was saved is kept, and a state that still will not load raises as before.

Falsificado: com o `except` reerguendo, a sessão gravada com `initial_check` volta a
levantar em vez de abrir.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015kREqtsHhqBXCUPMTvaYvB
…s own, and run_turn is a shim

The last of the three entry points, and with it the two slot names the verdict path refuses
a stored prompt row for missing. It is the only one that raises rather than degrading, and
`ValidationError` follows it out; nothing left behind needs it.

`run_turn.py` is what remains: forty-three lines that define `MAX_REDRAFTS`, the logger, and
nothing else. It imports from every new module and is imported by none of them, so the graph
runs one way. The imports are what the file is for — `__all__` names all fourteen, including
the four private ones the redraft-note test reads from here, because ruff's F401 is satisfied
by nothing else and rewriting those imports is a different ticket. A module named `run_turn`
that no longer defines `run_turn` reads oddly on purpose: the name is pinned by twenty-seven
monkeypatch sites and by a logger name asserted verbatim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gp2EMCdkt43sLZjE8JNV1Z
The guard read the prompts and stopped there, so it would have stayed green with the
room answering the ticket's own sentence with a fail-safe. The block being absent is
half of what was asked; the other half is that the turn goes to the Guide, in the Guide's
own words, off the conversation — not a microphone line and not a scripted micro-check.

Falsificado: fazendo a sala responder essa frase com a linha do fail-safe de áudio, o
teste falha; sem isso passava com o bloco já fora.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015kREqtsHhqBXCUPMTvaYvB
…ny order

The graph is one-way now, and this is what will say so when it stops being. A cycle here
resolves in whatever order the first importer used, and pytest collects `run_turn` early
enough to hide one every time: nine branches of one stack were green together while one of
them could not boot, and the green was order, not luck. So each module gets an interpreter
of its own with nothing imported before it.

`run_turn.py` says in a docstring what it now is. The file reads as a puzzle otherwise — a
module named for a function it no longer defines, holding nothing but imports that look
unused — and the next reader to tidy those away would take the twenty-seven monkeypatch
sites and an asserted logger name with them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gp2EMCdkt43sLZjE8JNV1Z
The fake picked up the decorator of the test it was inserted above. `pytest.mark.asyncio`
marks test functions, so on a collaborator class it does nothing at all — and the two
inviting fakes beside it in this file carry no such mark.

Falsificado: não se aplica; a marca não tinha efeito e o teste da palavra de fecho passa
sem ela, como já passava com ela.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015kREqtsHhqBXCUPMTvaYvB
Opts into `verdict_mode: block`, so it requests changes when it finds
something real instead of always posting a COMMENTED review reading
"nothing blocking". It still never approves — that stays the human
reviewer's call.

Criteria and the re-review convergence rules live in shemaobt/.github.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
A pull request into dev merges with no human approval, so the bot review is the
only reviewer it gets. Until now it ran only when someone remembered to request
the reviewer, and always advisory, which is no gate at all.

The caller now picks the verdict mode from the base branch: block into dev,
where the bot may hold the merge, and comment into main, where a person approves
and the bot stays advisory. That is the rule ADR 0001 already recorded, and it
supersedes the bare block that #140 had just set for every base. The block
travels as a review and not as this check — a requested change is still a
successful run — so dev keeps required reviews at zero approvals for the review
to hold anything.

The new workflow requests the reviewer on every head that reaches a pull request
into dev, a base change included: GitHub retargets a stacked pull request when
its parent merges and moves no head, so nothing else would follow. It mints the
Joãozinho App token rather than using GITHUB_TOKEN, because nothing GITHUB_TOKEN
creates triggers a workflow: the request would fire no run, the check would
never appear on the head SHA, and the pull request would wait forever on a
status with nothing behind it. The reviewer is removed and requested again every
time, since requesting someone already requested emits no event.

Two authors are never asked. GitHub refuses a review request from the author,
and the review skips authors ending in [bot]; asking anyway would fire a job
that skips, and a skipped job publishes the check as skipped, which counts as
passing. Not asking leaves the check absent and the pull request blocked, which
is the safe side of the mistake.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ecret

Four variables lived only on the production service, set by hand: the mail
provider, the Azure tenant and client ids, and the CORS origins. They survived
every deploy because --update-env-vars merges, so nothing forced them into the
file and the repository stopped describing what production runs with.

--set-secrets has no such mercy: it replaces the secret set whole. The Azure
client secret was mounted on the service and named nowhere, so the next merge to
main removed it and the service lost its e-mail credential. It is the fourteenth
mapping now.

CORS_ORIGINS is itself a comma-separated list, so the flag uses gcloud's
alternate-delimiter form. The delimiter may not appear in any value, which rules
out ':' — every origin carries one — so it is '|'.

Staging names its own CORS origins for the first time: the two localhost ones.
Left to the application default it would have answered production's domains
while holding a Neon branch of production's data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The comment claimed a merge to main dropped the mount and named the effect on
e-mail. The mapping had never been in a workflow, so that history is not the
repository's to assert; the mechanism is true on its own and is what the next
reader needs.

Naming these keys hands them to the file: an origin changed on the service is
reverted by the next deploy, without a word. That is the consequence of this
change and it belongs in the CI notes. The count of secret mappings does not —
it goes stale, and staging does not mount the same number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ecret-manager

The deploy carries every plain variable and mounts the Azure client secret
…-main

Merge gates differ by target: Joãozinho blocks on dev and is asked automatically
@little-joao
little-joao Bot requested a review from joaocarvoli September 9, 2026 01:34
joaocarvoli and others added 3 commits September 9, 2026 12:25
…ape that is parsed

The classifier only moves beads on the necklace and never speaks, so it does not need the
model the Guide and the Validator run: it rides `claude-sonnet-5`, falling back to
`claude-sonnet-4-6`, while the voice keeps the frontier ladder to itself.

Its reply was free text that `_parse` had to go hunting in — stripping a ```json fence when
one was there, giving up when it was not. It is now bound to a schema, and the three statuses
are named in the schema rather than left to the prompt's prose: `_parse` has no bucket for a
fourth word, so a status nobody anticipated is a bead that quietly does not move, and the
session it stalls looks from outside like a team that never covered the passage.

The defensive reading in `_parse` stays exactly as it was. A schema binds what a well-behaved
reply looks like; it is not a reason to delete the handling that a malformed one already has.

The turn now also states in a test what it had only ever done by habit: no call on the voice
path runs the classifier's model. In the golden-session runner one turn measured 322 seconds
because that call was made inline, and nothing in the room stopped it happening here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqQPP7Ui4pgYcTp1yrSzFf
…eir own

Both were left pointing at whatever the voice happened to be configured with, which meant
`TRIPOD_ANALYSIS_MODEL` named a role nothing consulted: setting it changed no call in the
system. A variable with no consumer is decoration, and worse than none — it reads like a knob
somebody can turn.

The two ladders start on the same rung, because that is what Marcia runs: her telling-back
analysis goes through `anthropicLlm()` with no argument, which is the voice ladder. Giving
analysis its own setting is not a disagreement with that, it is what lets a deployment move
the analyst without touching a word the team hears, and the doctrine's floor for the Guide and
the Validator does not extend here.

The reading these two do is the one the whole verdict rests on — whether the team told what
the map says, and whether a re-recorded stretch answered the finding raised on it — so the
default is the frontier rung and not the classifier's cheaper one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqQPP7Ui4pgYcTp1yrSzFf
…rts-three-kinds

The Analyst reports three kinds of Finding, and a retired kind reads as addition
@little-joao
little-joao Bot requested review from joaocarvoli and removed request for joaocarvoli September 9, 2026 18:20
…ed-by-its-stretch

A back-translation take is numbered by the stretch it tells, or not at all
@little-joao
little-joao Bot requested review from joaocarvoli and removed request for joaocarvoli September 9, 2026 18:25
henokteixeira and others added 22 commits September 9, 2026 16:17
Thin evidence about a legible stretch is no finding, so the evidence flag and the
insufficient-evidence kind leave the server: `checked` is `finding is None`, an entry
naming the retired kind is dropped from a fresh reply and from a stored row, and a reply
that still carries the flag is read with the key ignored. An unreadable reply stays the
only round with no verdict.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Analyst's output loses the evidence flag and keeps Marcia's under-reporting line as
she wrote it; the Speaker loses the branch that asked a team for a fuller telling of a
frase it could read perfectly well.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A row whose only finding was the retired name kept the clip the Speaker had said about
it, and `terminei` serves a stored verdict rather than reading again — so the team heard
"too little to check" on every press. The state's validator now clears the verdict in the
same copy, and the next press decides again on what the row still holds.

The drop is announced once the reading is accepted, so a reply that is refused for another
entry no longer reports a drop it threw away with the rest. One helper does it for both
readings. The prompt guards now hold her under-reporting line and the clean reply, and the
plain closing keeps the pure-function contract its deleted test carried.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The key this deployment holds is a Console key tied to a person, and a key like that is not
scoped to a workspace on its own: every call under one comes back 400 `not scoped to a
workspace` until the workspace rides along as a header. Measured against the live key on all
three rungs of the voice ladder — `claude-fable-5-1`, `claude-opus-5` and `claude-sonnet-5`
all answer 200 with the header and 400 without it.

The setting stays optional in the code even though it is required for this deployment. A
classic workspace key carries its own scope and must send no header at all, so which kind of
key is in play is a fact about the secret rather than a branch worth writing twice; an empty
header travelling on every call would be a deployment lying about having a workspace.

Both names are plumbed the two ways secrets reach this backend: the fetch-and-print heredoc
in `docker-compose.yml`, and `--set-secrets` on production and staging. That flag replaces the
whole set rather than merging into it, so the pair is appended to the existing list — a
mapping missing from the file is a mount removed from the service on the next merge, which is
how the Azure client secret came off it once already.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqQPP7Ui4pgYcTp1yrSzFf
… not

The ladder existed as three settings and one head model: whatever sat first was the only rung
ever asked, so a key without frontier access failed the turn outright and the team heard a
canned line over a question of entitlement. The room now walks down until a rung answers.

Only one error walks it — the one that means *this key may not use this model*. A rate limit,
an overload or a bad gateway keeps the rung it is on and rises to the caller, because those
say the model is busy rather than unavailable, and stepping down on a busy minute would
quietly finish a session on a weaker model than it began on. The last rung raises rather than
being swallowed: a ladder that has run out is a real failure and belongs to the caller.

Which rung answered is remembered for the life of the process. Without that, every call pays
a refusal on each missing rung before reaching the usable one, so a key with no frontier
access would spend two round trips per turn rediscovering the same fact. A restart forgets
it, which is also the moment a key's entitlements can have changed.

The accessors return the whole ladder now rather than its head, because a head is not
something any caller wanted — it was only ever the part of the ladder that got used.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqQPP7Ui4pgYcTp1yrSzFf
… it took

The pilot had no way to read a session's spend. Whether the map was being served from cache
or paid for in full every turn changed nothing anybody could see — the room answers the same
either way — so a cache that silently stopped matching would have looked exactly like a cache
that was working, at several times the price.

Every call now leaves its counts and the rung that answered, and every turn leaves how many
milliseconds it took and whether it ended in speech or in a fixed line. Those two belong
together: a turn is allowed to take fifty-six seconds under this doctrine, and the only way
to tell that from a turn that gave up is what came out of it.

Counts only. The team's own words do not reach this logger, for the same reason the draft
rejection logs a length rather than the draft.

Persisting any of this per session belongs to ENG-832; this is the log that makes the pilot
readable while that is built, and both exits of the turn pass through one place so a later
change cannot record one path and forget the other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqQPP7Ui4pgYcTp1yrSzFf
…s-voice-path-runs-gemini-at-low-thinking-instead-of
The two rows naming `llm.py` were the last the model rule carried, and both pointed at lines
this branch deleted: the room's Gemini accessor and the `ThinkingLevel.LOW` constant. A row
no hit confirms fails the guard exactly like a violation, which is the point of keeping it
that way — a mechanism that moved without the list moving is the case the list exists to
catch.

They go in their own commit rather than with the code, because the guard reached this branch
after that code did: it landed on main in #347 and arrived here in the merge above.

The rule is now fully blocking for the room. Nothing in `app/services/internalization_room`
or `app/api/internalization_room` names a `gemini_*` model or a thinking level below adaptive,
and no allowlist row stands between that and the build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqQPP7Ui4pgYcTp1yrSzFf
…ding it on thinking

Three of four classifications in the first live session stopped at `max_tokens` and two came
back with no text at all: the adaptive reasoning spent the whole ceiling before a single
decision was written. Only the shortest reply parsed, and the beads it moved were the only
ones that moved all session.

The 1500 was carried over from a provider where the ceiling did not include thinking. Here it
does, so a budget that used to hold a list of decisions now holds a deliberation instead —
and the failure is silent in the worst way, because an empty reply reads downstream as a
model that answered badly rather than one that was never given room to answer.

The classifier no longer thinks. It is bookkeeping over a list of elements with three
statuses, not a judgement about anything the team hears, and DOCTRINE.md's floor is written
for the Guide and the Validator by name. The ceiling goes to 4096, which is what the decisions
for a whole passage need — P01 alone has twenty-nine elements.

This departs from her classifier, which runs adaptive thinking at 1500, and the reason it can
is a difference on our side: hers classifies only the elements a literal name-match could not
resolve, and answers with two statuses. Ours sends every unresolved element and three. Worth
her word before the pilot, not worth a session of beads that never move.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqQPP7Ui4pgYcTp1yrSzFf
…reaches it

The same defect the classifier had, on the two calls that read a telling-back. Both kept the
budgets they were given for Gemini — 2000 for the analysis, 1500 for the correction check —
where the reasoning was not spent out of them. Here it is, so either call can come back empty
with `stop_reason: max_tokens`.

Empty is not a quiet outcome on this path. `_parse_analysis` reads it as None, and the route
turns that into `UnreadableReply`, so a team pressing terminei gets an error instead of a
verdict on what they told back. The correction check fails the other way: a mend nobody could
read counts as unresolved, and the team is sent to re-record a stretch they had already fixed.

Both go to 4096, the ceiling the Guide and the Validator already use. Her stack gives the
telling-back analysis 2500, and that number is not being followed here: it predates the rule
she wrote for the Validator one file over — *headroom so adaptive thinking can't truncate the
JSON verdict* — and both of these return JSON that a parser has to read. The principle is
hers; only the arithmetic is ours.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqQPP7Ui4pgYcTp1yrSzFf
The cache mark reached the passage Guide and the Validator and stopped there. The panorama
renders its own prompt, and the block inside it — every pericope digest of the book, plus the
preservation rules — is byte-identical from its first turn to its last. So the session that
runs longest was the one re-sending the most, as new input, every time anybody said anything.

Nothing in that prompt moves: the book, the language and the material are all fixed for the
length of the session, so there is no first varying byte to sit in front of and the boundary
is the end of the prompt itself. That is a claim about the template worth making deliberately,
so it is its own function rather than passing whichever slot happens to be last today.

This widens what the ticket's fourth item was taken to mean. "The prefix that repeats every
turn" was read as the passage turn, because that is where the ticket says *within a passage*;
the panorama repeats a prefix every turn just as literally, and costs more for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqQPP7Ui4pgYcTp1yrSzFf
…th-runs-gemini-at-low-thinking-instead-of

feat(ir): the room's voice leaves Gemini for the frontier ladder the doctrine names
…OV-Ruth

The Book Panorama screen has no route of its own for the raised hand — the
app posts to the same /questions the room already serves, and the router
forwards whatever session.pericope holds, unchecked. That already reaches
OV-Ruth for a panorama session (Marcia ruled the mão stays live through the
Panorama, 2026-09-07), but nothing pinned it, so a later refactor could start
reading the book id instead of the session's own pericope and nobody would
notice until a facilitator saw the wrong passage on a question card.

This locks it in through the real router — POST /questions against a session
created with pericope="OV" — because the service function only stores
whatever pericope string it's handed; only the router's own forwarding line
can be falsified. Verified by forcing the router to send a fixed "P03"
instead: the assertion failed on 'P03' == 'OV-Ruth', and reverting made it
pass again.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…t found it

The design boundary for the hand names coverage explicitly: "Nothing
touches: Guide/Validator prompts, the live-turn voice path, canon, coverage,
or the Kept Rehearsal" (raised-hand-design.md:188-189). raise_question does
read ir_coverage_events once, through last_bead_moved_in_session, to anchor
an element key nobody sent — but that is a SELECT with no write, and a
panorama session never accumulates events in the first place, so it always
resolves to nothing. Nothing pinned that a future change to this path
couldn't quietly start writing to the necklace it is not supposed to see.

Snapshots session.coverage_state and ir_coverage_events for the session
before and after a real POST /questions, and asserts both unchanged.
Verified by mutating coverage_state inside raise_question: the assertion
failed on {'x': 'engaged'} == {}, and reverting made it pass again.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…what it writes

The docstring claimed the hand "reads no map and writes no coverage event". The
write half is what the two asserts check. The read half was wrong: with no
element_key — the normal case — raise_question runs last_bead_moved_in_session,
a SELECT over the coverage events that anchors the question to the bead that
moved last. A later reader restoring the boundary as the sentence stated it
would delete that anchor. Now it says the hand reads the events once and writes
none, which is what the test actually pins.

Found by the review bot on the PR.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AucpnrSDAiaKpcsBCNH7C2
…uestion-path-a-raised-hand-in-a-panorama

test(ir): the panorama's raised hand keeps its own OV-Ruth and never touches the necklace
The maps and the Guide write the divine name as the tetragrammaton
"YHWH", which a voice engine reads letter by letter instead of speaking
it — a name spelled out in the middle of the turn the map most depends
on. The prompts already instruct the spoken form (validator_system_prompt.md:73,
guide_system_prompt.md:163, in Marcia's own rebuilt prompts), but nothing
downstream enforced it: any "YHWH" that slipped past the model — a
corrected draft, a story-so-far quote — reached the platform unchanged.

speakable_text(text, language) is a deterministic, word-boundary
substitution against her own table: pt -> "Senhor Jeová", en -> "the
LORD". A language outside that table returns the text untouched rather
than guessing at a form the pilot does not speak — Spanish included,
since no Spanish form has been ruled on. Falsified by hand: reverting
the substitution failed all twelve boundary-case assertions (mid-sentence,
trailing period, trailing comma, end of line, quoted, beside an em dash)
for the right reason, and forcing a form onto an unlisted language failed
exactly the two tests that check for it; both were restored to green.

Nothing wired to this yet — that is the next commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012kSA6TT3QkfWa8EUCHm65G
… by letter

synthesize_facilitator_speech handed text to the platform unmodified —
the single chokepoint every caller reaches TTS through (opening clips,
whole turns, re-speaking the last line, the passage wheel, the two
back-translation fail-safes, the prepared opening, and the generic
/voice/speak route), and none of them sanitized the divine name first.
speakable_text now runs on the resolved language right before the
platform call, so every one of those callers gets the substitution
uniformly, with no call site changed.

Because platform_speech computes its content-addressed cache key from
the same text it is handed, running the substitution first means the
cache key is also computed over the spoken form — no separate change
needed in the cache layer, and no cache migration, since this changes
what a future line resolves to rather than what an existing key means.

Falsified by hand: commenting out the new line let "YHWH" reach
client.post's JSON body again in both the Portuguese and the English
test, and only those two tests failed; restored to green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012kSA6TT3QkfWa8EUCHm65G
The table lives in code now (speakable.py) and in Marcia's prompts, but
nowhere in prose for someone who isn't reading either. This records
what was implemented — pt/en spoken forms, their source in her own
rebuilt prompts, and that only the voiced form changes, never the
persisted transcript — not a request to approve a form invented here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012kSA6TT3QkfWa8EUCHm65G
…nd is reachable

The record pointed at line numbers in her repository, which nobody reading
this tree can open, and the same lines here sit elsewhere. It now cites the
in-tree prompts with the rule quoted, and names her repository and branch
beside them. And nothing indexed the file: AGENTS.md lists every other
document under docs/, and speakable.py is where a reader arrives from, so
both now point at it.

Found by the review bot on the PR.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AucpnrSDAiaKpcsBCNH7C2
…name-into-speakable-form-at-the

fix(ir): the facilitator's voice never spells the divine name letter by letter
…out-a-legible-stretch-confers

Thin evidence about a legible stretch is no finding, and the passage is checked
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants