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 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 18:11:37 -07:00
parent 16afc56c0a
commit 0f28a27441
6 changed files with 84 additions and 32 deletions

View File

@@ -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/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/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/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/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/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 python tests/usage_check.py # D5 - one analytics core, admin report 15 checks

View File

@@ -458,15 +458,41 @@ def wp_link(db: Session, wp: "models.WorkPackage") -> str:
return (base + path) if base else path 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: 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 # Number, title and location — customer context, allowed since the
# customer IP inside the app, behind login). # 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 who = actor.full_name or actor.username
name = assignee.full_name or assignee.username name = assignee.full_name or assignee.username
return ( return (
f"Hi {name},\n\n" f"Hi {name},\n\n"
f"{who} assigned you a work package: {wp.number or '(no number)'}.\n\n" f"{who} assigned you a work package: {wp_titled(wp)}.\n"
f"Open the Work Package Suite to view and action it:\n{link}\n\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." 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", def qa_ready_body(user: "models.User", wp: "models.WorkPackage",
actor: "models.User", link: str) -> str: actor: "models.User", link: str) -> str:
# A WP number and a deep link - NOT the package contents. The task text asked # Number, title and location ride in the body — the 2026-08-20 decision
# for location and a scope summary, but the done-when list (and the standing # restored the location the T7.6 done-when had excluded. The SCOPE summary
# rule) says no customer IP in a message body; the link is the summary. # stays out: scope text is document content, and the link is its summary.
who = actor.full_name or actor.username who = actor.full_name or actor.username
name = user.full_name or user.username name = user.full_name or user.username
return ( return (
f"Hi {name},\n\n" 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"It is in the QA queue waiting to be accepted or returned.\n\n"
f"Open it here:\n{link}\n\n" f"Open it here:\n{link}\n\n"
f"— This is an automated message from the Work Package Suite." 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 name = user.full_name or user.username
return ( return (
f"Hi {name},\n\n" 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"The reason is recorded on the package.\n\n"
f"Open it here:\n{link}\n\n" f"Open it here:\n{link}\n\n"
f"— This is an automated message from the Work Package Suite." 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], def hold_body(user: "models.User", wp: "models.WorkPackage", names: list[str],
actor: "models.User", link: str) -> str: actor: "models.User", link: str) -> str:
# Constraint names and a WP number only — no package contents, same rule as the # Constraint names, number, title and location — customer context per the
# assignment mail. # 2026-08-20 decision. No package contents; the link carries those.
who = user.full_name or user.username who = user.full_name or user.username
by = actor.full_name or actor.username by = actor.full_name or actor.username
which = ", ".join(names) which = ", ".join(names)
return ( return (
f"Hi {who},\n\n" 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"after it was released to the field, so the package is on hold.\n\n"
f"Constraint: {which}\n" f"Constraint: {which}\n"
+ _where_line(wp) +
f"Reopened by: {by}\n\n" 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" or (wp.data or {}).get("mimoLoc") or "").strip() or "not set"
return ( return (
f"Hi {name},\n\n" 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"{old_status or 'Not Started'} to {new_status or 'Not Started'}.\n"
f"Delivery location: {delivery}.\n\n" f"Delivery location: {delivery}.\n\n"
f"Open it here:\n{link}\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 "" needed_line = f" needed by {needed}" if needed else ""
return ( return (
f"Hi {name},\n\n" 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"{n_lines} line{'' if n_lines == 1 else 's'}{needed_line}.\n"
f"Delivery location: {delivery}.\n\n" f"Delivery location: {delivery}.\n\n"
f"Open it here:\n{link}\n\n" f"Open it here:\n{link}\n\n"

View File

@@ -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 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 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 background task. Notification bodies carry customer CONTEXT — the WP number, its
number and a deep link, not the work-package contents. 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 os
import smtplib import smtplib

View File

@@ -106,7 +106,7 @@ def main():
rcpts = sorted(m["to"][0] for m in sink.messages) rcpts = sorted(m["to"][0] for m in sink.messages)
chk("...exactly them, actor excluded", chk("...exactly them, actor excluded",
rcpts == ["pat@example.test", "sue@example.test"], ascii_(rcpts)) 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", chk("the mail says old status, new status and who",
"In Transit" in body and "Delivered" in body and "Root" in body, "In Transit" in body and "Delivered" in body and "Root" in body,
ascii_(body, 260)) ascii_(body, 260))

View File

@@ -121,13 +121,14 @@ def main():
len((wp.get("data") or {}).get("materialRequests") or []) == 1) len((wp.get("data") or {}).get("materialRequests") or []) == 1)
chk("...and the warehouse owner is notified through the T7.6 gate", 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)) 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 " chk("...the mail goes to the owner, says the size, the date, the delivery "
"and carries the deep link", "and carries the deep link",
body["to"] == ["sue@example.test"] and "2 lines" in body["data"] body["to"] == ["sue@example.test"] and "2 lines" in body["text"]
and "2026-09-01" in body["data"] and "Shark cage 7" in body["data"] 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["data"], and ("/wp-creation-index.html?project=projA&wp=" + wp_id) in body["text"],
ascii_(body["data"], 300)) ascii_(body["text"], 300))
_, ev = api(base, "/api/audit?entity_type=wp&entity_id=%s&action=material_requested" % wp_id, root) _, 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)) chk("...and the audit history has it", bool(ev))

