main onto dev: the branch carries the workflows its gate needs - #353
Open
henokteixeira wants to merge 72 commits into
Open
main onto dev: the branch carries the workflows its gate needs#353henokteixeira wants to merge 72 commits into
henokteixeira wants to merge 72 commits into
Conversation
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>
…ecret-in-secret-manager
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
…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
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
Bot
requested review from
joaocarvoli
and removed request for
joaocarvoli
September 9, 2026 18:25
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Carries
mainontodevso thatdevholds the two workflows the gate is made of:joaozinho-review.ymlwith the verdict mode chosen by base branch, andrequest-joaozinho-on-dev.yml, which asks the reviewer on every head that reaches a pull request here.The order matters.
dev's protection cannot requirejoaozinho / revieworrequest-joaozinhobefore the workflows that produce them live ondev, so this merge comes first and the protection is applied straight after. Once it is applied, a direct push todevis 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, sorequest-joaozinhoruns here, the reviewer is requested with nobody clicking, andjoaozinho / reviewreports in block mode. That is where the seventh required context gets its name read off the API rather than guessed.🤖 Generated with Claude Code