Chapter 5 · Security and Operations
Team Handover: Prompt Libraries, Change Logs, Handover Documents and Readiness
- Page 22 of 24
- 18 min read
The previous page, Prompt Operations: Owners, Versions, Acceptance Criteria and Rollback, gave one prompt an owner, versions, a fixed regression set and acceptance criteria. This page covers what happens when more people and models are involved: a second model, changes that need a second pair of eyes, a prompt that gets worse in production, and a teammate who must take over. The backend side (which prompt and model version served a request) is in Git, APIs and Backend for AI: Advanced; the topic here is the asset's lifecycle.
The page runs on its own: the first two blocks recreate where the previous page ended (its promptops.py, trimmed, plus a change log and rollback). It is shorter than the previous page's version (no lint, diff or token budget; run_set writes no runs/ files), so use a fresh folder rather than reusing the old one. You need Python 3.12+ and promptlab.py. Every "model" is a toy provider, a deterministic stand-in for repeatable tests; toy results say nothing about real, less predictable models.
What you will learn
- When a variant is justified, and how to review, roll back and log a change.
- How to keep a searchable library and evaluation data free of unnecessary personal data.
- How to state limitations, judge readiness for a product and write a handover document.
Start here: the toolkit and a small project
Save this module first.
# file: promptops.py
"""promptops, trimmed, plus a change log and rollback."""
import hashlib, json
from pathlib import Path
from promptlab import Client, render
ROOT = Path("prompts")
def _read():
return json.loads((ROOT / "registry.json").read_text(encoding="utf-8"))
def _save(reg):
text = json.dumps(reg, indent=1, sort_keys=True, ensure_ascii=False)
(ROOT / "registry.json").write_text(text + "\n", encoding="utf-8")
def update(name, **fields):
reg = _read()
reg[name].update(fields)
_save(reg)
def load(name, version=None):
asset = _read()[name]
version = version or asset["current"]
entry = asset["versions"][version]
return {"version": version, "entry": entry, "asset": asset,
"text": (ROOT / name / entry["file"]).read_text(encoding="utf-8")}
def add_version(name, text, reason, date):
if not reason.strip():
raise ValueError("a new version needs a change reason")
reg = _read()
version = f"v1.{len(reg[name]['versions'])}"
(ROOT / name / f"{version}.txt").write_text(text, encoding="utf-8")
reg[name]["versions"][version] = {"file": f"{version}.txt", "reason": reason, "date": date, "status": "candidate"}
_save(reg)
return version
def set_status(name, version, status):
reg = _read()
reg[name]["versions"][version]["status"] = status
_save(reg)
def set_current(name, version, date, why):
asset = _read()[name]
history = asset.get("pointer_history", []) + [{"from": asset["current"], "to": version, "date": date, "why": why}]
update(name, current=version, pointer_history=history)
set_status(name, version, "released")
KEYWORDS = {"en": {"billing": ("charged", "invoice"), "bug": ("crash", "error")},
"es": {"billing": ("cobr", "factura"), "bug": ("falla", "falló")},
"bn": {"billing": ("চার্জ",), "bug": ("ক্র্যাশ",)}}
LANGUAGES = {"en": "English", "es": "Spanish", "bn": "Bangla"}
def toy_triage(messages, **settings):
"""STAND-IN, not a model: reads only the languages the prompt names; breaks ties as told.
Planted weakness: without the context line, it loses tickets longer than 12 words."""
rules, ticket = messages[-1]["content"].split("Ticket:\n", 1)
if "software shop" not in rules and len(ticket.split()) > 12:
return "other"
langs = [code for code, word in LANGUAGES.items() if word in rules]
hits = [label for label in ("billing", "bug")
if any(w in ticket.lower() for c in langs for w in KEYWORDS[c][label])]
if len(hits) > 1 and "choose bug" in rules:
return "bug"
return hits[0] if hits else "other"
def run_set(name, version, provider=toy_triage, text=None):
"""Run the fixed set; `text` tries an unsaved draft instead."""
raw = (ROOT / name / "cases.json").read_bytes()
prompt, cases, client = text or load(name, version)["text"], json.loads(raw), Client(provider)
ok = {c["id"]: client.complete([{"role": "user", "content": render(prompt, ticket=c["ticket"])}]).text.strip()
== c["expected"] for c in cases}
langs = {}
for c in cases:
langs.setdefault(c["language"], []).append(ok[c["id"]])
return {"cases": hashlib.sha256(raw).hexdigest()[:8], "accuracy": round(sum(ok.values()) / len(ok), 3),
"by_language": {k: round(sum(v) / len(v), 2) for k, v in sorted(langs.items())},
"failed": [i for i, good in ok.items() if not good]}
def check(report, baseline, criteria):
"""Judge a run by the criteria. Empty list = accepted."""
if baseline["cases"] != report["cases"]:
return ["different case set: results are not comparable"]
problems = []
if report["accuracy"] < criteria["min_accuracy"]:
problems.append(f"accuracy {report['accuracy']} is below {criteria['min_accuracy']}")
for lang, acc in report["by_language"].items():
if acc < criteria["min_accuracy_per_language"]:
problems.append(f"{lang} accuracy {acc} is below {criteria['min_accuracy_per_language']}")
new = sorted(set(report["failed"]) - set(baseline["failed"]))
if len(new) > criteria["max_new_failures"]:
problems.append(f"new failures: {new}")
return problems
def log_change(kind, name, version, who, date, summary):
"""Append one line to prompts/changelog.jsonl: append-only, one event per line."""
event = {"date": date, "kind": kind, "asset": name, "version": version, "who": who, "summary": summary}
with (ROOT / "changelog.jsonl").open("a", encoding="utf-8") as handle:
handle.write(json.dumps(event, sort_keys=True, ensure_ascii=False) + "\n")
def changelog(name=None):
lines = (ROOT / "changelog.jsonl").read_text(encoding="utf-8").splitlines()
events = [e for e in map(json.loads, lines) if name in (None, e["asset"])]
return "\n".join(f"{e['date']} {e['version']:<5} {e['kind']:<11} {e['who']}: {e['summary']}"
for e in reversed(events))
def rollback(name, to_version, date, who, why):
"""Undo by pointer: nothing is deleted. Refuses a target that was never released."""
asset = load(name)["asset"]
if asset["versions"][to_version]["status"] != "released":
raise ValueError(f"{to_version} was never released; choose a released version")
bad = asset["current"]
set_current(name, to_version, date, why)
set_status(name, bad, "withdrawn")
log_change("rolled-back", name, bad, who, date, f"back to {to_version}: {why}")This block creates the asset: v1.1 is current, v1.2 was rejected, with twelve cases, criteria, a change log and three more library entries.
import json
from pathlib import Path
from promptops import *
V10 = """You label customer support tickets for a software shop.
Tickets are written in English.
Labels: billing, bug, other.
Reply with the label only.
Ticket:
{ticket}
"""
V11 = V10.replace("are written in English.", "may be written in English, Spanish or Bangla.").replace(
"Reply", "If a ticket fits more than one label, choose bug.\nReply")
V12 = V11.replace("If a ticket fits more than one label, choose bug.\n", "")
TEXT = {"billing": ["I was charged twice for my March invoice.", "Me cobraron dos veces la factura de marzo.",
"আমার কার্ডে দুইবার চার্জ করা হয়েছে।"],
"bug": ["The app crashes when I upload a photo.", "La aplicación falla al subir una foto.",
"অ্যাপটি বারবার বন্ধ হয়ে যায়।"],
"other": ["How do I change my account email?", "¿Cómo cambio mi correo?", "আমার পাসওয়ার্ড কীভাবে বদলাব?"],
"mixed": ["Charged twice and then the app crashed.", "Me cobraron dos veces y luego la app falló.",
"দুইবার চার্জ হয়েছে আর অ্যাপ ক্র্যাশ করেছে।"]}
cases = [{"id": f"{lang}-{use}", "language": lang, "use_case": use, "expected": "bug" if use == "mixed" else use,
"ticket": TEXT[use][i]} for use in TEXT for i, lang in enumerate(("en", "es", "bn"))]
folder = Path("prompts/ticket-triage")
folder.mkdir(parents=True)
(folder / "cases.json").write_text(json.dumps(cases, ensure_ascii=False, indent=1), encoding="utf-8")
(folder / "criteria.json").write_text(json.dumps({"min_accuracy": 0.85, "min_accuracy_per_language": 0.75,
"max_new_failures": 0}), encoding="utf-8")
(folder / "v1.0.txt").write_text(V10, encoding="utf-8")
asset = {"purpose": "Label a support ticket as billing, bug or other.", "current": "v1.0",
"owner": "Nadia Rahman, support tools (Dhaka)", "reviewer": "Mateo Silva, support operations (Bogotá)",
"assumptions": {"model": "a stand-in; write the real model name", "checked_on": "2026-10-12"},
"tags": {"category": "classification", "languages": ["bn", "en", "es"], "stage": "production"},
"limitations": ["Only English, Spanish and Bangla were tested.",
"Bangla bug reports without the word for 'crash' are labelled other (bn-bug); route to a person."],
"versions": {"v1.0": {"file": "v1.0.txt", "reason": "First release.", "date": "2026-09-01",
"status": "released"}}}
library = {"reply-draft": ("Draft a customer reply.", "writing", ["en"], "pilot"),
"invoice-extract": ("Extract invoice fields.", "extraction", ["en", "es"], "production"),
"meeting-summary": ("Summarise meeting notes.", "writing", ["en", "bn"], "draft")}
registry = {"ticket-triage": asset, **{k: {"purpose": p, "tags": {"category": c, "languages": l, "stage": s}}
for k, (p, c, l, s) in library.items()}}
(ROOT / "registry.json").write_text(json.dumps(registry, ensure_ascii=False), encoding="utf-8")
add_version("ticket-triage", V11, "Spanish and Bangla tickets were labelled other.", "2026-09-14")
add_version("ticket-triage", V12, "Shorter prompt to cut input tokens.", "2026-09-20")
set_current("ticket-triage", "v1.1", "2026-09-21", "Passed all criteria.")
set_status("ticket-triage", "v1.2", "rejected")
log_change("added", "ticket-triage", "v1.0", "Nadia Rahman", "2026-09-01", "First release.")
log_change("rejected", "ticket-triage", "v1.2", "Nadia Rahman", "2026-09-20", "Lost the three mixed tickets.")
log_change("released", "ticket-triage", "v1.1", "Nadia Rahman", "2026-09-21", "Three languages and a tie rule.")When a prompt needs a model-specific variant
Keep one prompt per asset while it passes the same criteria on every model you support. Make a variant only when evidence says one text cannot: the fixed set scores clearly differently on two models, one model breaks an output format the other follows, or a provider's guide recommends another style for a model family. A variant must share the variables, pass the same criteria and be re-tested when the base changes.
Provider note (as of 2026-10-12): OpenAI's reasoning guide advises a clear goal, strong constraints and an explicit output contract for reasoning models, and more explicit, precise instructions for non-reasoning ones. Anthropic advises general instructions over hand-written step lists. Style advice differs by family, so test; check the current documentation (see Provider Differences and Portability: Reading Guides, Testing Across Models).
A second toy model (stand-in) wraps the label in a sentence unless the prompt says "no other words". With it the base prompt scores zero, since every reply is a sentence. The variant changes that one thing.
def toy_chatty(messages, **settings):
"""STAND-IN for a second model that wraps the label in a sentence unless told not to."""
label = toy_triage(messages)
return label if "no other words" in messages[-1]["content"] else f"Label: {label}."
base = load("ticket-triage", "v1.1")["text"]
variant = base.replace("Reply with the label only.", "Reply with the label only, with no other words.")
for text in (base, variant):
r = run_set("ticket-triage", None, provider=toy_chatty, text=text)
print(r["accuracy"], len(r["failed"]), "failing")
Path("prompts/ticket-triage/v1.1.chatty.txt").write_text(variant, encoding="utf-8")
update("ticket-triage", variants={"v1.1:chatty": "v1.1.chatty.txt"})0.0 12 failing
0.917 1 failingThe variant reaches the same 0.917 as the base prompt on the first model; the registry records which version it belongs to.
A review process for material changes
A change is material if it can change what the prompt does: instructions, labels, variables, languages, examples, model or settings. A spacing fix is not; when unsure, treat it as material. Process: the author writes the version with a reason and runs the fixed set; a reviewer who is not the author reads the diff, reason and report, and checks that the criteria file was not edited in the same change; the gate passes, the owner moves the pointer, the release is logged. Version v1.3 is a compact rewrite that drops the context line; gate applies the criteria and the author/approver rule:
V13 = """Label the support ticket: billing, bug or other.
Tickets may be in English, Spanish or Bangla. Mixed ticket: choose bug.
Reply with the label only.
Ticket:
{ticket}
"""
def gate(name, version, baseline, author, approver):
criteria = json.loads((ROOT / name / "criteria.json").read_text(encoding="utf-8"))
problems = check(run_set(name, version), baseline, criteria)
if approver == author:
problems.append("the approver must not be the author")
return problems
add_version("ticket-triage", V13, "Same labels in fewer tokens: drop the context line.", "2026-10-01")
baseline = run_set("ticket-triage", "v1.1")
print(gate("ticket-triage", "v1.3", baseline, "Nadia Rahman", "Nadia Rahman"))
print(gate("ticket-triage", "v1.3", baseline, "Nadia Rahman", "Mateo Silva") or "approved")
set_current("ticket-triage", "v1.3", "2026-10-01", "Gate passed; approved by Mateo Silva.")
log_change("released", "ticket-triage", "v1.3", "Mateo Silva", "2026-10-01", "Compact prompt; gate passed.")['the approver must not be the author']
approvedThe gate stopped a self-approved change, then passed v1.3, because no case exercises what the compact prompt gets wrong. A gate is only as good as its set.
A rollback plan for a regressing prompt
Three days later the support lead reports that long tickets go to other. A rollback plan, written before this happens, says what counts as a regression, who may decide, how to undo, how to verify and what comes next. Rollback is cheap because released files never change: move the pointer to the last released version, add the failing case to the set so it cannot return unnoticed, and fix forward.
PLAN = """Trigger: a failing case in the set, or a confirmed report from support.
Who: the owner or the reviewer; tell the other within the day.
How: rollback() moves the pointer; no file is deleted. Then re-run the set.
Next: add the failing ticket to cases.json, fix forward, pass the gate."""
update("ticket-triage", rollback_plan=PLAN)
LONG = "Charged twice for my March invoice, and then the app crashed while I uploaded the receipt photo."
path = Path("prompts/ticket-triage/cases.json")
cases = json.loads(path.read_text(encoding="utf-8"))
cases.append({"id": "en-long", "language": "en", "use_case": "mixed", "expected": "bug", "ticket": LONG})
path.write_text(json.dumps(cases, ensure_ascii=False, indent=1), encoding="utf-8")
crit = json.loads(Path("prompts/ticket-triage/criteria.json").read_text(encoding="utf-8"))
r11, r13 = run_set("ticket-triage", "v1.1"), run_set("ticket-triage", "v1.3")
print(r13["failed"], check(r13, r11, crit))
rollback("ticket-triage", "v1.1", "2026-10-04", "Nadia Rahman", "v1.3 loses long tickets (en-long)")
print(load("ticket-triage")["version"], load("ticket-triage", "v1.3")["entry"]["status"],
run_set("ticket-triage", "v1.1")["failed"])['bn-bug', 'en-long'] ['accuracy 0.846 is below 0.85', "new failures: ['en-long']"]
v1.1 withdrawn ['bn-bug']The restored v1.1 fails only bn-bug, as before. A rollback restores the old prompt; if a provider changed a model, the fix may be to pin a model version or add a variant, as the assumptions block shows.
A prompt change log
The registry lists what exists; the change log records what happened and who decided. One line per event, append-only, never edited. Fields: date, asset, version, kind (added, released, rejected, variant, rolled-back), who, and a summary with the evidence (a score, a case id). Log rejections and rollbacks too. Git shows which lines changed; the log shows what the team decided.
print(changelog("ticket-triage"))2026-10-04 v1.3 rolled-back Nadia Rahman: back to v1.1: v1.3 loses long tickets (en-long)
2026-10-01 v1.3 released Mateo Silva: Compact prompt; gate passed.
2026-09-21 v1.1 released Nadia Rahman: Three languages and a tie rule.
2026-09-20 v1.2 rejected Nadia Rahman: Lost the three mixed tickets.
2026-09-01 v1.0 added Nadia Rahman: First release.A prompt library with searchable categories
Without search, people write duplicates. A library is the registry plus tags from a controlled list: category (classification, extraction, writing), languages and stage (draft, pilot, production). Free-form tags drift (summarise, summarize), so check them against the list. Search combines words from the name or purpose with exact tag values.
def find(text="", **tags):
registry = json.loads(Path("prompts/registry.json").read_text(encoding="utf-8"))
def has(asset, key, wanted):
value = asset.get("tags", {}).get(key)
return wanted in value if isinstance(value, list) else value == wanted
return sorted(name for name, asset in registry.items()
if text.lower() in f"{name} {asset['purpose']}".lower()
and all(has(asset, key, wanted) for key, wanted in tags.items()))
print(find(category="writing"), find("invoice"), find(languages="bn", stage="production"))['meeting-summary', 'reply-draft'] ['invoice-extract'] ['ticket-triage']Keep evaluation data free of unnecessary sensitive information
Test sets are copied into repositories, reports and chats. Minimise: a case needs the shape of a real ticket, rarely the real email address or card number. Prefer invented or redacted text; if you must use real tickets, check the rules where you live and your organisation's policy, and limit access. A pattern scan finds emails and long numbers but misses names and identifying stories, so a person still reads a sample. Privacy and Security Review: Secrets, Personal Data, Logging and Output Handling covers the wider review.
import re
EMAIL = re.compile(r"[\w.+-]+@[\w-]+(?:\.[\w-]+)+")
NUMBER = re.compile(r"\+?\d(?:[ -]?\d){8,}") # 9 or more digits
def redact(text):
return NUMBER.sub("[NUMBER]", EMAIL.sub("[EMAIL]", text))
export = [{"id": "x1", "ticket": "Charged twice, please call me: +49 30 1234 5678."},
{"id": "x2", "ticket": "Refund to card 4111 1111 1111 1111 or write to lena@example.org."}]
for row in export:
print(redact(row["ticket"]))Charged twice, please call me: [NUMBER].
Refund to card [NUMBER] or write to [EMAIL].Summarise limitations and known failure modes
A limitation is a statement a teammate can test: what the prompt does not handle, the evidence, and what to do instead. Name a scope ("only three languages tested"), a case id and a mitigation ("route to a person"); "may make mistakes" is useless. Combine what a person writes with what the latest run shows, so the list cannot go stale.
Weak: "may make mistakes". Testable: "Bangla bug reports without the word for 'crash' are labelled other (bn-bug); route to a person." The setup block stored two such entries; the handover document below prints them beside the failing cases.
When a prompt is ready for product integration
"Ready" is a checklist a script can partly check and a person signs. ready returns the open items; it cannot judge whether the criteria were the right ones. Your code must also treat the reply as untrusted text and validate it against the allowed labels, as Validation and Repair: jsonschema, Business Rules and Bounded Retries showed, with a person in the loop where a wrong label is costly.
def ready(name):
current = load(name)
asset, version = current["asset"], current["version"]
criteria = json.loads((ROOT / name / "criteria.json").read_text(encoding="utf-8"))
report = run_set(name, version)
cases = json.loads((ROOT / name / "cases.json").read_text(encoding="utf-8"))
checks = {
"owner, reviewer and assumptions recorded": all(asset.get(k) for k in ("owner", "reviewer", "assumptions")),
"criteria met on the fixed set": not check(report, report, criteria),
"limitations written": bool(asset.get("limitations")),
"rollback plan written": bool(asset.get("rollback_plan")),
"an earlier released version exists": any(v != version and e["status"] == "released"
for v, e in asset["versions"].items()),
"current version is in the change log": f" {version} " in changelog(name),
"no personal data in the cases": all(redact(c["ticket"]) == c["ticket"] for c in cases)}
return [item for item, ok in checks.items() if not ok]
print(ready("ticket-triage") or "no open items")no open itemsA prompt handover document for teammates
A handover document lets a teammate take over without a meeting. Generate the facts from the registry and the latest run so they stay true; leave marked lines for judgement only a person can write. Keep it to one page.
HANDOVER = """Handover: {name}
Owner: {owner} | Reviewer: {reviewer}
Purpose: {purpose}
Current version: {version} | Fixed set: {n} cases (digest {digest}) | Quality: {quality}
Failing today: {failing}
Known limitations:
{limits}
Recent changes:
{recent}
If it regresses:
{plan}
Open readiness items: {open_items}
To write by hand (a script cannot know these):
- Who asks for changes, and how urgent ones are handled:
- Which downstream code reads the label, and who owns it:
- Ideas we rejected, and why:"""
def handover(name):
current = load(name)
asset, report = current["asset"], run_set(name, current["version"])
n = len(json.loads((ROOT / name / "cases.json").read_text(encoding="utf-8")))
return render(HANDOVER, name=name, owner=asset["owner"], reviewer=asset["reviewer"], purpose=asset["purpose"],
version=current["version"], n=n, digest=report["cases"], failing=", ".join(report["failed"]) or "none",
quality=", ".join(f"{k} {v}" for k, v in report["by_language"].items()),
limits="\n".join("- " + text for text in asset["limitations"]),
recent="\n".join(changelog(name).splitlines()[:2]), plan=asset["rollback_plan"],
open_items=", ".join(ready(name)) or "none")
print(handover("ticket-triage"))Handover: ticket-triage
Owner: Nadia Rahman, support tools (Dhaka) | Reviewer: Mateo Silva, support operations (Bogotá)
Purpose: Label a support ticket as billing, bug or other.
Current version: v1.1 | Fixed set: 13 cases (digest e8191325) | Quality: bn 0.75, en 1.0, es 1.0
Failing today: bn-bug
Known limitations:
- Only English, Spanish and Bangla were tested.
- Bangla bug reports without the word for 'crash' are labelled other (bn-bug); route to a person.
Recent changes:
2026-10-04 v1.3 rolled-back Nadia Rahman: back to v1.1: v1.3 loses long tickets (en-long)
2026-10-01 v1.3 released Mateo Silva: Compact prompt; gate passed.
If it regresses:
Trigger: a failing case in the set, or a confirmed report from support.
Who: the owner or the reviewer; tell the other within the day.
How: rollback() moves the pointer; no file is deleted. Then re-run the set.
Next: add the failing ticket to cases.json, fix forward, pass the gate.
Open readiness items: none
To write by hand (a script cannot know these):
- Who asks for changes, and how urgent ones are handled:
- Which downstream code reads the label, and who owns it:
- Ideas we rejected, and why:Facts come from files, so the document cannot disagree with the registry.
Where this shows up at work
When a teammate leaves or an audit asks who approved a prompt and what they checked, the registry, log and handover document are the answer.
Common mistakes
- A variant for every model. It doubles the testing. Fix: only when the set shows a gap.
- Self-approved changes. Authors miss their own blind spots. Fix: a different reviewer; the gate enforces it.
- A rollback plan written during the incident. Fix: write it at release.
- Real customer data in test cases. It spreads with every copy. Fix: invented or redacted cases, a scan, a human sample check.
- Limitations written once. Fix: join them with the latest failing cases.
Try it yourself
- Easy. Write the call that finds production assets handling Spanish.
- Medium. Try to roll back to the rejected
v1.2. Predict what happens, then run it. - Hard. Write
v1.4:v1.3with its context line restored. Pass the gate with a different reviewer, release it, log it, and print the newest log line.
Answers
Easy.
print(find(languages="es", stage="production"))['invoice-extract', 'ticket-triage']Pass checklist: it uses tags, not text search; both matching assets appear.
Medium. The version was rejected, so rollback refuses it.
try:
rollback("ticket-triage", "v1.2", "2026-10-05", "Nadia Rahman", "test")
except ValueError as exc:
print(exc)v1.2 was never released; choose a released versionPass checklist: the pointer did not move; you can say why (a rejected version failed the criteria).
Hard.
V14 = V13.replace("Label the support ticket:", "You label customer support tickets for a software shop.\nLabels:")
add_version("ticket-triage", V14, "v1.3 plus the context line; fixes long tickets.", "2026-10-06")
baseline = run_set("ticket-triage", "v1.1")
print(gate("ticket-triage", "v1.4", baseline, "Nadia Rahman", "Mateo Silva") or "approved")
set_current("ticket-triage", "v1.4", "2026-10-06", "Gate passed; en-long now covered.")
log_change("released", "ticket-triage", "v1.4", "Mateo Silva", "2026-10-06", "Compact prompt with context line.")
print(changelog("ticket-triage").splitlines()[0])approved
2026-10-06 v1.4 released Mateo Silva: Compact prompt with context line.Pass checklist: the baseline is the current version on the updated set; the approver differs from the author; the log names the new version.
Summary
- Make a model-specific variant only when the fixed set shows a gap one text cannot close.
- Material changes need a reviewer who is not the author, a passing gate and a logged release.
- Write the rollback plan at release time; rolling back moves a pointer, then the failing case joins the set.
- Keep an append-only change log, a library with controlled tags, and test data without unnecessary personal data.
- State limitations with evidence, check readiness, and hand over with a generated document plus marked lines for judgement.
Next: Advanced Practice Lab: Compare, Validate, Harden, Regress and Red-Team, where you combine comparison, validation, hardening and regression testing.