View File

@@ -40,7 +40,8 @@ from sections_check import set_sop # noqa: E40
from stepper_check import dismiss_dialogs # noqa: E402 from stepper_check import dismiss_dialogs # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 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): def ascii_(v, n=300):
@@ -87,7 +88,7 @@ class SmtpSink(threading.Thread):
self.sock.bind(("127.0.0.1", 0)) self.sock.bind(("127.0.0.1", 0))
self.sock.listen(8) self.sock.listen(8)
self.port = self.sock.getsockname()[1] self.port = self.sock.getsockname()[1]
self.messages = [] # {"to": [...], "data": str} self.messages = [] # {"to": [...], "data": wire str, "text": decoded body}
self._stop = False self._stop = False
def run(self): def run(self):
@@ -127,8 +128,22 @@ class SmtpSink(threading.Thread):
return return
if in_data: if in_data:
if line.rstrip(b"\r\n") == b".": 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), 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, [] rcpt, in_data, buf = [], False, []
conn.sendall(b"250 OK\r\n") conn.sendall(b"250 OK\r\n")
else: else:
@@ -177,7 +192,7 @@ def set_qa_group(ids):
def mkwp(base, tok, wp_id, status="In Progress", extra_data=None, assignee=None): def mkwp(base, tok, wp_id, status="In Progress", extra_data=None, assignee=None):
data = {"constraints": [{"name": "Boom lift", "status": "cleared", "comment": ""}], 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 {}) data.update(extra_data or {})
return api(base, "/api/wps", tok, "POST", { return api(base, "/api/wps", tok, "POST", {
"id": wp_id, "project_id": "projA", "number": "QA-" + wp_id[-2:], "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()) rcpts = sorted(m["to"][0] for m in qa_msgs())
chk("...addressed to the group members and NOBODY else", chk("...addressed to the group members and NOBODY else",
rcpts == ["pat@example.test", "sue@example.test"], ascii_(rcpts)) 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("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", 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)) "/wp-creation-index.html?project=projA&wp=wpQA2" in body, ascii_(body, 400))
chk("...and no customer IP: the location canary does not appear", # Decided 2026-08-20: context IN, content OUT. This pin asserted the
CANARY not in body and all(CANARY not in m["data"] for m in sink.messages)) # 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 chk("...and no SMTP password either", "SMTP_PASSWORD" not in body
and os.getenv("SMTP_PASSWORD", "hunter2-not-set") 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", chk("...exactly them", rcpts == ["mix@example.test", "pat@example.test",
"sue@example.test"], ascii_(rcpts)) "sue@example.test"], ascii_(rcpts))
chk("...and the comment itself stays on the package, out of the mail", 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). # 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"}) code, _ = api(base, "/api/wps/wpQA2/status", root, "POST", {"status": "Ready for QA"})