From 2b597e68d88c50790d5ac5bb4a1746341fa2b761 Mon Sep 17 00:00:00 2001 From: "n.siegfried" Date: Wed, 19 Aug 2026 10:05:57 -0700 Subject: [PATCH] T7.3 - CR-015/A1/D4: the hold clears when the constraints do ROOT CAUSE, exactly (the done-when asks for it): Hold state was stored, twice, and derived nowhere. 1) Client: submitHold() wrote prevStatus='Issue', destroying the status the hold interrupted at the moment it was placed - there was never anything to return to. Clearing the last constraint then fell into the "Mark it as Issued now?" confirm, because STATUS_ORDER.indexOf('Issue') is -1 and -1 reads as "before Issued". Decline it and the package stayed on hold with zero open constraints, forever - the exact state reproduced live in front of the Micron team. 2) Server: server/app.py's STATUS_ORDER put "Issue" at index 4, so _released('Issue') was true and every transition OUT of hold skipped enforce_release_gates() as "already released". POST /api/wps/{id}/status could walk a held package to Issued past its open constraint. The comment claimed the ladder was "mirrored in the front end"; the front end's ladder has no 'Issue' in it at all. What changed: - setConstraint() recalculates hold state on EVERY constraint change: clearing the last open constraint on a held package releases it immediately - no refresh, no dialog - back to the status recorded on the hold entry (`from`), which now rides on data.holds and survives save/reload. - Every hold and release is history: pkgHolds entries carry ts, by, from/to, reason; the exported Hold Log gained a By column; the server writes hold_logged / hold_released audit rows (with the reason from data.holds) on both the upsert and the /status endpoint. - _released() no longer counts the hold: 'Issue' is a branch, not a rung. Leaving hold to a field state re-runs the gates; entering hold never did and still does not. The critical-reopen email keeps its old reach ("has been in the field" includes on-hold). - A1 preserved by name and by test: confirmEarlyRelease() still the one place a gate override is written (comment-stripped grep asserts exactly one pkgGateOverride assignment), still reason-first, still logged server-side. D4 - what Urgent does (amended Aug 18): surface the audited path, add no new one. confirmEarlyRelease() now also covers open constraints, but only for an Urgent package, and the override must NAME every constraint it crosses - the server refuses coverage by an old reason. The release banner gives an Urgent package the override as its primary action (a real `; + } } b.innerHTML=`
${txt}
`; updateStickyStatus(); @@ -999,9 +1012,17 @@ function updateReleaseBanner(){ // Releasing with an unclosed predecessor is allowed but must be explained. The // reason rides on the package (data.gateOverride) and the server writes it to the // audit log. Returns false if the user backed out. -function confirmEarlyRelease(blocking){ - const list=blocking.map(p=>'• '+(p.number||p.id)+' — '+p.status).join('\n'); - const reason=prompt('These predecessor packages are not Closed yet:\n\n'+list+ +function confirmEarlyRelease(blocking, openConstraints){ + // D4 extended this to open constraints on an URGENT package - the same audited + // path, not a new one. A silent bypass would destroy the delay-documentation + // use case that justifies the constraint workflow, so the reason is mandatory + // and the override records exactly which gates it crossed. + const open=(openConstraints||[]).slice(); + const parts=[]; + if(open.length) parts.push('These constraints are still OPEN:\n\n'+open.map(n=>'• '+n).join('\n')); + if(blocking.length) parts.push('These predecessor packages are not Closed yet:\n\n'+ + blocking.map(p=>'• '+(p.number||p.id)+' — '+p.status).join('\n')); + const reason=prompt(parts.join('\n\n')+ '\n\nYou can still release this package, but the reason is recorded on it and in the audit log.\n\n'+ 'Why is it being released now? (Cancel to stop.)'); if(reason===null || !reason.trim()) return false; @@ -1011,18 +1032,79 @@ function confirmEarlyRelease(blocking){ by: (window.WP_USER && (WP_USER.full_name||WP_USER.username)) || '', blocking: blocking.map(p=>p.number||p.id) }; + // The server only honours a constraint override that NAMES what it covers, so + // a constraint opened after the override cannot ride through on an old reason. + if(open.length) pkgGateOverride.constraints=open; track('predecessor_gate_overridden'); return true; } +// Which status an on-hold package returns to. The hold entry records it at the +// moment the hold is placed (`from`); the newest hold entry wins. Falls back to +// 'Issued' for a package held before this field existed - it must have been +// released to be flagged, and 'Issued' is the most conservative released state. +function holdReturnStatus(){ + for(let i=pkgHolds.length-1;i>=0;i--){ + const h=pkgHolds[i]; + if(!h || h.released) continue; + if(h.from && STATUS_ORDER.indexOf(h.from)>=0) return h.from; + } + if(prevStatus && STATUS_ORDER.indexOf(prevStatus)>=0) return prevStatus; + return 'Issued'; +} + +// CR-015: leaving hold. Writes the release to the same history the hold went to - +// timestamp, user, what cleared it - and returns to the recorded prior status. +// Predecessors stay a refusable gate on the way back out (A1): if one reopened +// while the package sat on hold, the audited override is offered, never skipped. +function releaseHold(clearedName){ + const back=holdReturnStatus(); + const r=readiness(); + if(STATUS_ORDER.indexOf(back)>=ISSUED_IDX && r.blocking.length && !pkgGateOverride){ + if(!confirmEarlyRelease(r.blocking)){ + toast('Constraint cleared — still on hold: predecessor package(s) are not Closed.'); + updateReleaseBanner(); return; + } + } + pkgHolds.push({ ts:new Date().toISOString(), released:true, + constraint:clearedName||'', + details:'Hold released — last open constraint cleared'+(clearedName?': '+clearedName:''), + by:(window.WP_USER && (WP_USER.full_name||WP_USER.username)) || '', + to:back }); + setRadio('status', back); prevStatus=back; + updateReleaseBanner(); toast('Hold released — back to '+back+'.'); + track('hold_released',{to:back}); + const sg=document.getElementById('status-group'); + if(sg) sg.scrollIntoView({behavior:'smooth', block:'center'}); +} + +// D4: the banner's primary action for an Urgent package blocked by constraints. +// It runs the SAME audited path the status control runs - one override, one log. +function urgentOverrideRelease(){ + const r=readiness(); + const open=pkgConstraints.filter(c=>c.status==='open').map(c=>c.name); + if(!open.length && !r.blocking.length){ updateReleaseBanner(); return; } + if(!confirmEarlyRelease(r.blocking, open)) return; + setRadio('status','Issued'); prevStatus='Issued'; + updateReleaseBanner(); track('status_change',{status:'Issued', via:'urgent_override'}); +} + function onStatusChange(target){ const idx=STATUS_ORDER.indexOf(target); const r=readiness(); - // Constraints are a hard gate: nothing releases with one open. + // Constraints are a hard gate for Normal and High: nothing releases with one + // open. For an URGENT package the gate is refusable through the audited + // override (D4) - the same confirmEarlyRelease() path, never a silent bypass. if(idx>=ISSUED_IDX && r.open>0){ const open=pkgConstraints.filter(c=>c.status==='open').map(c=>c.name); - alert('Cannot move to "'+target+'" — these constraints are still open:\n\n• '+open.join('\n• ')+'\n\nClear or mark N/A first.'); - setRadio('status', prevStatus||'Scheduled'); updateReleaseBanner(); return; + if(wpPriorityOf({priority: gv('wp_priority')})==='Urgent'){ + if(!confirmEarlyRelease(r.blocking, open)){ + setRadio('status', prevStatus||'Scheduled'); updateReleaseBanner(); return; + } + } else { + alert('Cannot move to "'+target+'" — these constraints are still open:\n\n• '+open.join('\n• ')+'\n\nClear or mark N/A first.'); + setRadio('status', prevStatus||'Scheduled'); updateReleaseBanner(); return; + } } // Predecessors are a gate you can refuse: planners genuinely need to release // ahead of upstream work closing out. Refusing it requires a reason, which is @@ -1057,7 +1139,11 @@ function submitHold(){ const details=document.getElementById('hold-details').value.trim(); const doclink=document.getElementById('hold-doclink').value.trim(); if(!details){ alert('A comment defining the issue is required.'); return; } - pkgHolds.push({ ts:new Date().toISOString(), constraint, details, doc:doclink, photo:holdPhotoData||'' }); + pkgHolds.push({ ts:new Date().toISOString(), constraint, details, doc:doclink, photo:holdPhotoData||'', + by:(window.WP_USER && (WP_USER.full_name||WP_USER.username)) || '', + // What the release returns to. prevStatus is captured before the status pill + // switches; guard against re-holding while already on hold. + from:(prevStatus && prevStatus!=='Issue') ? prevStatus : holdReturnStatus() }); const c=pkgConstraints.find(x=>x.name===constraint); if(c){ c.status='open'; c.comment=details; } buildConstraints(); setRadio('status','Issue'); prevStatus='Issue'; holdContext=null; document.getElementById('hold-modal').classList.remove('open'); @@ -1319,9 +1405,9 @@ function renderPackage(pkg){ `); if(pkg.holds&&pkg.holds.length){ - let t=``; + let t=`
LoggedConstraintDetailsSupport
`; pkg.holds.forEach(hd=>{ const when=hd.ts?wpFormatDateTime(hd.ts):''; const sup=[hd.doc?linkify(hd.doc):'', hd.photo?'photo attached':''].filter(Boolean).join('
')||ns(); - t+=``; }); + t+=``; }); // The hold LOG belongs to QA/QC — it is the record of the hold points that // section defines, so it goes with it rather than surviving on its own. add('qaqc', 'Hold Log', t+`
LoggedByConstraintDetailsSupport
${esc(when)}${cell(hd.constraint)}${cell(hd.details)}${sup}
${esc(when)}${cell(hd.by)}${cell(hd.constraint)}${cell(hd.details)}${sup}
`); diff --git a/html/wp-creation-styles.css b/html/wp-creation-styles.css index ce0f4d4..7ab05aa 100644 --- a/html/wp-creation-styles.css +++ b/html/wp-creation-styles.css @@ -564,10 +564,16 @@ .sop-hint { color:var(--accent) !important; } .release-banner { max-width:none; margin:0; padding:0 28px 0 calc(var(--nav-w,288px) + 28px); } .release-banner .rb-inner { margin-top:14px; border-radius:var(--radius); padding:11px 16px; font-size:13px; font-weight:600; - display:flex; align-items:center; gap:10px; } + display:flex; align-items:center; gap:10px; flex-wrap:wrap; } .rb-ready { background:var(--accent-green-dim); color:var(--accent-green); border:1px solid var(--wp-status-success-border-b); } .rb-notready { background:var(--accent-amber-dim); color:var(--accent-amber); border:1px solid var(--wp-status-warning-border-b); } .rb-hold { background:var(--red-dim); color:var(--red); border:1px solid var(--wp-status-error-border-b); } + /* D4: the audited-override action on the release banner. A real button in the + primary position for an Urgent package; it simply never renders otherwise. */ + .rb-act { margin-left:auto; border:none; border-radius:var(--radius); cursor:pointer; + background:var(--primary); color:var(--cds-text-on-color); font-family:var(--sans); + font-size:12px; font-weight:600; padding:7px 14px; min-height:32px; } + .rb-act:hover { background:var(--cds-hover-primary); } .pill-hold.selected { background:var(--red) !important; border-color:var(--red) !important; } .pill-hold.selected .dot { background:var(--cds-text-on-color) !important; } diff --git a/server/app.py b/server/app.py index 1368c30..5e6aa4d 100644 --- a/server/app.py +++ b/server/app.py @@ -1405,14 +1405,23 @@ def delete_sop(sop_id: str, user: models.User = Depends(auth.get_current_user), # ── Release gates (constraints + predecessors) ───────────────────────────────── -# Status ladder, mirrored in the front end (wp-creation-app.js STATUS_ORDER). +# Status ladder. The front end's STATUS_ORDER (wp-creation-app.js) has no "Issue" +# in it at all - the hold is a BRANCH off the released states, not a rung. It sits +# in this list so unknown statuses can still be told apart from known ones, but +# _released() must never count it: counting it is what let every transition out of +# hold skip the release gates as "already released", which made /status a side +# door past an open constraint (CR-015 / T7.3). STATUS_ORDER = ["Draft", "Scheduled", "Issued", "In Progress", "Issue", "QC", "Closed"] ISSUED_IDX = STATUS_ORDER.index("Issued") DONE_STATUS = "Closed" +HOLD_STATUS = "Issue" def _released(status: Optional[str]) -> bool: - """Has this package been released to the field (Issued or anything after)?""" + """Is this status a field state (Issued or beyond)? The hold is NOT one: a + package leaves hold through the gates, and enters it without them.""" + if status == HOLD_STATUS: + return False try: return STATUS_ORDER.index(status or "") >= ISSUED_IDX except ValueError: @@ -1438,6 +1447,23 @@ def gate_override(data: Optional[dict]) -> Optional[dict]: return None +def _urgent_constraint_override(data: Optional[dict], open_names: list) -> bool: + """D4: only an URGENT package may release past an open constraint, and only + through the audited override - the same gateOverride record the predecessor + gate uses, never a separate path. The override must NAME every constraint it + covers: a constraint opened after the reason was written cannot ride through + on it. Normal and High are unchanged - a hard refusal.""" + if str((data or {}).get("priority") or "") != "Urgent": + return False + ov = gate_override(data) + if not ov: + return False + covered = ov.get("constraints") + if not isinstance(covered, list): + return False + return {str(n) for n in open_names} <= {str(c) for c in covered} + + def blocking_predecessors(db: Session, wp_id: Optional[str], data: Optional[dict]) -> list[dict]: """Predecessors that are not Closed yet. A predecessor that no longer exists is NOT blocking — a deleted package must not freeze everything downstream.""" @@ -1486,7 +1512,7 @@ def enforce_release_gates(db: Session, wp_id: Optional[str], data: Optional[dict return # not a release transition constraints = (data or {}).get("constraints") or [] open_names = [c.get("name") for c in constraints if isinstance(c, dict) and c.get("status") == "open"] - if open_names: + if open_names and not _urgent_constraint_override(data, open_names): raise HTTPException(status_code=409, detail={ "message": "Open constraints block release", "open": open_names, }) @@ -1633,13 +1659,35 @@ def upsert_wp(body: WpIn, background_tasks: BackgroundTasks, user: models.User = ov = gate_override(body.data) if ov and _released(wp.status) and not _released(old_status): blockers = blocking_predecessors(db, wp.id, body.data) + _ov_constraints = [str(c) for c in ov.get("constraints") or [] if c] log_event(db, user, "gate_overridden", "wp", wp.id, project_id=wp.project_id, summary=(wp.number or wp.subject or wp.id), detail={"reason": str(ov.get("reason"))[:300], - "blocking": [b["number"] or b["id"] for b in blockers]}) + "blocking": [b["number"] or b["id"] for b in blockers], + "constraints": _ov_constraints}) + # CR-015: holds and releases are history, not just state. Entering or leaving + # the hold branch gets its own audit line with the actor and the reason the + # browser recorded in data.holds - `status_changed` alone says from/to but + # not why, and the WHY is what a notice of delay is built from. + if not is_new and old_status != wp.status and "Issue" in (old_status, wp.status): + _hlds = [h for h in ((wp.data or {}).get("holds") or []) if isinstance(h, dict)] + if wp.status == "Issue": + _h = next((h for h in reversed(_hlds) if not h.get("released")), None) + log_event(db, user, "hold_logged", "wp", wp.id, project_id=wp.project_id, + summary=(wp.number or wp.subject or wp.id), + detail={"from": old_status, + "constraint": str((_h or {}).get("constraint") or "")[:200], + "reason": str((_h or {}).get("details") or "")[:300]}) + else: + _h = next((h for h in reversed(_hlds) if h.get("released")), None) + log_event(db, user, "hold_released", "wp", wp.id, project_id=wp.project_id, + summary=(wp.number or wp.subject or wp.id), + detail={"to": wp.status, + "constraint": str((_h or {}).get("constraint") or "")[:200], + "reason": str((_h or {}).get("details") or "")[:300]}) # A critical constraint reopened after release: log it and tell the people who # need to know (owner, PM, CM, distribution). - if not is_new and _released(old_status): + if not is_new and (_released(old_status) or old_status == HOLD_STATUS): reopened = reopened_critical(old_data, wp.data) if reopened: log_event(db, user, "constraint_reopened", "wp", wp.id, project_id=wp.project_id, @@ -2386,6 +2434,22 @@ def set_wp_status(wp_id: str, body: StatusIn, user: models.User = Depends(auth.g if old_status != body.status: log_event(db, user, "status_changed", "wp", wp.id, project_id=wp.project_id, summary=(wp.number or wp.subject or wp.id), detail={"from": old_status, "to": body.status}) + if "Issue" in (old_status, body.status): + _hlds = [h for h in ((wp.data or {}).get("holds") or []) if isinstance(h, dict)] + if body.status == "Issue": + _h = next((h for h in reversed(_hlds) if not h.get("released")), None) + log_event(db, user, "hold_logged", "wp", wp.id, project_id=wp.project_id, + summary=(wp.number or wp.subject or wp.id), + detail={"from": old_status, + "constraint": str((_h or {}).get("constraint") or "")[:200], + "reason": str((_h or {}).get("details") or "")[:300]}) + else: + _h = next((h for h in reversed(_hlds) if h.get("released")), None) + log_event(db, user, "hold_released", "wp", wp.id, project_id=wp.project_id, + summary=(wp.number or wp.subject or wp.id), + detail={"to": body.status, + "constraint": str((_h or {}).get("constraint") or "")[:200], + "reason": str((_h or {}).get("details") or "")[:300]}) db.commit() db.refresh(wp) return wp.to_dict() diff --git a/tests/hold_check.py b/tests/hold_check.py new file mode 100644 index 0000000..4ef7422 --- /dev/null +++ b/tests/hold_check.py @@ -0,0 +1,480 @@ +#!/usr/bin/env python3 +"""Does clearing the last constraint clear the hold? — CR-015 / A1 / D4, T7.3. + +`CR-015` is the highest-severity item in the plan and it was reproduced live in +front of the Micron team: a package on hold with every constraint cleared, stuck. +The root cause was hold state STORED rather than derived — `submitHold()` wrote +`prevStatus='Issue'`, destroying the status the hold interrupted, and the clear +path fell into the "Mark it as Issued now?" offer because `'Issue'` is not in +`STATUS_ORDER` (`indexOf` gives -1, which reads as "before Issued"). Declining +that offer left the package on hold with nothing open, forever. + + 1. the clear-last-constraint path, specifically (the regression this file exists for) + 2. D4: an Urgent package gets the audited override as the primary action; Normal and High are unchanged + 3. Issue (Hold) branches from every released state + 4. the server refuses what the browser refuses, and writes the history + +The dialog stubs record every native dialog: the release path must fire NONE. +The prompt stub is how the audited override is driven — its return value is the +reason, and None is the user backing out. + +Self-contained: throwaway SQLite, its own uvicorn, headless Edge or Chrome. +Exit 0 all passed, 1 a failure, 2 could not run. +""" +import ast +import json +import os +import re +import sys +import tempfile +import time +import urllib.error +import urllib.request + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import cdp # noqa: E402 +from browser_check import seed, start_server, chk, _PASS, _FAIL, _c # noqa: E402 +from sections_check import set_sop # noqa: E402 +from stepper_check import dismiss_dialogs # noqa: E402 + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def ascii_(v, n=300): + return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n] + + +def settle(seconds=0.8): + time.sleep(seconds) + + +def wait_creator(page, tries=40): + for _ in range(tries): + if page.eval("!!window.wpCreatorReady"): + return True + time.sleep(0.3) + return False + + +def api(base, path, token, method="GET", body=None): + """Returns (http_status, parsed_body). 4xx is a result here, not an error — + half of what this file checks is that the server says no.""" + req = urllib.request.Request(base + path, method=method) + req.add_header("Cookie", "wp_session=" + token) + req.add_header("Accept", "application/json") + data = None + if body is not None: + data = json.dumps(body).encode() + req.add_header("Content-Type", "application/json") + try: + with urllib.request.urlopen(req, data, timeout=15) as r: + return r.status, json.loads(r.read().decode() or "null") + except urllib.error.HTTPError as e: + try: + return e.code, json.loads(e.read().decode() or "null") + except Exception: + return e.code, None + + +def audit(base, tok, wp_id, action): + _, rows = api(base, "/api/audit?entity_type=wp&entity_id=%s&action=%s" % (wp_id, action), tok) + return rows or [] + + +# In-page dialog stubs. Installed once per load; __dlg records what fired so a +# path that must be dialog-free can prove it, and __pReturn is the prompt answer. +STUBS_JS = """(() => { + window.__dlg = []; + window.__pReturn = 'Boom lift arrives Friday - releasing to hold schedule'; + window.prompt = m => { window.__dlg.push(['prompt', String(m)]); return window.__pReturn; }; + window.alert = m => { window.__dlg.push(['alert', String(m)]); }; + window.confirm= m => { window.__dlg.push(['confirm',String(m)]); return false; }; + return true; +})()""" + + +def dlg(page): + return json.loads(page.eval("JSON.stringify(window.__dlg||[])")) + + +def reset_dlg(page): + page.eval("window.__dlg=[]") + + +def constraint_btn(page, index, which): + """Click Open/Cleared/N-A on constraint row `index` — the same buttons a + user clicks, re-queried each time because buildConstraints() rebuilds them.""" + labels = {"open": "Open", "cleared": "Cleared", "na": "N/A"} + page.eval("""(() => { + const tr = document.querySelectorAll('#constraint-body tr')[%d]; + [...tr.querySelectorAll('.cstatus button')] + .find(b => b.textContent.trim() === %s).click(); + })()""" % (index, json.dumps(labels[which]))) + settle(0.4) + + +def click_status(page, val): + page.eval("document.querySelector('#status-group .radio-pill[data-val=%s]').click()" + % json.dumps(val)) + settle(0.4) + + +def status_of(page): + return page.eval("getRadio('status')") + + +def strip_js_comments(src): + """Good enough for grepping: block comments, then line comments that are not + inside a string (approximated by requiring the // not be preceded by : which + covers the https:// case that bit BL-017).""" + src = re.sub(r"/\*.*?\*/", "", src, flags=re.S) + return "\n".join(re.sub(r"(?h.released).length") == 2 + and page.eval("pkgHolds.filter(h=>!h.released).length") == 2, + ascii_(page.eval("JSON.stringify(pkgHolds.map(h=>!!h.released))"))) + + # The return status must survive persistence, not live in a JS variable: + # collect the package, put it back mid-hold, and clear. + constraint_btn(page, 2, "open") + page.eval("document.getElementById('hold-details').value='Third issue'") + page.eval("submitHold()") + settle(0.4) + page.eval("""(() => { + const p = collectPackage(); + loadPackageIntoForm(JSON.parse(JSON.stringify(p))); + })()""") + settle(0.8) + chk("a saved-and-reloaded package is still on hold", status_of(page) == "Issue", + status_of(page)) + constraint_btn(page, 2, "cleared") + chk("...and clearing its constraint returns it to the status recorded on the hold", + status_of(page) == "In Progress", status_of(page)) + + # ── 2. D4: the Urgent override ──────────────────────────────────────── + print("\n2. D4: Urgent surfaces the audited path; Normal and High do not") + page.eval("newPackage()") + settle(0.8) + page.eval(STUBS_JS) + chk("a new package starts with no override recorded", + page.eval("pkgGateOverride === null || pkgGateOverride === undefined") is True + or page.eval("!pkgGateOverride") is True) + + chk("a Normal package with open constraints shows NO override button", + page.eval("!document.querySelector('.rb-act')")) + reset_dlg(page) + click_status(page, "Issued") + alerts = [d for d in dlg(page) if d[0] == "alert"] + chk("...and the status control still hard-blocks it with the same refusal", + len(alerts) == 1 and "still open" in alerts[0][1], ascii_(dlg(page))) + chk("...and the status snapped back", status_of(page) == "Draft", status_of(page)) + + page.eval("document.getElementById('wp_priority').value='High'") + constraint_btn(page, 0, "open") # any change redraws the banner + chk("High is treated exactly like Normal — no override button", + page.eval("!document.querySelector('.rb-act')")) + + page.eval("document.getElementById('wp_priority').value='Urgent'") + constraint_btn(page, 0, "open") + chk("an Urgent package with open constraints offers the override as the " + "banner's primary action, a real button", + page.eval("(document.querySelector('.rb-act')||{}).tagName") == "BUTTON") + + reset_dlg(page) + page.eval("window.__pReturn = null") # back out first + page.eval("document.querySelector('.rb-act').click()") + settle(0.4) + chk("backing out of the prompt releases nothing", status_of(page) == "Draft", + status_of(page)) + chk("...and records no override", page.eval("!pkgGateOverride")) + + reset_dlg(page) + page.eval("window.__pReturn = 'Client directive 42 - install proceeds at risk'") + page.eval("document.querySelector('.rb-act').click()") + settle(0.4) + prompts = [d for d in dlg(page) if d[0] == "prompt"] + open_names = json.loads(page.eval( + "JSON.stringify(pkgConstraints.filter(c=>c.status==='open').map(c=>c.name))")) + chk("the prompt names every open constraint it is about to cross", + len(prompts) == 1 and all(nm in prompts[0][1] for nm in open_names), + ascii_(prompts)) + chk("taking the override releases the package", status_of(page) == "Issued", + status_of(page)) + ov = json.loads(page.eval("JSON.stringify(pkgGateOverride||{})")) + chk("the override records actor, timestamp, reason and the constraints it crossed", + bool(ov.get("by")) and bool(ov.get("at")) + and ov.get("reason", "").startswith("Client directive") + and sorted(ov.get("constraints") or []) == sorted(open_names), ascii_(ov)) + + src = strip_js_comments( + open(os.path.join(ROOT, "html", "wp-creation-app.js"), encoding="utf-8").read()) + chk("the client writes a gate override in exactly one place — confirmEarlyRelease()", + src.count("pkgGateOverride={") == 1, src.count("pkgGateOverride={")) + chk("the hold history has exactly two writers — the hold and the release", + src.count("pkgHolds.push(") == 2, src.count("pkgHolds.push(")) + + # ── 3. the hold branches from every released state ──────────────────── + print("\n3. Issue (Hold) is a branch, not a step") + page.eval("newPackage()") + settle(0.8) + page.eval(STUBS_JS) + n = page.eval("pkgConstraints.length") + for i in range(n): + constraint_btn(page, i, "na") + reset_dlg(page) + for st in ("Issued", "In Progress", "QC"): + click_status(page, st) + click_status(page, "Issue") + opened = page.eval("document.getElementById('hold-modal').classList.contains('open')") + page.eval("cancelHold()") + settle(0.3) + chk("from %s: Issue (hold) opens the log modal, and cancel restores %s" % (st, st), + opened and status_of(page) == st, status_of(page)) + + page.close() + browser.close() + browser = None + + # ── 4. the server refuses what the browser refuses, and writes history ─ + print("\n4. the server: gates and history") + CON = lambda st: [{"name": "Boom lift", "status": st, "comment": ""}, + {"name": "Permits", "status": "cleared", "comment": ""}] + + code, _ = api(base, "/api/wps", root, "POST", { + "id": "wpN1", "project_id": "projA", "number": "N-1", "subject": "normal", + "status": "Issued", "data": {"priority": "Normal", "constraints": CON("open")}}) + chk("Normal + open constraint: refused", code == 409, code) + + code, _ = api(base, "/api/wps", root, "POST", { + "id": "wpU1", "project_id": "projA", "number": "U-1", "subject": "urgent bare", + "status": "Issued", "data": {"priority": "Urgent", "constraints": CON("open")}}) + chk("Urgent with NO override: refused — urgency alone is not a reason", code == 409, code) + + ov = {"reason": "Client directive 42", "at": "2026-08-19T00:00:00Z", "by": "Root"} + code, _ = api(base, "/api/wps", root, "POST", { + "id": "wpU2", "project_id": "projA", "number": "U-2", "subject": "urgent unnamed", + "status": "Issued", + "data": {"priority": "Urgent", "constraints": CON("open"), "gateOverride": ov}}) + chk("Urgent + override that names nothing: refused — it must say what it covers", + code == 409, code) + + code, _ = api(base, "/api/wps", root, "POST", { + "id": "wpU3", "project_id": "projA", "number": "U-3", "subject": "urgent wrong name", + "status": "Issued", + "data": {"priority": "Urgent", "constraints": CON("open"), + "gateOverride": dict(ov, constraints=["Permits"])}}) + chk("Urgent + override naming a DIFFERENT constraint: refused — no riding through " + "on an old reason", code == 409, code) + + code, _ = api(base, "/api/wps", root, "POST", { + "id": "wpN2", "project_id": "projA", "number": "N-2", "subject": "normal named", + "status": "Issued", + "data": {"priority": "Normal", "constraints": CON("open"), + "gateOverride": dict(ov, constraints=["Boom lift"])}}) + chk("Normal + a fully-named override: still refused — the path is Urgent-only", + code == 409, code) + + code, _ = api(base, "/api/wps", root, "POST", { + "id": "wpU4", "project_id": "projA", "number": "U-4", "subject": "urgent covered", + "status": "Issued", + "data": {"priority": "Urgent", "constraints": CON("open"), + "gateOverride": dict(ov, constraints=["Boom lift"])}}) + chk("Urgent + an override naming the open constraint: released", code == 200, code) + rows = audit(base, root, "wpU4", "gate_overridden") + chk("...and the audit log names the constraints it crossed", + rows and "Boom lift" in ((rows[0].get("detail") or {}).get("constraints") or []), + ascii_(rows[:1])) + + # A1: the predecessor gate is untouched — refusable only with a reason. + api(base, "/api/wps", root, "POST", { + "id": "wpPred", "project_id": "projA", "number": "P-0", "subject": "upstream", + "status": "In Progress", "data": {"constraints": CON("cleared")}}) + code, _ = api(base, "/api/wps", root, "POST", { + "id": "wpA1c", "project_id": "projA", "number": "A1-1", "subject": "downstream", + "status": "Issued", + "data": {"constraints": CON("cleared"), "predecessors": ["wpPred"]}}) + chk("A1: an unclosed predecessor still refuses a release", code == 409, code) + code, _ = api(base, "/api/wps", root, "POST", { + "id": "wpA1c", "project_id": "projA", "number": "A1-1", "subject": "downstream", + "status": "Issued", + "data": {"constraints": CON("cleared"), "predecessors": ["wpPred"], + "gateOverride": {"reason": "Staged ahead of turnover", "by": "Root", + "at": "2026-08-19T00:00:00Z"}}}) + chk("A1: the reasoned override still releases", code == 200, code) + chk("A1: and still logs", bool(audit(base, root, "wpA1c", "gate_overridden"))) + + # The hold history, through the upsert (how the browser and outbox save). + api(base, "/api/wps", root, "POST", { + "id": "wpH1", "project_id": "projA", "number": "H-1", "subject": "hold history", + "status": "In Progress", "data": {"constraints": CON("cleared")}}) + code, _ = api(base, "/api/wps", root, "POST", { + "id": "wpH1", "project_id": "projA", "number": "H-1", "subject": "hold history", + "status": "Issue", + "data": {"constraints": CON("open"), + "holds": [{"ts": "2026-08-19T01:00:00Z", "constraint": "Boom lift", + "details": "Lift recalled for inspection", "by": "Root", + "from": "In Progress"}]}}) + rows = audit(base, root, "wpH1", "hold_logged") + chk("going on hold writes hold_logged with the reason and the interrupted status", + code == 200 and rows + and (rows[0].get("detail") or {}).get("reason") == "Lift recalled for inspection" + and (rows[0].get("detail") or {}).get("from") == "In Progress", + ascii_((code, rows[:1]))) + code, _ = api(base, "/api/wps", root, "POST", { + "id": "wpH1", "project_id": "projA", "number": "H-1", "subject": "hold history", + "status": "In Progress", + "data": {"constraints": CON("cleared"), + "holds": [{"ts": "2026-08-19T01:00:00Z", "constraint": "Boom lift", + "details": "Lift recalled for inspection", "by": "Root", + "from": "In Progress"}, + {"ts": "2026-08-19T02:00:00Z", "released": True, + "constraint": "Boom lift", "by": "Root", + "details": "Hold released - last open constraint cleared", + "to": "In Progress"}]}}) + rows = audit(base, root, "wpH1", "hold_released") + chk("coming off hold writes hold_released with where it went back to", + code == 200 and rows + and (rows[0].get("detail") or {}).get("to") == "In Progress", + ascii_((code, rows[:1]))) + + # The /status endpoint is not a side door: same gates, same history. + api(base, "/api/wps", root, "POST", { + "id": "wpS1", "project_id": "projA", "number": "S-1", "subject": "endpoint", + "status": "In Progress", "data": {"constraints": CON("cleared")}}) + code, _ = api(base, "/api/wps/wpS1/status", root, "POST", {"status": "Issue"}) + chk("/status can place a package on hold", code == 200, code) + chk("...and writes hold_logged", bool(audit(base, root, "wpS1", "hold_logged"))) + api(base, "/api/wps", root, "POST", { + "id": "wpS1", "project_id": "projA", "number": "S-1", "subject": "endpoint", + "status": "Issue", "data": {"constraints": CON("open")}}) + code, _ = api(base, "/api/wps/wpS1/status", root, "POST", {"status": "Issued"}) + chk("/status cannot walk a held package past its open constraint", code == 409, code) + api(base, "/api/wps", root, "POST", { + "id": "wpS1", "project_id": "projA", "number": "S-1", "subject": "endpoint", + "status": "Issue", "data": {"constraints": CON("cleared")}}) + code, _ = api(base, "/api/wps/wpS1/status", root, "POST", {"status": "In Progress"}) + chk("/status releases once the record is clear", code == 200, code) + chk("...and writes hold_released", bool(audit(base, root, "wpS1", "hold_released"))) + + # Grep half of D4's "no unwritten release": every status assignment in + # app.py sits in a function that runs the gates. (Constructor-built rows + # in seed scripts are out of scope — S13 owns seeding.) + tree = ast.parse(open(os.path.join(ROOT, "server", "app.py"), encoding="utf-8").read()) + bad = [] + for fn in [n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)]: + assigns = [n for n in ast.walk(fn) if isinstance(n, ast.Assign) + and any(isinstance(t, ast.Attribute) and t.attr == "status" + for t in n.targets)] + if not assigns: + continue + calls = {c.func.id for c in ast.walk(fn) + if isinstance(c, ast.Call) and isinstance(c.func, ast.Name)} + if "enforce_release_gates" not in calls: + bad.append(fn.name) + chk("every status assignment in server/app.py is inside a function that runs " + "the release gates", not bad, ascii_(bad)) + + finally: + if browser is not None: + try: + browser.close() + except Exception: + pass + if server is not None: + try: + server.terminate() + except Exception: + pass + + print("\n" + "-" * 54) + print("%d/%d checks passed." % (len(_PASS), len(_PASS) + len(_FAIL))) + for f in _FAIL: + print(" - " + f) + return 1 if _FAIL else 0 + + +if __name__ == "__main__": + sys.exit(main())