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:
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|||||||
@@ -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"})
|
||||||
|
|||||||
Reference in New Issue
Block a user