Recover Human Gate decisions after interrupted mobile responses
All checks were successful
CI / lint (push) Successful in 3m40s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 7m10s
CI / release-candidate (push) Successful in 7s

Closes #1446
This commit is contained in:
timmy 2026-08-26 23:05:32 +00:00
commit ff469d96d1
22 changed files with 257 additions and 60 deletions

View File

@ -859,7 +859,10 @@ Authenticated release producers use `POST /api/v1/human-gates/intake` with an
SQLite storage is configured by `STACKCHAIN_HUMAN_GATE_DB`. Account isolation
binds each queue to the upstream principal ID and login, including its offline
browser cache. Review decisions carry `expected_revision`, a stable idempotency
key across network retries, and return durable receipts. If producer evidence
key across network retries and mobile app restarts, and return durable receipts.
An interrupted decision response is restored as **Decision outcome unknown**;
the operator verifies the exact persisted operation instead of creating a second
decision. The client clears that operation only after recovering a receipt. If producer evidence
changes after a release or hold, the update reopens the exact hash for a new
revision-checked decision instead of silently retaining the old outcome.
New hashes mark older pending candidates `superseded` without removing their audit

View File

@ -27,6 +27,8 @@ The immutable identity is authenticated account + `source` + `project` + `candid
Consumers list `GET /api/v1/human-gates`, inspect `GET /api/v1/human-gates/{id}`, and submit `POST /api/v1/human-gates/{id}/decision` with a new `Idempotency-Key`, `expected_revision`, and either `release` or `hold`. Hold requires a reason. Release requires all three checklist confirmations; if any required check is not successful it also requires an explicit override reason. Durable receipts are available at `GET /api/v1/human-gate-receipts/{receipt_id}`. All endpoints are authenticated, account-bound, and `Cache-Control: no-store`.
Before a mobile client submits Release or Hold, it stores one account-, gate-, and revision-bound pending operation with the exact payload and idempotency key. A transport interruption changes the decision tray to **Decision outcome unknown** and blocks replacement decisions. **Verify decision** replays that exact operation; server-side idempotency returns the original receipt if the decision committed, or executes it once if the first request never arrived. The pending operation and saved review progress are removed only after a receipt is confirmed. Reloading or restarting the installed app restores this verification flow even when the committed gate is no longer present in the live pending queue. Pending decision data never crosses the immutable account cache boundary.
## Privacy-safe notification contract
Web Push is opt-in per authenticated device under **My Work → Settings → Notify me when release decisions are waiting**. The preference is stored with that device's push subscription; revoked sessions are removed before delivery. The poller honors the device's routine-alert quiet hours, coalesces unchanged pending counts, and routes a notification tap to `#/my-work/human-gates`.

View File

