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

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