diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md
index 84c25ee..1e66586 100644
--- a/DEPLOYMENT.md
+++ b/DEPLOYMENT.md
@@ -346,6 +346,48 @@ hides the BIM/VDC section and every project is install-only (IWP). A SOP that
already has BIM enabled keeps its data — it just stops being offered — so turning
the flag off never deletes BIM types, gates, or sequence steps.
+## Release gates (constraints + predecessors)
+
+A work package reaches **Issued** only when both gates are met:
+
+1. every constraint is **Cleared** or **N/A** — a hard gate, no override;
+2. every **predecessor work package** (`data.predecessors`, a list of WP ids) is
+ **Closed**.
+
+Enforced by `enforce_release_gates()` on **every** path that can set a status —
+`/api/wps` (the browser and the offline outbox both save through it),
+`/api/wps/{id}/issue`, and `/api/wps/{id}/status`. Also:
+
+- **Overridable, deliberately.** Planners legitimately release ahead of upstream
+ close-out, so the predecessor gate accepts `data.gateOverride = {reason, by, at}`.
+ A blank reason is not an override. The server writes a `gate_overridden` audit
+ event naming the reason and what was skipped, and the reason prints on the
+ package. Changing the predecessor set clears the override.
+- **Cycles are refused** (`check_predecessor_cycle`) — direct and through a chain,
+ with a 400 explaining which package already waits on this one.
+- **A deleted predecessor does not block.** It would otherwise freeze everything
+ downstream of a package someone removed.
+- The Creator's picker hides itself and any package that already waits on it, so a
+ cycle is hard to build in the first place; the dashboard refuses to issue a
+ blocked package and points at the form for the logged override.
+
+`data.seq` (the SOP sequence phase) is still stored and shown, but it is
+descriptive — it gates nothing.
+
+## Critical constraints reopened after release
+
+A constraint marked **Critical** on the SOP that reopens **after** the package was
+released emails the **owner, PM, CM and everyone on the package's distribution
+list** (minus whoever reopened it), and writes a `constraint_reopened` audit event.
+
+Detected by comparing incoming constraints against the stored ones inside the
+normal upsert — *not* a separate endpoint, because the browser saves through the
+sync outbox, which only replays `POST /api/wps`; anything hung off another route
+would be lost offline. It fires only on a real transition (cleared/N-A → open), so
+re-saving an already-open constraint doesn't re-announce, and never for a package
+that was never released or a non-critical constraint. Bodies carry the constraint
+name, WP number and a link — never the package contents.
+
## Localization (dates, times, numbers)
Three levels, most specific first — resolved in `html/wp-format.js`:
diff --git a/html/wp-creation-app.js b/html/wp-creation-app.js
index 9597da8..3a650db 100644
--- a/html/wp-creation-app.js
+++ b/html/wp-creation-app.js
@@ -382,6 +382,114 @@ function resetPeopleForNewPackage(){
renderPeoplePicker('distribution');
}
+// ── PREDECESSOR WORK PACKAGES ─────────────────────────────────────────────────
+// "Package Predecessor" used to be a free-text SOP phase label, which couldn't
+// express "WP04 waits on WP02" and gated nothing. It's now a set of references to
+// other packages on the project, and an unclosed predecessor makes a package not
+// release-ready (server: enforce_release_gates). The SOP phase survives as the
+// descriptive "Sequence phase" field beside it.
+let pkgPredecessors = []; // [wpId]
+let pkgGateOverride = null; // {reason, at, by} once a planner releases early
+
+function wpById(id){ return savedPackages.find(p => p.id === id) || null; }
+
+// Everything this package already blocks, directly or through a chain — offering
+// any of them as a predecessor would create a cycle, so they're excluded.
+function descendantsOf(id){
+ const out = new Set();
+ const stack = [id];
+ while(stack.length){
+ const cur = stack.pop();
+ savedPackages.forEach(p => {
+ if((p.predecessors || []).includes(cur) && !out.has(p.id)){
+ out.add(p.id);
+ stack.push(p.id);
+ }
+ });
+ }
+ return out;
+}
+
+function predCandidates(){
+ const self = editingId || '';
+ const banned = self ? descendantsOf(self) : new Set();
+ return savedPackages.filter(p => p.id !== self && !banned.has(p.id));
+}
+
+// Predecessors that aren't Closed yet. Missing ones (deleted since) don't block —
+// a deleted package must not freeze everything downstream of it.
+function blockingPredecessors(){
+ return pkgPredecessors.map(id => wpById(id)).filter(p => p && p.status !== 'Closed');
+}
+
+function predLabel(p){ return (p.number || '(unnumbered)') + ' — ' + (p.subject || 'untitled'); }
+
+function renderPredPicker(){
+ const box = document.getElementById('pick_predecessors');
+ if(!box) return;
+ const chips = pkgPredecessors.map((id, i) => {
+ const p = wpById(id);
+ const gone = !p;
+ const done = p && p.status === 'Closed';
+ const title = gone ? 'This package no longer exists — it does not block release.'
+ : (done ? 'Closed — cleared' : p.status + ' — blocks release until Closed');
+ const cls = done ? ' pp-locked' : '';
+ const name = gone ? '(deleted package)' : (p.number || p.subject || id);
+ return `${done?'✓ ':''}${esc(name)}` +
+ ``;
+ }).join('');
+ const cands = predCandidates();
+ const opts = cands.map(p => {
+ const on = pkgPredecessors.includes(p.id);
+ return ``;
+ }).join('');
+ box.innerHTML = chips +
+ `
+
+
+ ${cands.length ? `
Work packages on this project
${opts}`
+ : `
No other packages saved yet
`}
+
+ `;
+ renderPredHint();
+}
+
+function renderPredHint(){
+ const el = document.getElementById('pred-hint');
+ if(!el) return;
+ const blocking = blockingPredecessors();
+ if(!pkgPredecessors.length){
+ el.textContent = 'None — this package can be released as soon as its constraints are cleared.';
+ el.style.color = '';
+ } else if(blocking.length){
+ el.innerHTML = '⛔ Waiting on ' + blocking.map(p => esc(p.number || p.id) + ' (' + esc(p.status) + ')').join(', ') +
+ '. Release is gated until they are Closed.';
+ el.style.color = 'var(--red)';
+ } else {
+ el.textContent = '✓ All predecessors are Closed.';
+ el.style.color = 'var(--accent-green)';
+ }
+}
+
+function togglePredecessor(id, on){
+ const ix = pkgPredecessors.indexOf(id);
+ if(on && ix < 0) pkgPredecessors.push(id);
+ else if(!on && ix >= 0) pkgPredecessors.splice(ix, 1);
+ // The set changed, so a previously-granted override no longer describes reality.
+ pkgGateOverride = null;
+ renderPredPicker();
+ updateReleaseBanner();
+}
+
+function removePredecessor(i){
+ pkgPredecessors.splice(i, 1);
+ pkgGateOverride = null;
+ renderPredPicker();
+ updateReleaseBanner();
+}
+
// ── SOP-inherited hints → label tooltips (site comment 8/3) ───────────────────
// The blue "from SOP types" subtext under a field becomes a small SOP chip on the
// label, with the detail on hover. The hint elements stay in the DOM (hidden) so
@@ -809,7 +917,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');
- if(STATUS_ORDER.indexOf(st) < ISSUED_IDX){
+ // 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){
+ renderPredHint();
+ toast('All constraints cleared — still waiting on '+readiness().blocking.length+' predecessor package(s).');
+ }
+ else if(STATUS_ORDER.indexOf(st) < ISSUED_IDX){
if(confirm('All constraints are cleared — this Work Package is release-ready.\n\nMark it as Issued now?')){
setRadio('status','Issued'); prevStatus='Issued'; updateReleaseBanner();
track('status_change',{status:'Issued',via:'constraint_clear'});
@@ -819,23 +933,73 @@ function setConstraint(i,val){
}
}
}
-function readiness(){ const open=pkgConstraints.filter(c=>c.status==='open').length; return {open, total:pkgConstraints.length, cleared:pkgConstraints.filter(c=>c.status==='cleared').length, ready:open===0}; }
+// Release readiness has two gates now: every constraint cleared/N-A, and every
+// predecessor package Closed. `ready` means both; `open`/`blocking` say which.
+function readiness(){
+ const open = pkgConstraints.filter(c=>c.status==='open').length;
+ const blocking = blockingPredecessors();
+ return {
+ open, blocking,
+ total: pkgConstraints.length,
+ cleared: pkgConstraints.filter(c=>c.status==='cleared').length,
+ constraintsClear: open === 0,
+ ready: open === 0 && blocking.length === 0
+ };
+}
function updateReleaseBanner(){
const b=document.getElementById('release-banner'); const r=readiness(); const st=getRadio('status');
let cls, txt;
if(st==='Issue'){ cls='rb-hold'; txt=`⛔ On hold — ${r.open} constraint${r.open===1?'':'s'} reopened. Resolve to resume.`; }
else if(r.ready){ cls='rb-ready'; txt=`✓ Release-ready — all ${r.total} constraints cleared or N/A.`; }
- else { cls='rb-notready'; txt=`⚠ Not release-ready — ${r.open} of ${r.total} constraint${r.open===1?'':'s'} still open.`; }
+ else if(r.constraintsClear){
+ // Constraints are done; what's left is upstream work.
+ cls='rb-notready';
+ txt=`⚠ Not release-ready — waiting on ${r.blocking.map(p=>esc(p.number||p.id)+' ('+esc(p.status)+')').join(', ')}.`;
+ }
+ else {
+ 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}.`;
+ }
b.innerHTML=`
${txt}
`;
updateStickyStatus();
}
+// 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+
+ '\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;
+ pkgGateOverride={
+ reason: reason.trim(),
+ at: new Date().toISOString(),
+ by: (window.WP_USER && (WP_USER.full_name||WP_USER.username)) || '',
+ blocking: blocking.map(p=>p.number||p.id)
+ };
+ track('predecessor_gate_overridden');
+ return true;
+}
+
function onStatusChange(target){
const idx=STATUS_ORDER.indexOf(target);
- if(idx>=ISSUED_IDX && readiness().open>0){
+ const r=readiness();
+ // Constraints are a hard gate: nothing releases with one open.
+ 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;
}
+ // 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
+ // stored on the package and written to the audit log by the server.
+ if(idx>=ISSUED_IDX && r.blocking.length && !pkgGateOverride){
+ if(!confirmEarlyRelease(r.blocking)){
+ setRadio('status', prevStatus||'Scheduled'); updateReleaseBanner(); return;
+ }
+ }
if(target==='Issue'){ openHoldModal(); return; } // modal commits or reverts
prevStatus=target; updateReleaseBanner(); track('status_change',{status:target});
}
@@ -945,7 +1109,12 @@ function collectPackage(){
disciplines:[...pkgDisciplines],
scope: isMultiDiscipline() ? Object.fromEntries(pkgDisciplines.map(d=>[d,(pkgScope[d]||[]).map(s=>s.trim()).filter(Boolean)])) : undefined,
discStatus: isMultiDiscipline() ? {...pkgDiscStatus} : undefined,
- hours:gv('wp_hours'), seq:gv('wp_seq'),
+ hours:gv('wp_hours'),
+ // seq is the SOP sequence phase (descriptive). predecessors are references to
+ // other packages and are what actually gate release.
+ seq:gv('wp_seq'),
+ predecessors:pkgPredecessors.slice(),
+ gateOverride:pkgGateOverride || undefined,
assets:pkgAssets.filter(a=>a.tag||a.link||a.desc),
materials:pkgMaterials.filter(m=>m.qty||m.desc), attachments:pkgAttach.filter(a=>a.doc),
kitStatus:gv('wp_kit_status'), kitOwner:gv('wp_kit_owner'), kitDate:gv('wp_kit_date'),
@@ -974,6 +1143,12 @@ function collectPackage(){
function savePackage(view){
if(!gv('wp_subject') || !gv('wp_type')){ alert('Subject and WP Type are required.'); return; }
// A BIM package marked "Signed off (IFF)" without the number isn't traceable.
+ // The status can also be set programmatically (the per-discipline roll-up), so
+ // re-check the predecessor gate at the point of saving.
+ const _r=readiness();
+ if(STATUS_ORDER.indexOf(getRadio('status'))>=ISSUED_IDX && _r.blocking.length && !pkgGateOverride){
+ if(!confirmEarlyRelease(_r.blocking)) return;
+ }
if(isEwp() && iffRequired() && !gv('wp_iff').trim()){
alert('Coordination is set to "Signed off (IFF)" — enter the IFF number so the sign-off is traceable.');
const el=document.getElementById('wp_iff'); if(el){ el.focus(); el.scrollIntoView({behavior:'smooth',block:'center'}); }
@@ -1031,7 +1206,13 @@ function renderPackage(pkg){
h+=`
3.0 Scope & Work
Description of Work
${scopeHtml}
Labor Est. Hrs.
${cell(pkg.hours)}
-
Package Predecessor
${pkg.seq?('After: '+esc(pkg.seq)):'None (no predecessor)'}