{# 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.';
});
});