{# Shared by action_review.html (form-fill) and action_approve.html (every other kind). The including page defines `form`, `btn`, `gateMsg` and `evaluate()` first; everything from the submit handler on is the same for both, and tests/test_review_says_what_it_knows.py holds both pages to it. #} // The server-side rule, on this side of the relay: a 4xx other than 408 // or 429 is somebody's decision about this request, and nothing ran that // was not meant to. Everything else — 5xx, 408, 429 — is a gateway // speaking for a service that said nothing (#416). function answeredAboutRequest(status) { return status >= 400 && status < 500 && status !== 408 && status !== 429; } // Back to chat (#884 G7). On a phone the review opens in the chat's own // tab, so once it is answered the way back is a link. Only through the // CareAgents relay, whose rewritten form action names the assistant: // /review///submit. The engine's own page has no chat. function showBackToChat() { var parts = form.getAttribute('action').split('/'); if (parts[1] !== 'review' || !parts[2] || document.getElementById('back-to-chat')) { return; } var a = document.createElement('a'); a.id = 'back-to-chat'; a.className = 'btn btn-outline-secondary btn-lg'; a.href = '/chat?agent=' + encodeURIComponent(parts[2]); a.textContent = 'Back to chat'; gateMsg.parentNode.insertBefore(a, gateMsg.nextSibling); } form.addEventListener('submit', function (e) { e.preventDefault(); if (!evaluate()) { return; } var payload = {}; new FormData(form).forEach(function (v, k) { payload[k] = v; }); var nka = form.querySelector('#nka'); if (nka && nka.checked) { payload['nka'] = 'true'; } fetch(form.action, { method: 'POST', // Credentials are the server's to supply. The relay resolves the // tenant from ownership and injects the step-up token itself, so the // headers this used to send were unread as well as exposed (#395). headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify(payload) }) // A rejection HERE is not an answer of any kind — the request never // got one. Resolved to a sentinel rather than handled in place, for // two reasons: the renderer below then owns every outcome in one // place, and a .catch attached after the renderer would also swallow // a throw from the rendering itself and report it as a network // failure. .catch(function () { return null; }) .then(function (r) { if (r === null) { return { reached: false }; } // An unreadable body is an OUTCOME, not the absence of one. A body // that is not JSON — a Werkzeug HTML error page, a gateway's own // 502 — rejects here, and with no .catch the entire chain went // silent: the screen did not move and the button stayed enabled, // which is an invitation to tap Approve again on a request that // may have run. Resolve into a shape the renderer can branch on. return r.json() .then(function (b) { // A body that PARSES but is not an object is not an answer // either, and it is worse than one that does not parse: the // renderer below reads `.confirmed` and `.next_step` off it, // and on `null` — valid JSON — that THROWS. Nothing catches // after the renderer, deliberately, so the screen never moves // and Approve stays armed: the silent failure this branch // exists to remove, one body shape over. Verified in a // browser at 390x844 — a 502 carrying the body `null` left // the page still reading "Ready to generate" with Approve // enabled, which is an invitation to tap it again. if (b === null || typeof b !== 'object' || Array.isArray(b)) { return { r: r, b: {}, reached: true, readable: false }; } return { r: r, b: b, reached: true, readable: true }; }) .catch(function () { return { r: r, b: {}, reached: true, readable: false }; }); }) .then(function (res) { // Nothing was reached, so nothing about the request is known here // — including whether it arrived at all. // // RULED: leave Approve ENABLED. A dropped connection most often // means the request never left the device, and a disabled button // with no way forward strands a patient mid-approval. Name the // uncertainty, point at the check, and let them act on it. The // option ruled out is the silent failure this branch used to be. // // It is not alone in that any more — the relay's own 503s below // stay armed too, for the same reason. It was, when this was // written, and the sentence saying so outlived the fact. if (!res.reached) { btn.disabled = false; gateMsg.className = 'alert alert-warning'; gateMsg.textContent = 'We could not reach the service, so we cannot tell you ' + 'whether your approval was sent. Reload this page to see ' + 'where this request stands before approving again.'; return; } // The session expired while the form sat open, which on a phone in // an in-app browser is the ordinary way this page is used. This // route is not under /api/, so `login_required` answers a 302 to // /auth rather than a 401; fetch follows it, and what arrives is // the sign-in page as 200 text/html. That parses as unreadable, so // it was reported as an approval whose outcome we could not // determine, with Approve taken away — when nothing was submitted // and nothing could have been: the request never passed the // session gate. Say that instead, and leave the button alone, // because signing in and tapping Approve is the whole recovery. // // Keyed on the redirect AND on where it landed. A redirect to // somewhere that is not our sign-in page — a captive portal, an // enterprise proxy — is not evidence about the session, so it // falls through to the unreadable branch below, which claims // nothing either way. if (res.r.redirected && /\/auth(\?|$)/.test(res.r.url || '')) { btn.disabled = false; // sign in, then approve gateMsg.className = 'alert alert-warning'; gateMsg.textContent = 'Your sign-in expired, so this approval was not submitted. ' + 'Sign in again in another tab, then approve here.'; return; } // Say only that we could not read it. NOT what became of the // request: on an unreadable 200 the relay has already confirmed // the action, on an unreadable 502 it may or may not have, and // neither is observable from here. Claim neither, and do not // re-arm the button on an outcome nobody established. if (!res.readable) { btn.disabled = true; // never invite a second send here gateMsg.className = 'alert alert-warning'; gateMsg.textContent = 'We could not read the answer to your approval, so we cannot ' + 'tell you whether it went through. We have not sent it again. ' + 'Reload this page to see where this request stands.'; return; } if (res.r.ok) { gateMsg.className = 'alert alert-success'; // The server's next_step already opens with what was recorded // ("Review recorded and approval issued…"), so it is the whole // message. Prefixing it here printed "Review recorded." twice. gateMsg.textContent = res.b.next_step || 'Review recorded.'; btn.disabled = true; // Approved: neither button has anything left to do, and a // disabled Approve beside a live Decline read as a question // still open (#853). btn.hidden = true; document.getElementById('decline-btn').hidden = true; // Then on to the result. Printing next_step and stopping left // a person who approved with no way to reach what they approved // (#847): the PDF link, or the plain fact that it was done or // did not finish, comes from the status, as on every other // branch that knows the relay answered. checkStatus(); return; } // 409: this action already carries a confirmation, so the review // was submitted before (#528 follow-on). The server refuses at the // pre-check, and the seal behind it refuses the double-tap that // races that pre-check; both answers arrive here. // // It must not render as an error. A red failure for a request that // WAS approved is the #416 shape — told it failed, a reasonable // person approves again, which is the double-tap that produced the // 409 in the first place. It must also not say nothing was sent: a // confirmation exists, so that is a claim this page never observed. // State what the 409 does establish — the person already approved // it — and let the status resolve the rest. // The CareAgents relay's 409 for a text whose number is not the // account's own: refused BEFORE any review was recorded, so it is // not "already approved" and there is nothing to poll for. Approve // goes; Decline stays, because declining it is the way out. // Keyed on the code alone: the relay sends it only with its 409. if (res.b.error === 'not_your_number') { btn.disabled = true; btn.hidden = true; gateMsg.className = 'alert alert-warning'; gateMsg.textContent = res.b.message || 'This text is not going to the phone number linked to your ' + 'account, so it cannot be approved here. You can decline it.'; return; } if (res.r.status === 409) { btn.disabled = true; // never invite a second send here gateMsg.className = 'alert alert-secondary'; gateMsg.textContent = 'You have already approved this request. We are checking ' + 'where it stands.'; checkStatus(); return; } // 404 from the ENGINE: the action is no longer awaiting review. // `_load_form_fill_action` requires `awaiting_confirmation` and runs // before the 409 pre-check, so once the confirm has claimed the // action a second submit answers 404 {"error": "Unknown action"} — // which is the ordinary two-tab and back-button outcome after an // approval that WORKED. It rendered as a red machine string with // Approve re-armed: the same shape the 409 branch exists to remove, // one status code over. // // What this 404 establishes, and no more: the action has left // `awaiting_confirmation`. That is claimed-and-run OR expired, and // the status tells them apart — so state the one fact and let the // poll finish the sentence. Ownership was verified before the // submit went out, so "no such action" is not among the readings. // // The relay's OWN 404 is `{"error": "not yours"}` and is a different // answer — a denial about this account, not about the action's // state — so it is excluded here and keeps the failure arm below. if (res.r.status === 404 && res.b.error !== 'not yours') { btn.disabled = true; // a further tap answers 404 too gateMsg.className = 'alert alert-secondary'; gateMsg.textContent = 'This request is no longer waiting for your approval. We are ' + 'checking where it stands.'; checkStatus(); return; } // THREE answers, not two (#220, #416). `confirmed === null` means // the engine never replied, so the approval MAY already have run. // This branch used to fall through to the else below and print // "Submission rejected." — a definite failure, for the one case // the server deliberately refused to call. Told it was rejected, a // reasonable person approves again, which is the double-send #416 // exists to prevent. if (res.b.confirmed === null) { btn.disabled = true; // never invite a second send here gateMsg.className = 'alert alert-warning'; gateMsg.textContent = res.b.message || // NOT "your review was saved": this branch has two producers // now, and on one of them — an answer to the review POST that // nobody could read — it may not have been. The fallback fires // only if the server sent no message at all, so it must be // true for both. 'We are checking where this request stands.'; checkStatus(); return; } // The relay's confirm-failure: the review WAS saved and the // approval IS recorded (the review route mints it), and only the // completion failed. Its message points the patient at the status, // so that instruction needs a destination (#419) — and the button // must not be left armed, because since #528 the retry it invites // answers 409. // // Keyed on the STATUS as well as the value, not on // `confirmed === false` alone: the two 503s carry that same value // for a review that never reached the engine, where there is no // approval on file, nothing to look up, and the poll would report // on a request that was never made. if (res.r.status === 502 && res.b.confirmed === false) { btn.disabled = true; // its retry now answers 409 gateMsg.className = 'alert alert-warning'; gateMsg.textContent = res.b.message || 'Your review was saved, but we could not complete your ' + 'approval. We are checking where this request stands.'; checkStatus(); return; } // Everything the relay itself answers on this route carries either // `confirmed` (its two 502s) or the `review_unavailable` code (its // 503s). A 5xx carrying neither was not written by it: a gateway // spoke for a service that said nothing, and by this project's own // rule (`_upstream_answered`, careagents/healthclaw.py) that is // silence with a status code on it, not a refusal. It reached the // red arm below with Approve armed — the #416 shape, one body // shape over from the unreadable branch that handles it honestly. // // No checkStatus: nothing here establishes that the relay ran at // all, let alone that a confirmation exists, and a poll from a // branch that has established neither is what the guard on // checkStatus's callers exists to forbid. var relayAnswered = res.b.confirmed !== undefined || res.b.error === 'review_unavailable'; if (!relayAnswered && !answeredAboutRequest(res.r.status)) { btn.disabled = true; // it may already have run gateMsg.className = 'alert alert-warning'; gateMsg.textContent = 'We could not tell what happened to your approval, so we ' + 'cannot say whether it went through. We have not sent it ' + 'again. Reload this page to see where this request stands.'; return; } // A 503 the relay wrote is a STALL, not a decision about this // request. All three of its 503 sites answer `review_unavailable` // — the ownership pre-check could not be run, the review POST was // lost in transport, or a gateway answered for an engine that said // nothing — and each carries an honest sentence: we could not tell, // we could not check. The failure style around it was not honest. // Red states a refusal, and it was paired with an armed Approve: // the style told the patient to stop while the control told them to // continue, and on these paths continuing is the recovery (nothing // ran, and if the POST did land the review route now answers 409). // Ruled by QA on the finished stack; found in a browser, where all // three painted `alert-danger` with Approve enabled. // // PRESENTATION ONLY. The sentence is still the server's, the // button is left where these branches already left it — enabled, // explicitly, so the next tidy-up cannot quietly disable it — and // nothing polls, because a 503 has established nothing to poll for. if (res.r.status === 503 && res.b.error === 'review_unavailable') { btn.disabled = false; // the recovery, not a hazard gateMsg.className = 'alert alert-warning'; gateMsg.textContent = res.b.message || 'We could not tell whether your review was saved. Reload ' + 'this page to see where this request stands.'; return; } gateMsg.className = 'alert alert-danger'; // `message` first: it is the field the server uses when it has // something specific to say. `error` is a machine code. // The fallback fires only when the server sent NEITHER message nor // error, which is precisely when we cannot say what happened to // the request. It therefore states the failure to record and // claims nothing about what was or was not sent. gateMsg.textContent = res.b.message || res.b.error || 'We could not record your review. Please try again.'; }) .then(function () { // Approve locked means this approval may have gone through, so a // Decline beside it would be a second, contrary answer (#884 G7). // Not a catch: a throw in the renderer still reaches nothing. if (btn.disabled || btn.hidden) { document.getElementById('decline-btn').disabled = true; showBackToChat(); } }); }); // Resolve the unknown outcome instead of telling the patient to go and // find it themselves. #419: the copy said "check the request's status // first" while `form_status` had no caller anywhere in CareAgents, so // the instruction pointed at nothing. // // form.action is /review///submit, which is where // both ids come from; the status endpoint 404s without ?agent=. function checkStatus() { var parts = form.getAttribute('action').split('/'); var agentId = parts[2], actionId = parts[3]; var tries = 0; (function poll() { tries += 1; fetch('/api/form/' + encodeURIComponent(actionId) + '?agent=' + encodeURIComponent(agentId), { credentials: 'same-origin' }) .then(function (r) { return r.json().then(function (b) { return { ok: r.ok, b: b }; }); }) .then(function (res) { var status = res.ok ? (res.b.status || '') : ''; // ONE SENTENCE PER STATE, saying only what that state // establishes — read off `_TRANSITIONS` (r6/actions/models.py), // not off who called this. The three arms that were here read // 'sent', 'pending' and 'approved', which this system cannot // produce, and none of the five it can, so a form-fill that // really did run and fail arrived at the terminal "we cannot // tell" while the status said otherwise. A test compares the two // vocabularies so a new state cannot be added without an arm. // // What changed with them: the caller-conditioned clause. The // previous `awaiting_confirmation` copy said "your approval is // recorded", which that status does not carry — it is set at // COMMIT, before any human approval — and was true only of the // callers there were then. There is now a caller with no // confirmation established (an unreadable answer to the review // POST, QA #566), and a claim that depends on the caller is the // shape docs/2026-08-02-retro.md is about. Every sentence below // is true for every caller. if (status === 'completed') { gateMsg.className = 'alert alert-success'; // "and nothing was sent twice" was in this string. A status // of completed says the action completed; it says nothing // about how many times it ran. Claim only what was observed. // The route to the result (#847). Only an http(s) link becomes // an href; anything else is left out rather than shown raw. A // form goes nowhere, so it is "ready", never "it went to" // (#853): the person saves, prints or sends it. var link = res.b.delivery_link; if (typeof link === 'string' && /^https?:\/\//.test(link)) { gateMsg.textContent = 'Confirmed. Your form is ready. '; var a = document.createElement('a'); a.href = link; a.target = '_blank'; a.rel = 'noopener'; a.textContent = 'Open your form (PDF)'; gateMsg.appendChild(a); gateMsg.appendChild(document.createTextNode( ' to save it, print it or send it to your new doctor.')); return; } gateMsg.textContent = 'Confirmed. Your approval went through and this request was ' + 'carried out.' + (res.b.to ? ' It went to ' + res.b.to + '.' : ''); return; } // executing, failed, needs_review and unknown are reachable only // THROUGH executing, and the claim into executing spends an // approval grant. So each of them does establish that the // approval went through, on the status alone. if (status === 'executing') { gateMsg.className = 'alert alert-success'; gateMsg.textContent = 'Confirmed. Your approval went through and this request is ' + 'being carried out now.'; return; } if (status === 'needs_review') { gateMsg.className = 'alert alert-warning'; gateMsg.textContent = 'Your approval went through. This request needs a person to ' + 'look at it before it can be completed.'; return; } if (status === 'failed') { gateMsg.className = 'alert alert-danger'; // Red, and that is not the #416 shape: #416 is a definite // failure printed for an outcome nobody observed. Here the // engine observed it. Naming what failed matters — the // approval landed, the work after it did not. // Plain words first (#876). This handler serves every kind // of request, so "this request", not "your form". gateMsg.textContent = "We couldn't finish this request. " + "Approving again won't retry it. " + 'Ask your assistant to try again. ' + 'Your hub lists it as not finished.'; return; } if (status === 'unknown') { gateMsg.className = 'alert alert-warning'; gateMsg.textContent = 'We cannot tell whether this request went out. We have not ' + 'sent it again, because it may already have gone.'; return; } // The action is committed and not yet claimed: nothing has run. // The route back is a reload, because every branch that reaches // here has disabled Approve — and it has to be offered, since on // one of those branches the review may never have been saved and // "do not approve again" would strand the patient mid-approval. // Offering it is safe on all of them: once a confirmation exists // the review route answers 409 (and the payload seal refuses the // resubmit that races it), and the claim transition has a single // winner, so a second approval cannot produce a second send. if (status === 'awaiting_confirmation') { gateMsg.className = 'alert alert-warning'; gateMsg.textContent = 'This request has not been carried out yet. If you need to ' + 'approve it again, reload this page first — a second ' + 'approval cannot send it twice.'; return; } if (status === 'expired') { gateMsg.className = 'alert alert-warning'; gateMsg.textContent = 'This request expired before it was carried out, and it ' + 'cannot be approved now.'; return; } if (status === 'declined') { gateMsg.className = 'alert alert-secondary'; gateMsg.textContent = 'This request was declined and will not be carried out. ' + 'If you change your mind, ask your assistant to start it again.'; return; } if (tries < 4) { setTimeout(poll, 2000 * tries); return; } // Still unknown after four tries. Say that, and point at the one // thing that can still resolve it. This used to end "Please do // not approve a second time", which is an instruction we cannot // support: on the unreadable-answer path nothing may have been // saved, and obeying it strands the patient mid-approval. gateMsg.className = 'alert alert-warning'; gateMsg.textContent = 'We still cannot tell whether your approval went through, so ' + 'we have not sent it again. Reload this page to see where ' + 'this request stands.'; }) .catch(function () { if (tries < 4) { setTimeout(poll, 2000 * tries); return; } gateMsg.className = 'alert alert-warning'; gateMsg.textContent = 'We still cannot tell whether your approval went through, so ' + 'we have not sent it again. Reload this page to see where ' + 'this request stands.'; }); })(); } evaluate(); // Decline (#520): an explicit no, recorded as one. Never armed by the // gate above, because saying no needs no attestation; a person who // has not reviewed the rows can still refuse the whole request. One // POST on the same single-use credential the relay mints for Approve. // The same rule as the submit handler on what to do afterwards: only a // branch where the engine answered about THIS request reads the status // back; a request that never arrived (status 0) or a gateway speaking // for a silent engine sends the person to a reload instead. var declineBtn = document.getElementById('decline-btn'); declineBtn.addEventListener('click', function () { declineBtn.disabled = true; btn.disabled = true; var target = form.getAttribute('action').replace(/\/submit$/, '/decline'); fetch(target, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: '{}' }) .then(function (r) { return r.json().then(function (b) { return { status: r.status, b: b }; }, function () { return { status: r.status, b: {} }; }); }, function () { return { status: 0, b: {} }; }) .then(function (res) { if (res.status === 200 && res.b.declined === true) { // Declined: nothing on the page is a question any more, so no // choice stays live to suggest otherwise (#884 G7). form.querySelectorAll('input').forEach(function (el) { el.disabled = true; }); gateMsg.className = 'alert alert-secondary'; gateMsg.textContent = 'This request was declined and will not be carried out. ' + 'If you change your mind, ask your assistant to start it again.'; showBackToChat(); return; } if (res.b && res.b.declined === false) { // The engine answered: already approved, lapsed, or moved on. // The status says which. checkStatus(); showBackToChat(); return; } gateMsg.className = 'alert alert-warning'; gateMsg.textContent = 'We could not record your answer. Reload this page to see ' + 'where this request stands.'; }); });