A root's owner card now carries one host-written sentence under the
question: the asking task, how its run started (read from the typed
run_origin provenance of the task record: the owner's message and its
time, a scheduled follow-up of task X, background consciousness, a
promotion, a schedule, or origin unknown with the recorded marker), and
when the owner last wrote in the card's chat, read from the chat log
tail. It is computed from host records only, never from the question
text, and an unrecorded fact is written as unknown.
The sentence is stored in the owner_quiz block (record_asked host_facts),
forwarded by the send_quiz event handler, and carried by the live quiz
frame, the chat.quiz host event, the chat row, history replay (with the
block as the source for rows logged without it), the Main question
pointer, the activity census and the browser mirror. The web card renders
it as a muted plain-text line (.chat-quiz-host-facts), absent when empty.
The chat.quiz event also carries the Project name for a Project card;
the browser wire does not.
QuizOutbound and ChatOutbound gain the optional host_facts field in both
language mirrors; the frozen-contract row and DESIGN.md describe it. No
gateway version change.
waitingModel runs census model_waits through mergeModelWaits so a malformed wait row cannot silence sidebar motion where the chat card would show nothing. The census stamps required_question_unavailable on a row whose recorded owner-question wait could not be resolved (detail read failure, missing Project pointer, or an unreadable registry) and the reducer keeps such a row static unknown instead of animating a possibly blocked task. Contract mirrors and typedef phase vocabulary updated; fixtures now carry the producers complete wait rows.
Review advisory. Folding an older Main card reads the named question's own
asked_at, and losing that stamp fails silently: every fold simply stops. The
census producer test now pins `ts` against the recorded asked_at, so the
cross-boundary contract breaks loudly on the producer side.
ARCHITECTURE also names what the browser guard does NOT cover: the Python
projection infers `resumed` from a wait record that moved on to another quiz
without an order proof, so a read taken inside the same window can show the
newest waiting question as resumed. That one is a non-live observation and the
next census naming the real wait restores the card.
Main mirrored only blocking Project questions, each as a tall amber System card
that stayed that tall forever. Three questions asked in a row filled the screen
long after they were answered, while questions the task passed under an
assumption never reached Main at all.
Now the row's size follows the owner's attention. Every Project question
projects into Main (history, the live frame and the activity census share one
producer). In every settled or passed state it is one line in the project
chip's language: status, then the recorded answer or the assumption the task
continues under, then the question as context, the Project and the time. The
whole line is one control that opens the exact question. Only while the task
actually waits does the row grow into a card with the whole question, the
option labels as buttons and one "Details and own answer" action; a press
answers through the existing decision ingress and the card folds back into its
line. Own words, option details and the stake stay in the Project form.
The view is a pure function of the question's recorded facts: no timers, no
grouping, no new store. The pointer row gains two additive fields,
`assumption` and `recommended_index` (ChatOutbound and its JS mirror, the live
frame and the census whitelist). The "wait moved on to another quiz" inference
now resumes only a question the task actually waited on, on both sides. An
answer from Main settles through the same observation a quiz_state frame uses,
so a lost frame plus a stale open snapshot cannot reopen it. The dead
resetViews export is removed.
Tests: web/tests/question_rows.test.js (realistic burst, every state, one-touch
answer, races, focus), the shared DOM stub moves to chat_decision_fixture.js,
backend projection of an optional question beside a required one, the browser
scenario at 1100 and 390 px, and the real-server journey answering from Main.
Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
The Windows full-test job decodes an encoding-less read_text() as cp1252, which mangles the
middle dot of the lifecycle words in question_presentation_parity.json and fails the Python
half of the parity test. The fixture is now read with an explicit UTF-8 encoding.
Review finding (Claude Fable 5.1 slot, critical): `_task_activity_facts` projected only
quiz_id/state/asked_at/wait_for_answer from the owner_quiz block, so the `required_question`
pointer of the 3-second activity census arrived with empty question/options, and the browser
merged those blanks over the complete history row — the Main pointer flipped between the
question and the placeholder, and a settled pointer lost its option label.
- The census projection keeps question, options, answered_index, comment and wait_ended_at
(the task result is already read and memoized; no new I/O).
- `project_question_pointer` emits question/options only when known, like the answer fields.
- The browser's pointer merge drops empty question/options/project_name from any re-delivery,
so no producer can blank a painted row (pinned in chat_decision.test.js).
- `applyQuizStateFrame` observes the live frame once; the pointer repaints from the merged
observation (advisory A1).
- The live pointer frame in `message_bus.send_quiz` is built with constant keys plus explicit
optional assignments (`test_chat_outbound_matches_message_bus_sends` forbids `**` expansion);
generated inventories regenerated after the `contracts.py` comment paydown.
Roast findings (codex gpt-6-astra) folded in:
- The pointer row carries the question, option labels, recorded answer and the
wait facts from history, the live delivery and the activity census
(`project_question_pointer`, `owner_wait_projection`; both contract mirrors),
so Main paints it from the row alone. The IntersectionObserver hydration, the
settled-source cache and the Retry state are gone: freshness is the ordinary
history reconciliation plus the `quiz_state` frame, and task detail is read
only to open the original form.
- Lifecycle observation precedence: once a live frame closed a wait, an older
history row or a detail read begun before it cannot reopen «Waiting»; an
unavailable row keeps what is known; a settled question never reopens. The
production timeout frame (`wait_for_answer:false`, no `wait_ended_at`) is the
shape the tests use.
- Parity: history attaches the task's wait record to the Project room's quiz rows
(a wait the owner resumed by ordinary input leaves no frame behind), the quiz
card reads those facts, and the parity fixture now pins the rows Python emits
against what the browser reads from them.
- Wording leads with one word — «Waiting for your answer» / «Unanswered · …» /
«You answered» / «Replaced by a newer question» / «Status unavailable» — with
three action labels; status and source lines use meta ink (DESIGN: the owner
reads them to act). The wait-ended line names the default path the task took
and that silence was not consent; a late answer's toast says where it went.
- Previews bound the option and the comment separately and never cut for less
than the marker costs.
- Module map row for `question_presentation.js`; DEVELOPMENT 11 points at the
ARCHITECTURE data flow instead of restating it; a stale comment in
`owner_quiz.py`; `contracts.py` pays its 1600-line gate down by compacting
five comment blocks.
The Main-chat pointer for a required Project question read «Question answered in
<Project>» with its `View question` button jammed against the timestamp, and the
lifecycle words did not tell the owner whether a question was waiting for him.
- One shared action-row composition, `ui_helpers.createSystemMessageActions`, now
owns space above and below the buttons, wrapping and focus-ring clearance for
question pointers, Project lifecycle rows and routing receipts; a button never
sits in a nowrap text line again.
- One lifecycle vocabulary in the pure `web/modules/question_presentation.js`,
shared by the pointer and the quiz-card header, with the Python fallback in
`project_dialogue.project_question_pointer` pinned by a shared parity fixture.
- The pointer shows the question first, then the status, the recorded option and
comment (never a locally invented answer), and the Project as its source.
- The answer POST settles the card only on a valid recorded confirmation
(`ok`, `state`, a valid index or a non-empty comment); a malformed 2xx never
substitutes the local draft (the independent audit's fabricated-answer defect).
Docs replace the touched descriptions in DESIGN §5, ARCHITECTURE 03 and
DEVELOPMENT 11. Version carriers are untouched: a contributor PR into `ouroboros`
leaves the release version to integration.
A root that must have an answer could only wait forever. escalate now takes an
optional max_wait_minutes: the task still parks at the completed-tool boundary,
and if no answer arrives within that window it resumes on its own with a
[SYSTEM NOTICE] instead of holding the task open indefinitely. The card is
untouched by the timeout — it stays open, and a later answer reaches the task's
chat as an ordinary owner message (the half that landed in the previous
commit).
The bound is validated by the shared quiz validator (a whole positive number of
minutes, only with wait_for_answer, capped by the task's absolute wall-clock
ceiling — beyond it the ceiling ends the task first, so a larger bound would be
a promise the runtime cannot keep) and refused as QUIZ_WAIT_BOUND_INVALID.
It travels as an ABSOLUTE instant in the owner-wait checkpoint
(wait_deadline_at plus the minutes it named), so a planned restart resumes the
SAME bound instead of granting the full wait again, and the cold continuation
emits the same notice as the warm one. Both continuation callbacks now return
"owner_input" or "timeout": the direct loop breaks on the deadline only AFTER
control_reason() is consulted, so Stop, cancel, the task deadline and the
absolute ceiling keep precedence; the pooled worker turns the spent bound into
a second reason for the SAME resume request the mailbox already sends, from
inside the parked gate, with no new scheduler (disclosed: that resume is a
request, not a guarantee — the grant can still be refused, and the hard axes
end the task in that case).
No new quiz or wait state: the row resumes with the additive
resume_reason: "timeout", which the activity projection now carries so the
project question pointer keeps reading "Answer needed" — the question was never
answered. The notice itself names the recorded assumption when there is one and
otherwise says plainly that no answer is not consent. The bound is deliberately
invisible on the cards and in Telegram (DESIGN: no timers, no countdowns); the
escalate receipts now state what the task actually agreed to.
Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>