Problem class: eduos-exec-tick-voice-text-fallback-shared-decision-module
I reconstructed the winning approach as a self-contained, executable reference implementation and verified it. The full solution is written to /workspace/SOLUTION.md; source and tests are under /workspace/src/voiceTextFallback/, with the acceptance matrix at /workspace/docs/tier-by-path-matrix.md.
Verification result: 32 passed (32), tsc typecheck clean.
Key design: a pure decideTextFallback() that composes existing MOB-008/MOB-010/MOB-019 signals (never re-classifying), selects voice only when everything is explicitly green, and fails open to text on any unknown; a pure view model behind one shared TextFallbackPanel; and additive BETA-071 delegation. spec-077 queuedOffline is propagated as a non-fatal, resumable state. Airplane-mode device E2E is explicitly deferred.
Problem class: eduos-exec-tick-voice-text-fallback-shared-decision-module
Outcome: a pure decision module that composes MOB-008 connectivity + MOB-010 capability + MOB-019 route verdicts (with spec-077 queuedOffline semantics), one shared student-facing TextFallbackPanel, and additive BETA-071 delegation.
Verification: 32/32 vitest tests pass + tsc typecheck clean. Airplane-mode device E2E is explicitly deferred (not claimed).
Speaking activities treated voice as the primary path and each screen classified availability on its own. That produced three classes of defect:
queued_offline verdict were all treated as blocking errors. A queued voice attempt under spec-077 is a resumable, successful state — not a failure — but it blocked completion.The plan that worked was not to add another classifier but to compose the existing verdicts and fail open to text: the only state that selects voice is the one where every upstream signal is explicitly green. Everything else — including every unknown — selects text.
Invariant (
specs/104AC4):canCompleteWithTextis alwaystrue.
src/voiceTextFallback/
types.ts # composition types (MOB-008/010/019 + spec-077)
decision.ts # pure decideTextFallback()
panelViewModel.ts # pure view model for the shared panel
TextFallbackPanel.tsx # single student-facing surface (thin shell)
beta071Conversation.ts # additive delegation
index.ts # barrel
decision.test.ts # 32 unit tests
docs/tier-by-path-matrix.md # matrix citing specs/104 AC4
types.tsexport interface ConnectivitySnapshot {
status: "online" | "offline" | "unknown";
queuedOffline?: boolean; // spec-077
}
export interface VoiceCapabilitySnapshot {
microphone: "available" | "unavailable" | "unknown";
speechToText: "available" | "unavailable" | "unknown";
}
export type RouteVerdict =
| "voice" | "text" | "queued_offline" | "denied" | "unknown";
export interface RouteAssessment { verdict: RouteVerdict; }
export interface TextFallbackDecision {
mode: "voice" | "text";
canCompleteWithText: true; // MOB-026 guarantee
queuedOffline: boolean; // spec-077 propagation
reason: FallbackReason;
isFallback: boolean;
}
decision.tsexport function decideTextFallback(input: FallbackInput = {}): TextFallbackDecision {
const connectivity = input.connectivity ?? { status: "unknown" };
const capability = input.capability ?? { microphone: "unknown", speechToText: "unknown" };
const route = input.route ?? { verdict: "unknown" };
const queuedOffline =
route.verdict === "queued_offline" || connectivity.queuedOffline === true;
const reason = resolveReason(connectivity, capability, route.verdict, queuedOffline);
const mode = reason === "voice-available" ? "voice" : "text";
return {
mode, canCompleteWithText: true, queuedOffline, reason,
isFallback: mode === "text" && reason !== "route-prefers-text",
};
}
function resolveReason(connectivity, capability, verdict, queuedOffline): FallbackReason {
if (queuedOffline) return "spec077-queued-offline"; // spec-077 first
if (verdict === "denied") return "route-denied"; // MOB-019
if (verdict === "text") return "route-prefers-text";
if (connectivity.status === "offline") return "connectivity-offline";
if (connectivity.status === "unknown") return "connectivity-unknown";
if (capability.microphone === "unavailable" ||
capability.speechToText === "unavailable") return "capability-unavailable";
if (capability.microphone === "unknown" ||
capability.speechToText === "unknown") return "capability-unknown";
if (verdict !== "voice") return "fail-open-unknown";
return "voice-available";
}
Precedence: spec-077 queued-offline > MOB-019 verdicts > MOB-008 connectivity > MOB-010 capability > fail-open. voice is selected only when all signals are explicitly green.
buildTextFallbackViewModel(decision, activity) maps each FallbackReason to student copy, offline notice, retry-voice gating, and canSubmitText (trim + minLength). TextFallbackPanel.tsx is a thin renderer over it, so behavior is DOM-free testable and it is the only text-fallback UI.
export function bindConversationFallback(input, activity) {
const decision = decideTextFallback(input);
const panel = decision.mode === "text"
? buildTextFallbackViewModel(decision, activity) : null;
return { panel, voiceInputEnabled: decision.mode === "voice", decision };
}
export function isCompletableByText(decision) { return decision.canCompleteWithText; }
The existing conversation flow keeps owning turns/scoring/navigation and only renders panel when non-null — purely additive.
npx vitest run # 32/32 pass
npx tsc -p tsconfig.json # typecheck clean
RUN v4.1.9
Test Files 1 passed (1)
Tests 32 passed (32)
$ npx tsc -p tsconfig.json
TYPECHECK OK
| Group | Tests | Locks down |
|---|---|---|
decideTextFallback |
1–18 | voice only when all green; denied/text/unknown route; spec-077 queued; offline/unknown connectivity; unavailable/unknown capability; missing input; precedence |
buildTextFallbackViewModel |
19–28 | single surface; activity copy; submit validity; offline notice; retry-voice gating |
bindConversationFallback |
29–32 | voice flow untouched; delegation; decision propagation; text-completion guarantee across 6 combos |
| Requirement | Evidence |
|---|---|
Completable by text (specs/104 AC4) |
canCompleteWithText: true; tests 2, 32 |
| Reuse classification | types mirror MOB-008/010/019; decision only composes |
spec-077 queuedOffline |
tests 6, 7, 17, 22 |
| Fail-open-to-text | tests 5, 9, 12–16 |
| Single surface | TextFallbackPanel.tsx; tests 19, 30 |
| BETA-071 additive | tests 29–31 |
Codex lane returned HTTP 429; the glm fallback lane delivered the implementation and verification above.
# Evidence - Problem class: eduos-exec-tick-voice-text-fallback-shared-decision-module - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-17T20:50:15.540Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "MOB-026: every speaking activity completable by text. Approach that worked: pure decision module composing existing MOB-008 connectivity + MOB-010 capability + MOB-019 route verdicts (fail-open-to-text, spec-077 queuedOffline semantics) instead of re-implementing classification; shared TextFallbackPanel as the single student-facing surface, existing BETA-071 conversation flow delegates to it additively; 32 vitest unit tests + tier-by-path matrix doc citing specs/104 AC4; airplane-mode device E2E explicitly deferred, never claimed. Worker lane: codex 429 -> glm fallback delivered.", "environment": "", "language": "", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "eduos-exec-tick-voice-text-fallback-shared-decision-module", "provider": "openrouter", "solved_at": "2026-09-17T20:50:15.540Z", "version": ""}