T7.6 - CR-014/D2/D9/D10: the Ready for QA gate, notification only, shipped off

Marlena's ask: QA sees inbound work ahead of time, not after the fact.

The rung: 'Ready for QA' sits between In Progress and QC in BOTH ladders
(wp-creation-app.js STATUS_ORDER, server/app.py STATUS_ORDER) and in Field
View's list - inside the T7.3 transition model, not beside it: entering it
from an unreleased state crosses the release gates, and 'Issue' (hold) stays
a branch. The dashboard filter and the navigator grouping learned the state
from the ladder without their own edits.

Who hears (D2): the QA GROUP, a multi-pick of project members on the SOP
wizard's team step, stored as account ids at data.sop.project.qaGroupIds.
Entering Ready for QA emails that list and nobody else. A rejection emails
the owner AND the same list (amended answer), returns the package to In
Progress, and REQUIRES a fresh comment - server-enforced on both write paths
(the first version accepted any old comment already on the record, which made
every rejection after the first one free; the gate now demands a new entry).
Accept and reject are real buttons on the release banner; the comment modal
enforces its field; qa_ready / qa_rejected / status_changed all land in the
audit history.

The link (X1): wp_link() now opens THE package - wp-creation-index.html
?project&wp=<id>, which the creator boots directly and login.html?next=
round-trips for a signed-out recipient. It previously pointed at the suite
root, which is exactly the failure X1 names; assignment mail inherits the fix.

Email discipline (D10 + standing rules): ships OFF (the stored setting the
admin console already owns; PUT /api/settings is admin-only, 403 for anyone
else, and audited). With it off, transitions write outbox rows marked
'skipped' and the sink receives nothing. With it on, the probe runs a REAL
SMTP conversation against an in-process capture sink and asserts the count
and the exact recipient set. A dead SMTP host leaves a 'failed' outbox row
with the error recorded. The SMTP password exists only in the environment.

DEVIATION, stated: the task's Do-paragraph asks the email to include location
and a scope summary; the done-when list (and CLAUDE.md) says no customer IP in
a message body. The done-when wins: bodies carry the WP number, who moved it,
and the deep link. A location canary planted on the package is asserted absent
from every captured message. If the fuller body is wanted, that is a product
call - needs Nick.

Found while building, logged not fixed (BL-021): project_sop_team() reads
sop.data['project'], a path pushSOP never writes - the critical-reopen email
has never actually reached the PM/CM. One-line fix, owned by T9.9.

Field View (D9): 'Ready for QA' is carried by TEXT on the card at 390px.

Verification (each probe run alone): NEW tests/qa_gate_check.py 40/40.
Regressions: hold_check 50/50, pipeline_check 44/44, aggregates_check 16/16,
frame_check 39/39, validation_check 83/83.

Items: CR-014, D2, D9, D10 (X1, X3 honored)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 10:54:07 -07:00
parent 5d27a1e086
commit 2486f87010
10 changed files with 700 additions and 9 deletions

View File

@@ -32,6 +32,7 @@ let state = {
// notification routing uses — a typed name can't be emailed.
team: {pm:'', apm:'', cm:'', qm:''},
teamIds: {pm:'', apm:'', cm:'', qm:''},
qaGroupIds: [], // D2: who is emailed when a package reaches Ready for QA
teamMembers: [],
signoffRoles: [{role:'Superintendent',name:''},{role:'Foreman',name:''}],
wpTypes: [],
@@ -419,6 +420,7 @@ function loadSampleData(){
state.team.cm = 'K. Boyd';
state.team.qm = 'D. Nguyen';
state.teamIds = {pm:'', apm:'', cm:'', qm:''};
state.qaGroupIds = [];
renderTeamPickers();
// Step 3 — standard required roles
@@ -500,6 +502,7 @@ function restoreSavedSOP(){
// A SOP saved before the team was account-backed has no teamIds; default them
// so the pickers render (the stored names show as "(no account)" until linked).
if(!state.teamIds) state.teamIds = {pm:'', apm:'', cm:'', qm:''};
if(!Array.isArray(state.qaGroupIds)) state.qaGroupIds = []; // D2, pre-T7.6 SOPs
// Re-render dynamic lists from restored state.
renderWPTypes();
@@ -820,6 +823,7 @@ function renderTeamPickers(){
sel.innerHTML = html;
sel.onchange = function(){ setTeamLead(key, this.value); };
});
renderQaGroupPicker();
if(!warn) return;
if(projectUsersLoaded && !projectUsers.length){
warn.style.display = '';
@@ -835,6 +839,20 @@ function renderTeamPickers(){
}
}
// D2: the QA group - a multi-pick of project members, chosen once here and read
// by the server when a package reaches Ready for QA. Stored as account ids;
// display names are derived at save so a rename never breaks the routing.
function renderQaGroupPicker(){
const sel = document.getElementById('proj_qagroup');
if(!sel) return;
const cur = new Set(state.qaGroupIds || []);
sel.innerHTML = projectUsers.map(u =>
`<option value="${escAttr(u.id)}"${cur.has(u.id)?' selected':''}>${escAttr(userLabel(u))}</option>`).join('');
sel.onchange = function(){
state.qaGroupIds = [...this.selectedOptions].map(o => o.value).filter(Boolean);
};
}
// One <select> of project people, reused everywhere the SOP names someone. Keeps a
// name that has no matching account as a selected "(no account)" option so older
// SOPs — and the sample's fictional names — are never silently dropped.
@@ -2124,6 +2142,9 @@ function completeSOP(){
apmId: state.teamIds.apm || '',
cmId: state.teamIds.cm || '',
qmId: state.teamIds.qm || '',
// D2: the QA group - ids are the routing, names are for display.
qaGroupIds: (state.qaGroupIds || []).slice(),
qaGroup: (state.qaGroupIds || []).map(id => { const u = userById(id); return u ? (u.full_name || u.username) : ''; }).filter(Boolean),
site: state.project.site,
teamMembers: state.teamMembers.filter(m=>(m.role||m.name||m.userId))
},