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 <button>); Normal and High
see nothing new and keep the same hard refusal, asserted per priority.
Banner button styled from tokens only; the banner now wraps at narrow widths.

Product question raised, not decided (per CLAUDE.md "asking versus assuming"):
Issue (hold) remains selectable from Draft and Scheduled, as it was before.
The done-when names no state list, so nothing was restricted. If a pre-release
hold is meaningless, closing it off is a one-line follow-up - needs Nick.

Verification (each probe run alone): NEW tests/hold_check.py 50/50, including
the clear-last-constraint regression specifically, the D4 priority matrix
against the server (six 409/200 cases), hold_logged/hold_released audit rows,
and an AST sweep proving every wp.status assignment in server/app.py sits in
a function that runs enforce_release_gates. Regressions: frame_check 39/39,
aggregates_check 16/16.

Items: CR-015, A1, D4 (X2 correction already recorded Aug 18)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 10:05:57 -07:00
parent 755c976841
commit 2b597e68d8
5 changed files with 652 additions and 15 deletions

View File

@@ -277,6 +277,7 @@ Wave 7 adds these:
```bash
python tests/frame_check.py # B7/T7.1/D1 - is the iframe actually gone? 39 checks
python tests/form_structure_check.py # F6/D3 - rail, disclosure, one open section 51 checks
python tests/hold_check.py # CR-015/A1/D4 - the hold clears, gates hold 50 checks
```
**Three probes were re-pointed at `T7.1`.** `sections_check.py` 5b drove the live

View File