@ -1626,5 +1626,6 @@ textarea { resize: vertical; min-height: 120px; }
.human-gates{position:fixed;inset:0;z-index:72;background:var(--bg);overflow:auto;padding:18px max(16px,env(safe-area-inset-right)) max(24px,env(safe-area-inset-bottom)) max(16px,env(safe-area-inset-left))}
.human-gates[hidden]{display:none}.human-gates-header{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;max-width:760px;margin:0 auto 14px}.human-gates-header h3{margin:0}.human-gates-list,.human-gate-detail-host{display:grid;gap:10px;max-width:760px;margin:0 auto 14px}.human-gate-card{display:grid;grid-template-columns:1fr auto;text-align:left;gap:6px 12px;min-height:58px;padding:12px;border:1px solid var(--border);border-radius:14px;background:var(--panel)}.human-gate-card span{grid-column:1/-1;color:var(--muted)}.human-gate-detail{display:grid;gap:12px;padding:16px;border:1px solid var(--border);border-radius:16px;background:var(--panel)}.human-gate-detail h3,.human-gate-detail h4,.human-gate-detail p{margin:0}.human-gate-detail label{display:grid;gap:6px}.human-gate-detail label:has(input[type=checkbox]){grid-template-columns:auto 1fr;align-items:center}.human-gate-detail textarea{min-height:78px}.human-gate-decision-tray{display:grid;gap:10px}.human-gate-decision-state{display:grid;gap:4px}.human-gate-decision-state [data-gate-error]{color:var(--danger,#fb7185)}.human-gate-decision-actions{display:grid;grid-template-columns:1fr 1fr;gap:10px}.human-gate-decision-actions button{min-height:44px}.human-gates-zero{display:grid;gap:6px;text-align:center;padding:32px 16px;border:1px dashed var(--border);border-radius:16px}.human-gates-launcher span{display:inline-grid;place-items:center;min-width:22px;border-radius:999px;background:var(--accent);color:#06101f}
.human-gate-views{display:grid;grid-template-columns:1fr 1fr;gap:8px;max-width:760px;margin:0 auto 14px}.human-gate-views button{min-height:44px}.human-gate-views button[aria-pressed="true"]{border-color:var(--accent);background:rgba(96,165,250,.14)}.human-gate-history-card time{font-size:.78rem;color:var(--muted)}.human-gate-history-more{min-height:44px;width:100%;padding:max(10px,env(safe-area-inset-bottom)) 12px}.human-gate-state{font-weight:700}.human-gate-state-released{color:#86efac}.human-gate-state-held{color:#fbbf24}.human-gate-state-superseded{color:#cbd5e1}.human-gate-receipt{padding-bottom:16px}
.human-gate-decision-recovery{border-color:#f59e0b}.human-gate-decision-recovery .human-gate-decision-state span{color:var(--muted)}.human-gate-decision-recovery button{min-height:44px;min-width:140px}
@media(max-width:600px){.human-gate-detail{padding-bottom:calc(124px + env(safe-area-inset-bottom))}.human-gate-decision-tray{position:sticky;bottom:calc(-1 * max(24px,env(safe-area-inset-bottom)));z-index:4;margin:0 -16px calc(-124px - env(safe-area-inset-bottom));padding:12px 16px;padding-bottom:max(16px,env(safe-area-inset-bottom));border-top:1px solid var(--border);background:rgba(11,21,38,.97);box-shadow:0 -12px 24px rgba(0,0,0,.32);backdrop-filter:blur(10px)}}
@media(min-width:761px){.human-gates{inset:8% max(8%,80px);border:1px solid var(--border);border-radius:20px;box-shadow:0 24px 80px rgba(0,0,0,.4)}}

View File

@ -704,6 +704,15 @@
qs('#human-gates-status').textContent = error.message;
}));
qs('#human-gate-detail').addEventListener('click', event => {
const recovery = event.target.closest('[data-gate-recover]');
if (recovery) {
recovery.disabled = true;
humanGates.recoverDecision().catch(error => {
qs('#human-gates-status').textContent = error.message || 'The decision outcome could not be verified.';
recovery.disabled = false;
});
return;
}
const decision = event.target.closest('[data-gate-decision]')?.dataset.gateDecision;
if (!decision) return;
humanGates.submitDecision(decision).catch(error => {

View File

@ -26,6 +26,8 @@ function createHumanGates(options = {}) {
const cacheKey = () => 'stackchain.human-gates.v1:' + String(getAccountKey() || '').trim().toLowerCase();
const progressKey = item => 'stackchain.human-gate-review.v1:' +
String(getAccountKey() || '').trim().toLowerCase() + ':' + item.id + ':' + item.revision;
const decisionStorageKey = () => 'stackchain.human-gate-decision.v1:' +
String(getAccountKey() || '').trim().toLowerCase();
const setText = (node, value) => { if (node) node.textContent = value; };
const setHtml = (node, value) => { if (node) node.innerHTML = value; };
const publish = state => onChange?.(JSON.parse(JSON.stringify(queue)), state);
@ -65,6 +67,26 @@ function createHumanGates(options = {}) {
try { storage.setItem(progressKey(item), JSON.stringify(progress)); return true; } catch (_) { return false; }
}
function restorePendingDecision(item = null) {
if (!getLogin()) return null;
try {
const value = JSON.parse(storage.getItem(decisionStorageKey()) || 'null');
if (!value || typeof value !== 'object' || !value.idempotency_key || !value.operation ||
!value.payload || !value.gate_id || !Number.isInteger(value.revision)) return null;
if (item && (value.gate_id !== item.id || value.revision !== item.revision)) return null;
return value;
} catch (_) { return null; }
}
function savePendingDecision(value) {
storage.setItem(decisionStorageKey(), JSON.stringify(value));
}
function clearPendingDecision(item) {
if (!restorePendingDecision(item)) return;
try { storage.removeItem?.(decisionStorageKey()); } catch (_) {}
}
function valuesFromDetail() {
const checklist = Object.fromEntries(Array.from(nodes.detail?.querySelectorAll?.('[data-gate-checklist]') || []).map(input => [input.dataset.gateChecklist, input.checked]));
return {
@ -215,6 +237,7 @@ function createHumanGates(options = {}) {
return;
}
const progress = restoreProgress(item) || {checklist:{}, reason:'', override_reason:''};
const pendingDecision = restorePendingDecision(item);
const checked = key => progress.checklist?.[key] === true ? ' checked' : '';
const checks = (item.checks || []).map(check =>
'<li class="gate-check gate-check-' + escape(check.state) + '"><strong>' + escape(check.name) + '</strong> · ' + escape(check.state) + (check.required ? ' · required' : '') + '</li>'
@ -236,11 +259,14 @@ function createHumanGates(options = {}) {
'<label><input type="checkbox" data-gate-checklist="provenance_reviewed"' + checked('provenance_reviewed') + '> Provenance reviewed</label>' +
'<label>Hold reason<textarea data-gate-reason>' + escape(progress.reason) + '</textarea></label>' +
'<label>Override reason<textarea data-gate-override>' + escape(progress.override_reason) + '</textarea></label>' +
'<div class="human-gate-decision-tray"><div class="human-gate-decision-state">' +
'<strong data-gate-readiness>0 of 3 confirmations complete</strong>' +
'<span data-gate-error role="alert" hidden></span></div>' +
'<div class="human-gate-decision-actions"><button type="button" data-gate-decision="hold">Hold &amp; next</button>' +
'<button type="button" data-gate-decision="release">Release &amp; next</button></div></div></article>'
'<div class="human-gate-decision-tray' + (pendingDecision ? ' human-gate-decision-recovery' : '') + '"><div class="human-gate-decision-state">' +
(pendingDecision ? '<strong>Decision outcome unknown</strong><span role="status">The previous ' + escape(pendingDecision.decision) +
' response was interrupted. Verify it before making another decision.</span>' :
'<strong data-gate-readiness>0 of 3 confirmations complete</strong><span data-gate-error role="alert" hidden></span>') + '</div>' +
'<div class="human-gate-decision-actions">' + (pendingDecision ?
'<button type="button" data-gate-recover>Verify decision</button>' :
'<button type="button" data-gate-decision="hold">Hold &amp; next</button><button type="button" data-gate-decision="release">Release &amp; next</button>') +
'</div></div></article>'
);
updateReadiness();
}
@ -266,6 +292,16 @@ function createHumanGates(options = {}) {
if (epoch !== loadEpoch || accountKey !== loadedAccountKey) return queue;
if (!live) throw new Error('Human Gates response is invalid.');
queue = { pending_count: live.pending_count, items: live.items.slice() };
const interrupted = restorePendingDecision();
if (interrupted && interrupted.item?.id === interrupted.gate_id && interrupted.item.revision === interrupted.revision) {
const recoveryItem = {...interrupted.item, decision_recovery:true};
const existingIndex = queue.items.findIndex(candidate => candidate.id === interrupted.gate_id);
if (existingIndex >= 0) queue.items[existingIndex] = recoveryItem;
else {
queue.items.unshift(recoveryItem);
queue.pending_count += 1;
}
}
save(queue);
render();
publish({available:true, authoritative:true});
@ -311,6 +347,8 @@ function createHumanGates(options = {}) {
function current() { return reviewIndex < 0 ? null : (reviewSnapshot[reviewIndex] || null); }
function idempotencyKey(item, decision, payload) {
const operation = item.id + ':' + item.revision + ':' + decision + ':' + JSON.stringify(payload);
const pending = restorePendingDecision(item);
if (pending?.operation === operation) return { operation, key: pending.idempotency_key };
if (decisionKeys.has(operation)) return { operation, key: decisionKeys.get(operation) };
const nonce = globalThis.crypto?.randomUUID?.() || (Date.now().toString(36) + '-' + Math.random().toString(36).slice(2));
const key = 'human-gate:' + item.id + ':' + item.revision + ':' + decision + ':' + nonce;
@ -318,6 +356,30 @@ function createHumanGates(options = {}) {
return { operation, key };
}
function completeDecision(item, decision, operation, receipt) {
decisionKeys.delete(operation);
clearPendingDecision(item);
try { storage.removeItem?.(progressKey(item)); } catch (_) {}
queue.items = queue.items.filter(candidate => candidate.id !== item.id);
queue.pending_count = Math.max(0, queue.pending_count - 1);
save(queue); render();
publish({available:true, authoritative:true, decision:true});
reviewIndex += 1;
const next = current();
if (next) reviewNext(); else renderDetail(null);
setText(nodes.status, next ? (decision === 'release' ? 'Released. Reviewing next gate.' : 'Held. Reviewing next gate.') : 'Decision saved. Human Gates review snapshot complete.');
return { receipt, next };
}
async function postDecision(item, pending) {
const receipt = await fetchJson('api/v1/human-gates/' + encodeURIComponent(item.id) + '/decision', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': pending.idempotency_key },
body: JSON.stringify(pending.payload),
});
return completeDecision(item, pending.decision, pending.operation, receipt);
}
function decisionError(message, selector) {
const error = new Error(message);
error.targetSelector = selector;
@ -351,22 +413,19 @@ function createHumanGates(options = {}) {
if (decisionFlight) return decisionFlight;
const operation = (async () => {
const decisionKey = idempotencyKey(item, decision, payload);
const receipt = await fetchJson('api/v1/human-gates/' + encodeURIComponent(item.id) + '/decision', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': decisionKey.key },
body: JSON.stringify(payload),
});
decisionKeys.delete(decisionKey.operation);
try { storage.removeItem?.(progressKey(item)); } catch (_) {}
queue.items = queue.items.filter(candidate => candidate.id !== item.id);
queue.pending_count = Math.max(0, queue.pending_count - 1);
save(queue); render();
publish({available:true, authoritative:true, decision:true});
reviewIndex += 1;
const next = current();
if (next) reviewNext(); else renderDetail(null);
setText(nodes.status, next ? (decision === 'release' ? 'Released. Reviewing next gate.' : 'Held. Reviewing next gate.') : 'Decision saved. Human Gates review snapshot complete.');
return { receipt, next };
const pending = {
gate_id:item.id, revision:item.revision, decision, payload,
operation:decisionKey.operation, idempotency_key:decisionKey.key,
item:JSON.parse(JSON.stringify(item)),
};
savePendingDecision(pending);
try {
return await postDecision(item, pending);
} catch (error) {
renderDetail(item);
setText(nodes.status, 'Decision outcome unknown. Verify the interrupted decision while online.');
throw error;
}
})();
decisionFlight = operation;
try {
@ -376,6 +435,20 @@ function createHumanGates(options = {}) {
}
}
async function recoverDecision() {
const item = current();
if (!item) throw new Error('No gate is selected.');
if (!isOnline()) throw new Error('Decision verification requires an online connection.');
if (!String(getLogin() || '').trim()) throw new Error('Authenticated account identity is required.');
const pending = restorePendingDecision(item);
if (!pending) throw new Error('No interrupted decision exists for this gate revision.');
if (decisionFlight) return decisionFlight;
const operation = postDecision(item, pending);
decisionFlight = operation;
try { return await operation; }
finally { if (decisionFlight === operation) decisionFlight = null; }
}
async function submitDecision(decision) {
const values = valuesFromDetail();
updateReadiness(values);
@ -425,7 +498,7 @@ function createHumanGates(options = {}) {
return {
load, open, reviewNext, select, showHistory, loadMoreHistory, selectHistory, showPending,
decideAndNext, submitDecision, current, saveProgress,
decideAndNext, recoverDecision, submitDecision, current, saveProgress,
setOnChange(callback) { onChange = callback; },
restoreCached: restore,
snapshot: () => JSON.parse(JSON.stringify(queue)),

View File

@ -49,6 +49,15 @@ function createProgressiveHumanGates(options = {}) {
if (card) controller.select(card.dataset.humanGateId);
});
nodes.detail?.addEventListener?.('click', event => {
const recovery = event.target?.closest?.('[data-gate-recover]');
if (recovery) {
recovery.disabled = true;
controller.recoverDecision().catch(error => {
showError(error);
recovery.disabled = false;
});
return;
}
const decision = event.target?.closest?.('[data-gate-decision]')?.dataset.gateDecision;
if (!decision) return;
controller.submitDecision(decision).catch(error => {

View File

@ -1,7 +1,7 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/private-data-registry.js');
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v149';
const CACHE = 'stackchain-dashboard-shell-v150';
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;

View File

@ -29,7 +29,7 @@ def test_release_artifact_reopens_human_gates_with_one_fresh_mobile_snapshot(
current = {"gate": "g1"}
evidence_url = {"value": "/evidence/manifest"}
list_requests: list[str] = []
decision_requests: list[str] = []
decision_requests: list[dict] = []
browser_errors: list[str] = []
def gate(gate_id: str) -> dict:
@ -62,7 +62,14 @@ def test_release_artifact_reopens_human_gates_with_one_fresh_mobile_snapshot(
def human_gates_route(route):
path = route.request.url.split("?", 1)[0]
if route.request.method == "POST":
decision_requests.append(path)
decision_requests.append({
"path": path,
"key": route.request.headers.get("idempotency-key"),
"body": route.request.post_data_json,
})
if len(decision_requests) == 1:
route.abort("failed")
return
payload = {"receipt_id": "receipt-1", "state": "released"}
elif path.endswith("/api/v1/human-gates"):
list_requests.append(current["gate"])
@ -131,9 +138,28 @@ def test_release_artifact_reopens_human_gates_with_one_fresh_mobile_snapshot(
assert progress_key and progress_key.endswith(":g1:1")
assert page.evaluate("key => localStorage.getItem(key) !== null", progress_key)
page.locator('[data-gate-decision="release"]').click()
expect(page.locator("#human-gate-detail")).to_contain_text("Decision outcome unknown")
expect(page.locator("[data-gate-recover]")).to_have_count(1)
decision_key = page.evaluate(
"Object.keys(localStorage).find(key => key.startsWith('stackchain.human-gate-decision.v1:'))"
)
assert decision_key
assert len(decision_requests) == 1
page.reload(wait_until="networkidle")
page.evaluate("document.querySelector('#open-human-gates').click()")
expect(page.locator("#human-gates")).to_be_visible()
expect(page.locator("#human-gate-detail")).to_contain_text("Decision outcome unknown")
recover = page.locator("[data-gate-recover]")
expect(recover).to_be_visible()
recover_height = recover.evaluate("element => element.getBoundingClientRect().height")
assert recover_height >= 44
recover.click()
expect(page.locator("#human-gates-status")).to_contain_text("Decision saved")
expect(page.locator(".human-gate-decision-tray")).to_have_count(0)
assert len(decision_requests) == 1
assert len(decision_requests) == 2
assert decision_requests[0] == decision_requests[1]
assert not page.evaluate("key => localStorage.getItem(key) !== null", decision_key)
assert not page.evaluate("key => localStorage.getItem(key) !== null", progress_key)
page.evaluate("document.querySelector('#close-human-gates').click()")

View File

@ -306,9 +306,13 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
f"requests={fake.requests[-20:]!r}"
)
# Deferred workspace startup can report its already-issued offline request
# after the pre-reconnect clear; retain every non-offline browser error.
# after the pre-reconnect clear. Chromium uses either its explicit offline
# code or a generic fetch rejection for that same request, depending on
# whether the service worker or page observes the disconnect first.
browser_errors[:] = [
error for error in browser_errors if "ERR_INTERNET_DISCONNECTED" not in error
error for error in browser_errors
if "ERR_INTERNET_DISCONNECTED" not in error
and not error.startswith("context failed TypeError: Failed to fetch")
]
for _ in range(40):

View File

@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
assert '.update-reply-actions button { min-height:44px;' in html
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v149" in worker
assert "stackchain-dashboard-shell-v150" in worker

View File

@ -593,7 +593,7 @@ process.stdout.write(JSON.stringify({{
assert ".following-disposition-mode" in css
assert "if (searchPreviewReturnKind === 'following')" in dashboard
assert "e.key === 'Escape' && searchPreviewReturnKind === 'following'" in dashboard
assert "stackchain-dashboard-shell-v149" in service_worker
assert "stackchain-dashboard-shell-v150" in service_worker
def test_prepare_today_lazily_refreshes_and_directly_reviews_following():

View File

@ -470,7 +470,7 @@ def test_mobile_decision_tray_is_safe_area_aware_touch_sized_and_does_not_cover_
def test_human_gate_decision_tray_frontend_assets_invalidate_the_installed_shell_cache():
worker = WORKER.read_text()
assert "stackchain-dashboard-shell-v149" in worker
assert "stackchain-dashboard-shell-v150" in worker
def test_unmet_required_check_is_named_inline_and_focuses_the_override_reason():
@ -548,6 +548,76 @@ const gates=createHumanGates({storage:{getItem:()=>null,setItem(){}},getLogin:()
assert output["keys"][0] == output["keys"][1]
def test_interrupted_decision_is_recovered_after_reload_with_the_exact_operation():
output = run_node(r"""
const stored=new Map(); const requests=[]; let failResponse=true, committed=false;
const storage={getItem:key=>stored.get(key)||null,setItem:(key,value)=>stored.set(key,value),removeItem:key=>stored.delete(key)};
const item={id:'g1',title:'Release candidate',candidate_hash:'a1',revision:3,checks:[]};
const createController=()=>{
const detail={innerHTML:'',addEventListener(){},querySelectorAll:()=>[],querySelector:()=>null};
const gates=createHumanGates({
storage,getLogin:()=> 'timmy',getAccountKey:()=> '7:timmy',isOnline:()=>true,
nodes:{count:{},list:{},status:{},panel:{},detail},location:{hash:''},
fetchJson:async(path,options={})=>{
if(options.method==='POST') {
requests.push({key:options.headers['Idempotency-Key'],body:JSON.parse(options.body)});
if(failResponse) throw new Error('response interrupted');
return {receipt_id:'r1',state:'held'};
}
return committed ? {pending_count:0,items:[]} : {pending_count:1,items:[item]};
},
});
return {gates,detail};
};
(async()=>{
const first=createController(); await first.gates.load(); first.gates.reviewNext();
first.gates.saveProgress({reason:'Awaiting approval',checklist:{}});
try { await first.gates.decideAndNext('hold',{reason:'Awaiting approval',checklist:{}}); } catch (_) {}
const immediateHtml=first.detail.innerHTML;
const decisionStorageKey=[...stored.keys()].find(key=>key.startsWith('stackchain.human-gate-decision.v1:'));
const pendingBefore=JSON.parse(stored.get(decisionStorageKey));
committed=true; failResponse=false;
const second=createController(); await second.gates.load(); second.gates.reviewNext();
const recoveryHtml=second.detail.innerHTML;
const result=await second.gates.recoverDecision();
process.stdout.write(JSON.stringify({
decisionStorageKey,pendingBefore,immediateHtml,recoveryHtml,requests,result,
pendingCleared:!stored.has(decisionStorageKey),
progressCleared:![...stored.keys()].some(key=>key.startsWith('stackchain.human-gate-review.v1:')),
snapshot:second.gates.snapshot(),
}));
})().catch(error=>process.stdout.write(JSON.stringify({fatal:error.stack})));
""")
assert "fatal" not in output, output.get("fatal")
assert output["decisionStorageKey"] == "stackchain.human-gate-decision.v1:7:timmy"
assert output["pendingBefore"]["gate_id"] == "g1"
assert output["pendingBefore"]["revision"] == 3
assert output["pendingBefore"]["decision"] == "hold"
assert "Decision outcome unknown" in output["immediateHtml"]
assert "Decision outcome unknown" in output["recoveryHtml"]
assert "data-gate-recover" in output["recoveryHtml"]
assert len(output["requests"]) == 2
assert output["requests"][0] == output["requests"][1]
assert output["pendingCleared"] is True
assert output["progressCleared"] is True
assert output["snapshot"] == {"pending_count": 0, "items": []}
assert output["result"]["receipt"]["receipt_id"] == "r1"
def test_interrupted_decision_recovery_is_wired_for_progressive_and_hydrated_mobile_shells():
dashboard = DASHBOARD.read_text()
progressive = PROGRESSIVE.read_text()
css = CSS.read_text()
assert "closest('[data-gate-recover]')" in dashboard
assert "humanGates.recoverDecision()" in dashboard
assert "closest?.('[data-gate-recover]')" in progressive
assert "controller.recoverDecision()" in progressive
assert ".human-gate-decision-recovery" in css
assert ".human-gate-decision-recovery button{min-height:44px" in css
assert "stackchain-dashboard-shell-v150" in WORKER.read_text()
def test_failed_decision_keeps_review_progress_and_successful_retry_clears_it():
output = run_node(r"""
const stored=new Map(); let attempts=0;
@ -707,7 +777,7 @@ def test_human_gate_mobile_shell_and_deep_route_are_wired():
assert "mobileStartDay.reconcile({authoritative:true, authoritativePhases:['gate']})" in dashboard
assert "counts.gate = queueCounts.gate" in dashboard
assert "gate:preparationItems.gate || []" in dashboard
assert "stackchain-dashboard-shell-v149" in WORKER.read_text()
assert "stackchain-dashboard-shell-v150" in WORKER.read_text()
def test_human_gate_history_tabs_are_touch_sized_wired_and_invalidate_the_shell():
@ -728,7 +798,7 @@ def test_human_gate_history_tabs_are_touch_sized_wired_and_invalidate_the_shell(
assert ".human-gate-views button{min-height:44px" in css
assert ".human-gate-history-card time" in css
assert ".human-gate-history-more{min-height:44px;width:100%" in css
assert "stackchain-dashboard-shell-v149" in WORKER.read_text()
assert "stackchain-dashboard-shell-v150" in WORKER.read_text()
def test_deep_link_opens_human_gates_without_waiting_for_optional_workspace():

View File

@ -435,5 +435,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v149" in source
assert "stackchain-dashboard-shell-v150" in source
assert "BASE + 'static/later-sync.js'" in source

View File

@ -256,4 +256,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" in css
assert "stackchain-dashboard-shell-v149" in worker
assert "stackchain-dashboard-shell-v150" in worker

View File

@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
assert "stackchain-dashboard-shell-v149" in worker
assert "stackchain-dashboard-shell-v150" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions():

View File

@ -383,7 +383,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
assert "controller.recoverPermission('deadline')" in dashboard
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
assert "BASE + 'static/mobile-device-setup.js'" in worker
assert "stackchain-dashboard-shell-v149" in worker
assert "stackchain-dashboard-shell-v150" in worker
assert ".device-setup-panel" in css
assert ".device-readiness-card" in css
assert "overflow-x:hidden" in css

View File

@ -274,5 +274,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights(
def test_mobile_insights_rolls_into_the_offline_shell():
worker = (CONTROLLER.parent / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v149" in worker
assert "stackchain-dashboard-shell-v150" in worker
assert "BASE + 'static/mobile-insights.js'" in worker

View File

@ -469,7 +469,7 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
assert ".mobile-start-day-finish { min-height:44px;" in html
assert "max-width:100%; overflow-wrap:anywhere;" in html
assert "BASE + 'static/mobile-start-day.js'" in service_worker
assert "stackchain-dashboard-shell-v149" in service_worker
assert "stackchain-dashboard-shell-v150" in service_worker
@pytest.mark.anyio

View File

@ -418,7 +418,7 @@ async def test_starting_saved_today_work_closes_a_concurrent_rollover_planner():
def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text()
assert "stackchain-dashboard-shell-v149" in source
assert "stackchain-dashboard-shell-v150" in source
assert "BASE + 'static/plan-today.js'" in source
assert "BASE + 'static/plan-today-readiness.js'" in source
assert "BASE + 'static/plan-today-preview.js'" in source

View File

@ -189,14 +189,14 @@ async function dispatchPush(payload) {{
def test_shared_progressive_snapshot_broker_rolls_the_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v149" in source
assert "stackchain-dashboard-shell-v150" in source
assert "BASE + 'static/progressive-live-snapshot.js'" in source
def test_week_unplan_undo_rolls_the_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v149" in source
assert "stackchain-dashboard-shell-v150" in source
assert "BASE + 'static/week-plan.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@ -204,20 +204,20 @@ def test_week_unplan_undo_rolls_the_offline_shell():
def test_private_today_action_mailbox_rolls_the_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v149" in source
assert "stackchain-dashboard-shell-v150" in source
def test_per_day_week_conflict_ui_rolls_the_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v149" in source
assert "stackchain-dashboard-shell-v150" in source
assert "BASE + 'static/week-plan.js'" in source
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v149" in source
assert "stackchain-dashboard-shell-v150" in source
assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@ -226,7 +226,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v149" in source
assert "stackchain-dashboard-shell-v150" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/authored-outbox.js'" in source
assert "BASE + 'static/background-issue-sync.js'" in source
@ -235,7 +235,7 @@ def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v149" in source
assert "stackchain-dashboard-shell-v150" in source
assert "BASE + 'static/issue-evidence-review.js'" in source
assert "BASE + 'static/issue-attachment.js'" in source
@ -243,14 +243,14 @@ def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v149" in source
assert "stackchain-dashboard-shell-v150" in source
assert "BASE + 'static/dashboard.js'" in source
def test_offline_review_next_ships_today_completion_atomically():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v149" in source
assert "stackchain-dashboard-shell-v150" in source
assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@ -258,7 +258,7 @@ def test_offline_review_next_ships_today_completion_atomically():
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v149" in source
assert "stackchain-dashboard-shell-v150" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@ -266,7 +266,7 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v149" in source
assert "stackchain-dashboard-shell-v150" in source
assert "BASE + 'static/issue-sheet.js'" in source
assert "BASE + 'static/checklist-conflict.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@ -276,14 +276,14 @@ def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v149" in source
assert "stackchain-dashboard-shell-v150" in source
assert "BASE + 'static/later-picker.js'" in source
def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v149" in source
assert "stackchain-dashboard-shell-v150" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@ -292,21 +292,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v149" in source
assert "stackchain-dashboard-shell-v150" in source
assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v149" in source
assert "stackchain-dashboard-shell-v150" in source
assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v149" in source
assert "stackchain-dashboard-shell-v150" in source
assert "BASE + 'static/update-ownership.js'" in source
@ -1390,7 +1390,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
def test_queue_today_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v149" in source
assert "stackchain-dashboard-shell-v150" in source
assert "BASE + 'static/queue-today.js'" in source

View File

@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
def test_readiness_runtime_is_available_in_offline_shell():
service_worker = SERVICE_WORKER.read_text()
assert "const CACHE = 'stackchain-dashboard-shell-v149';" in service_worker
assert "const CACHE = 'stackchain-dashboard-shell-v150';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker

View File

@ -343,7 +343,7 @@ listeners['stackchain:first-task-complete']();
def test_inflight_today_drain_ships_in_a_new_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v149" in source
assert "stackchain-dashboard-shell-v150" in source
assert "BASE + 'static/today-sync.js'" in source