From 0f28a274411fc0b2258f4c2a2eba0d105509e6a7 Mon Sep 17 00:00:00 2001 From: "n.siegfried" Date: Thu, 20 Aug 2026 18:11:37 -0700 Subject: [PATCH] CR-014 - bodies carry customer context and the link carries the content Nick's decision, 2026-08-20: 'email bodies provide links back to the system. we can talk about customers we just cant exposed their confidential documents.' The T7.6-era rule (no customer IP at all, so number + link only) is refined: context IN, content OUT. - wp_titled() and wp_where() compose 'number - title' and the CR-004 location (structured paths first, legacy free text second); the where-line is dropped entirely when unset rather than mailing 'Where: '. - assign, qa-ready, qa-reject and hold bodies gain title + location. The scope summary the original CR asked for stays OUT - scope text is document content; the link is its summary. Rejection comments stay on the package. - hold_body gains the house footer it alone lacked. - kitting and material-request bodies adopt wp_titled for the same identity line (their delivery-location rule is unchanged). - notify.py's docstring states the new rule where the transport documents it. Pins flipped WITH the rule, reasons in code: qa_gate_check's location canary is now asserted PRESENT in QA bodies; a new DESC_CANARY (document content) is asserted absent from every message (40 -> 41 checks). The sink also gains a decoded-body view: the em-dash switches smtplib to quoted-printable, whose column-76 soft breaks made raw-payload substring pins pass or fail on luck of line position - content pins now read the decoded body, header pins still read the wire payload. Battery: qa_gate_check 41/41, kitting_notify_check 17/17, mreq_check 19/19. Items: CR-014 (rule per decisions-2026-08-20.md), CR-011 pins. Co-Authored-By: Claude Fable 5 --- docs/reference/file-map.md | 2 +- server/app.py | 60 ++++++++++++++++++++++++++--------- server/notify.py | 6 ++-- tests/kitting_notify_check.py | 2 +- tests/mreq_check.py | 11 ++++--- tests/qa_gate_check.py | 35 +++++++++++++++----- 6 files changed, 84 insertions(+), 32 deletions(-) diff --git a/docs/reference/file-map.md b/docs/reference/file-map.md index 742a75f..0f98039 100644 --- a/docs/reference/file-map.md +++ b/docs/reference/file-map.md @@ -280,7 +280,7 @@ python tests/form_structure_check.py # F6/D3 - rail, disclosure, one open sectio python tests/hold_check.py # CR-015/A1/D4 - the hold clears, gates hold 50 checks python tests/warning_check.py # A2 - one warning, a badge from anywhere 17 checks python tests/triage_check.py # A6 - the sidebar answers the stand-up 16 checks -python tests/qa_gate_check.py # CR-014/D2/D9/D10 - QA gate + capture sink 40 checks +python tests/qa_gate_check.py # CR-014/D2/D9/D10 - QA gate + capture sink 41 checks python tests/files_check.py # CR-007/D8 - drawings upload + real offline 36 checks python tests/sticky_bar_check.py # B6 - save reachable on every wizard step 12 checks python tests/usage_check.py # D5 - one analytics core, admin report 15 checks diff --git a/server/app.py b/server/app.py index b8b6fe7..55099e8 100644 --- a/server/app.py +++ b/server/app.py @@ -458,15 +458,41 @@ def wp_link(db: Session, wp: "models.WorkPackage") -> str: return (base + path) if base else path +def wp_titled(wp: "models.WorkPackage") -> str: + """Number — title, for a message body. Decided 2026-08-20: the title is + customer CONTEXT and may ride in mail; document CONTENT may not.""" + t = (wp.subject or "").strip() + n = wp.number or "a work package" + return f"{n} — {t}" if t else n + + +def wp_where(wp: "models.WorkPackage") -> str: + """Where the work happens, for a message body: the CR-004 structured + fields (stored as paths — stable, and readable to the people these mails + address), else the pre-CR-004 free text. Empty string when unset, and + callers drop the line entirely rather than mail 'Where: '.""" + data = wp.data or {} + parts = [str(data.get(d) or "").strip() for d in LOCATION_DIMENSIONS] + parts = [p for p in parts if p] + return " / ".join(parts) if parts else str(data.get("location") or "").strip() + + +def _where_line(wp: "models.WorkPackage") -> str: + w = wp_where(wp) + return f"Where: {w}\n" if w else "" + + def assign_body(assignee: "models.User", wp: "models.WorkPackage", actor: "models.User", link: str) -> str: - # Deliberately minimal — a WP number + a link, NOT the package contents (keeps - # customer IP inside the app, behind login). + # Number, title and location — customer context, allowed since the + # 2026-08-20 decision (decisions-2026-08-20.md). Contents stay behind + # the link: no scope text, no descriptions, no attachments. who = actor.full_name or actor.username name = assignee.full_name or assignee.username return ( f"Hi {name},\n\n" - f"{who} assigned you a work package: {wp.number or '(no number)'}.\n\n" - f"Open the Work Package Suite to view and action it:\n{link}\n\n" + f"{who} assigned you a work package: {wp_titled(wp)}.\n" + + _where_line(wp) + + f"\nOpen the Work Package Suite to view and action it:\n{link}\n\n" f"— This is an automated message from the Work Package Suite." ) @@ -1652,14 +1678,15 @@ def enforce_qa_rejection_comment(data: Optional[dict], new_status: str, def qa_ready_body(user: "models.User", wp: "models.WorkPackage", actor: "models.User", link: str) -> str: - # A WP number and a deep link - NOT the package contents. The task text asked - # for location and a scope summary, but the done-when list (and the standing - # rule) says no customer IP in a message body; the link is the summary. + # Number, title and location ride in the body — the 2026-08-20 decision + # restored the location the T7.6 done-when had excluded. The SCOPE summary + # stays out: scope text is document content, and the link is its summary. who = actor.full_name or actor.username name = user.full_name or user.username return ( f"Hi {name},\n\n" - f"{who} moved {wp.number or 'a work package'} to Ready for QA.\n" + f"{who} moved {wp_titled(wp)} to Ready for QA.\n" + + _where_line(wp) + f"It is in the QA queue waiting to be accepted or returned.\n\n" f"Open it here:\n{link}\n\n" f"— This is an automated message from the Work Package Suite." @@ -1672,7 +1699,8 @@ def qa_reject_body(user: "models.User", wp: "models.WorkPackage", name = user.full_name or user.username return ( f"Hi {name},\n\n" - f"{who} returned {wp.number or 'a work package'} from Ready for QA to In Progress.\n" + f"{who} returned {wp_titled(wp)} from Ready for QA to In Progress.\n" + + _where_line(wp) + f"The reason is recorded on the package.\n\n" f"Open it here:\n{link}\n\n" f"— This is an automated message from the Work Package Suite." @@ -1705,18 +1733,20 @@ def notify_qa_transition(db: Session, wp: "models.WorkPackage", def hold_body(user: "models.User", wp: "models.WorkPackage", names: list[str], actor: "models.User", link: str) -> str: - # Constraint names and a WP number only — no package contents, same rule as the - # assignment mail. + # Constraint names, number, title and location — customer context per the + # 2026-08-20 decision. No package contents; the link carries those. who = user.full_name or user.username by = actor.full_name or actor.username which = ", ".join(names) return ( f"Hi {who},\n\n" - f"A critical constraint was reopened on {wp.number or 'a work package'} " + f"A critical constraint was reopened on {wp_titled(wp)} " f"after it was released to the field, so the package is on hold.\n\n" f"Constraint: {which}\n" + + _where_line(wp) + f"Reopened by: {by}\n\n" - f"Open the package:\n{link}\n" + f"Open the package:\n{link}\n\n" + f"— This is an automated message from the Work Package Suite." ) @@ -1733,7 +1763,7 @@ def kitting_body(user: "models.User", wp: "models.WorkPackage", actor: "models.U or (wp.data or {}).get("mimoLoc") or "").strip() or "not set" return ( f"Hi {name},\n\n" - f"{who} moved kitting on {wp.number or 'a work package'} from " + f"{who} moved kitting on {wp_titled(wp)} from " f"{old_status or 'Not Started'} to {new_status or 'Not Started'}.\n" f"Delivery location: {delivery}.\n\n" f"Open it here:\n{link}\n\n" @@ -1749,7 +1779,7 @@ def material_request_body(user: "models.User", wp: "models.WorkPackage", needed_line = f" needed by {needed}" if needed else "" return ( f"Hi {name},\n\n" - f"{who} raised a material request on {wp.number or 'a work package'}: " + f"{who} raised a material request on {wp_titled(wp)}: " f"{n_lines} line{'' if n_lines == 1 else 's'}{needed_line}.\n" f"Delivery location: {delivery}.\n\n" f"Open it here:\n{link}\n\n" diff --git a/server/notify.py b/server/notify.py index f2cf8d6..7774390 100644 --- a/server/notify.py +++ b/server/notify.py @@ -7,8 +7,10 @@ and is NEVER stored in the database or shown in the UI. Every notable event (e.g. a WP assignment) writes a `notifications` row — an in-app record — and, when email is on + SMTP is set, the row is delivered by email in a -background task. Notification bodies deliberately avoid customer IP: they carry a WP -number and a deep link, not the work-package contents. +background task. Notification bodies carry customer CONTEXT — the WP number, its +title, where the work happens — and a deep link, never customer document CONTENT +(scope text, descriptions, comments, attachments). Decided 2026-08-20; the link is +the summary of everything a body leaves out. """ import os import smtplib diff --git a/tests/kitting_notify_check.py b/tests/kitting_notify_check.py index 77eb850..29c504c 100644 --- a/tests/kitting_notify_check.py +++ b/tests/kitting_notify_check.py @@ -106,7 +106,7 @@ def main(): rcpts = sorted(m["to"][0] for m in sink.messages) chk("...exactly them, actor excluded", rcpts == ["pat@example.test", "sue@example.test"], ascii_(rcpts)) - body = sink.messages[0]["data"] if sink.messages else "" + body = sink.messages[0]["text"] if sink.messages else "" chk("the mail says old status, new status and who", "In Transit" in body and "Delivered" in body and "Root" in body, ascii_(body, 260)) diff --git a/tests/mreq_check.py b/tests/mreq_check.py index f1db5b1..e127f3d 100644 --- a/tests/mreq_check.py +++ b/tests/mreq_check.py @@ -121,13 +121,14 @@ def main(): len((wp.get("data") or {}).get("materialRequests") or []) == 1) chk("...and the warehouse owner is notified through the T7.6 gate", wait_for(lambda: any("Material request" in m["data"] for m in sink.messages), 12)) - body = next((m for m in sink.messages if "Material request" in m["data"]), {"data": "", "to": [""]}) + body = next((m for m in sink.messages if "Material request" in m["data"]), + {"data": "", "text": "", "to": [""]}) chk("...the mail goes to the owner, says the size, the date, the delivery " "and carries the deep link", - body["to"] == ["sue@example.test"] and "2 lines" in body["data"] - and "2026-09-01" in body["data"] and "Shark cage 7" in body["data"] - and ("/wp-creation-index.html?project=projA&wp=" + wp_id) in body["data"], - ascii_(body["data"], 300)) + body["to"] == ["sue@example.test"] and "2 lines" in body["text"] + and "2026-09-01" in body["text"] and "Shark cage 7" in body["text"] + and ("/wp-creation-index.html?project=projA&wp=" + wp_id) in body["text"], + ascii_(body["text"], 300)) _, ev = api(base, "/api/audit?entity_type=wp&entity_id=%s&action=material_requested" % wp_id, root) chk("...and the audit history has it", bool(ev)) diff --git a/tests/qa_gate_check.py b/tests/qa_gate_check.py index 7218118..7ac3080 100644 --- a/tests/qa_gate_check.py +++ b/tests/qa_gate_check.py @@ -40,7 +40,8 @@ from sections_check import set_sop # noqa: E40 from stepper_check import dismiss_dialogs # noqa: E402 ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -CANARY = "FAB-9 SECRET SECTOR" # rides on the package; must never reach a body +CANARY = "FAB-9 SECRET SECTOR" # location: customer CONTEXT - IN bodies since 2026-08-20 +DESC_CANARY = "PULL-SCHED-CANARY-7X" # document CONTENT - must never reach a body def ascii_(v, n=300): @@ -87,7 +88,7 @@ class SmtpSink(threading.Thread): self.sock.bind(("127.0.0.1", 0)) self.sock.listen(8) self.port = self.sock.getsockname()[1] - self.messages = [] # {"to": [...], "data": str} + self.messages = [] # {"to": [...], "data": wire str, "text": decoded body} self._stop = False def run(self): @@ -127,8 +128,22 @@ class SmtpSink(threading.Thread): return if in_data: if line.rstrip(b"\r\n") == b".": + raw = b"".join(buf) + # "data" is the wire payload (headers + body, transfer-encoded). + # "text" is the DECODED body: any non-ASCII character (the + # bodies' em-dash) switches smtplib to quoted-printable, whose + # soft line breaks split words at column 76 - a substring pin + # against "data" then fails on luck of line position. Content + # pins read "text"; header pins (Subject:) still read "data". + import email as _email + try: + _msg = _email.message_from_bytes(raw) + _text = _msg.get_payload(decode=True).decode("utf-8", "replace") + except Exception: + _text = raw.decode("utf-8", "replace") self.messages.append({"to": list(rcpt), - "data": b"".join(buf).decode("utf-8", "replace")}) + "data": raw.decode("utf-8", "replace"), + "text": _text}) rcpt, in_data, buf = [], False, [] conn.sendall(b"250 OK\r\n") else: @@ -177,7 +192,7 @@ def set_qa_group(ids): def mkwp(base, tok, wp_id, status="In Progress", extra_data=None, assignee=None): data = {"constraints": [{"name": "Boom lift", "status": "cleared", "comment": ""}], - "location": CANARY, "desc": "600ft of 3/4 EMT through " + CANARY} + "location": CANARY, "desc": "600ft of 3/4 EMT through " + DESC_CANARY} data.update(extra_data or {}) return api(base, "/api/wps", tok, "POST", { "id": wp_id, "project_id": "projA", "number": "QA-" + wp_id[-2:], @@ -263,12 +278,16 @@ def main(): rcpts = sorted(m["to"][0] for m in qa_msgs()) chk("...addressed to the group members and NOBODY else", rcpts == ["pat@example.test", "sue@example.test"], ascii_(rcpts)) - body = qa_msgs()[0]["data"] if qa_msgs() else "" + body = qa_msgs()[0]["text"] if qa_msgs() else "" chk("the message carries the WP number", "QA-A2" in body, ascii_(body, 200)) chk("...and a link that opens THAT work package, not the app root", "/wp-creation-index.html?project=projA&wp=wpQA2" in body, ascii_(body, 400)) - chk("...and no customer IP: the location canary does not appear", - CANARY not in body and all(CANARY not in m["data"] for m in sink.messages)) + # Decided 2026-08-20: context IN, content OUT. This pin asserted the + # location's ABSENCE until that decision; it flipped with the rule. + chk("...and the location and title ride in the body (context, allowed)", + CANARY in body and "conduit" in body, ascii_(body, 300)) + chk("...but document content never does: the desc canary appears nowhere", + all(DESC_CANARY not in m["text"] + m["data"] for m in sink.messages)) chk("...and no SMTP password either", "SMTP_PASSWORD" not in body and os.getenv("SMTP_PASSWORD", "hunter2-not-set") not in body) @@ -290,7 +309,7 @@ def main(): chk("...exactly them", rcpts == ["mix@example.test", "pat@example.test", "sue@example.test"], ascii_(rcpts)) chk("...and the comment itself stays on the package, out of the mail", - all("Torque strap" not in m["data"] for m in sink.messages)) + all("Torque strap" not in m["text"] + m["data"] for m in sink.messages)) # The upsert path enforces the same comment rule (it is how the browser saves). code, _ = api(base, "/api/wps/wpQA2/status", root, "POST", {"status": "Ready for QA"})