@@ -949,6 +949,13 @@ function setConstraint(i,val){
// issue it and scroll up to the status control so the change is visible.
if(before==='open' && val!=='open' && readiness().open===0){
const st=getRadio('status');
// CR-015: hold state is recalculated on EVERY constraint change, never read
// once and left to go stale. Clearing the last open constraint releases the
// hold right here - no refresh, no dialog - and returns the package to the
// status it held when the hold was placed. The old code fell through to the
// "Mark it as Issued now?" offer because STATUS_ORDER.indexOf('Issue') is -1,
// and declining it left the package on hold with nothing open.
if(st==='Issue'){ releaseHold(pkgConstraints[i].name); return; }
// Don't offer to issue while a predecessor is still open — that would walk the
// user straight into the override prompt they didn't ask for.
if(readiness().blocking.length){
@@ -992,6 +999,12 @@ function updateReleaseBanner(){
cls='rb-notready';
const extra = r.blocking.length ? ` · also waiting on ${r.blocking.length} predecessor${r.blocking.length===1?'':'s'}` : '';
txt=`⚠ Not release-ready — ${r.open} of ${r.total} constraint${r.open===1?'':'s'} still open${extra}.`;
// D4: an Urgent package gets the audited override as the banner's primary
// action - not a hidden menu item, and not on Normal or High, whose gate is
// exactly as prominent as it was before this change.
if(STATUS_ORDER.indexOf(st)<ISSUED_IDX && wpPriorityOf({priority: gv('wp_priority')})==='Urgent'){
txt+=` <button type="button" class="rb-act" onclick="urgentOverrideRelease()">Release now — audited override</button>`;
}
}
b.innerHTML=`<div class="rb-inner ${cls}">${txt}</div>`;
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){
</tbody></table>`);
if(pkg.holds&&pkg.holds.length){
let t=`<table><thead><tr><th style="width:150px">Logged</th><th style="width:200px">Constraint</th><th>Details</th><th style="width:120px">Support</th></tr></thead><tbody>`;
let t=`<table><thead><tr><th style="width:150px">Logged</th><th style="width:130px">By</th><th style="width:180px">Constraint</th><th>Details</th><th style="width:120px">Support</th></tr></thead><tbody>`;
pkg.holds.forEach(hd=>{ const when=hd.ts?wpFormatDateTime(hd.ts):''; const sup=[hd.doc?linkify(hd.doc):'', hd.photo?'<span style="color:var(--accent-green)">photo attached</span>':''].filter(Boolean).join('<br>')||ns();
t+=`<tr><td>${esc(when)}</td><td>${cell(hd.constraint)}</td><td>${cell(hd.details)}</td><td>${sup}</td></tr>`; });
t+=`<tr><td>${esc(when)}</td><td>${cell(hd.by)}</td><td>${cell(hd.constraint)}</td><td>${cell(hd.details)}</td><td>${sup}</td></tr>`; });
// 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+`</tbody></table>`);

View File

@@ -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; }

View File

@@ -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()

480
tests/hold_check.py Normal file
View File

@@ -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"(?<![:'\"])//.*$", "", ln) for ln in src.split("\n"))
def main():
exe = cdp.find_browser()
if not exe:
print("no headless-capable browser found; set WP_BROWSER.")
return 2
tmpdir = tempfile.mkdtemp(prefix="wpsuite-hold-")
db_path = os.path.join(tmpdir, "check.db")
server = None
browser = None
try:
tok = seed(db_path)
set_sop(db_path, {})
port = cdp.free_port()
base = "http://127.0.0.1:%d" % port
server = start_server(port, db_path)
root = tok["root"]
# ── 1. the clear-last-constraint path, driven in the browser ─────────
print("\n1. CR-015: the stale hold")
browser = cdp.Browser(exe)
page = browser.page()
page.clear_cookies()
page.set_cookie("wp_session", root)
page.viewport(1440, 900)
page.goto(base + "/wp-creation-index.html?project=projA")
dismiss_dialogs(page)
chk("the creator boots", wait_creator(page))
settle(1.5)
page.eval(STUBS_JS)
page.eval("window.__sentinel = 1")
n = page.eval("pkgConstraints.length")
for i in range(n):
constraint_btn(page, i, "cleared")
offers = [d for d in dlg(page) if d[0] == "confirm" and "release-ready" in d[1]]
chk("the release-ready offer on an unreleased package is unchanged",
len(offers) == 1, ascii_(dlg(page)))
reset_dlg(page)
click_status(page, "In Progress")
chk("with everything cleared the package moves to In Progress",
status_of(page) == "In Progress", status_of(page))
constraint_btn(page, 0, "open")
chk("flagging a constraint open on an in-progress package opens the hold modal",
page.eval("document.getElementById('hold-modal').classList.contains('open')"))
page.eval("document.getElementById('hold-details').value='Boom lift recalled for inspection'")
page.eval("submitHold()")
settle(0.4)
chk("submitting the hold puts the package on hold", status_of(page) == "Issue",
status_of(page))
h = json.loads(page.eval("JSON.stringify(pkgHolds[pkgHolds.length-1]||{})"))
chk("the hold entry records timestamp, user, and the status it interrupted",
bool(h.get("ts")) and bool(h.get("by")) and h.get("from") == "In Progress",
ascii_(h))
reset_dlg(page)
constraint_btn(page, 0, "cleared")
chk("clearing the last open constraint clears the hold",
status_of(page) != "Issue", status_of(page))
chk("...and returns the package to its PRIOR status, not to Issued and not to a guess",
status_of(page) == "In Progress", status_of(page))
chk("...with no dialog of any kind", dlg(page) == [], ascii_(dlg(page)))
chk("...and no refresh — the same document is still running",
page.eval("window.__sentinel") == 1)
rel = json.loads(page.eval("JSON.stringify(pkgHolds[pkgHolds.length-1]||{})"))
chk("the release is in the history with timestamp, user, and what cleared it",
rel.get("released") is True and bool(rel.get("ts")) and bool(rel.get("by"))
and rel.get("to") == "In Progress", ascii_(rel))
# Done-when items two and three: the same round trip, again.
constraint_btn(page, 1, "open")
page.eval("document.getElementById('hold-details').value='Second issue'")
page.eval("submitHold()")
settle(0.4)
chk("logging a NEW constraint places it back on hold", status_of(page) == "Issue")
constraint_btn(page, 1, "cleared")
chk("clearing that constraint releases it again", status_of(page) == "In Progress",
status_of(page))
chk("the history now holds two hold/release pairs",
page.eval("pkgHolds.filter(h=>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())