Review the actual notice, not a promise to be careful
A workshop assistant drafts an announcement for Library Room. You want to inspect the audience and wording before anything is saved for publication. Approving “prepare the announcement” is too vague if the text can change afterward.
Input: {"tool":"save_notice","audience":"workshop","text":"Workshop venue: Library Room. Please confirm attendance."}. Finished output: SAVED: approved-notice.json; exact reviewed proposal. Open that file to inspect the same three values. A changed proposal, a rejection and a repeated save must not produce a second write. Nothing is emailed or published.
This lesson builds a small approval gate, code that checks a decision before allowing an operation. A tool call is a request to perform a named operation with specific inputs. The default replay uses a scripted proposal and scripted decision, not an AI model or a human approval. A second mode lets you make the decision yourself.
This is the permissions-and-approval track in our agent learning path. Edition 20 offered a read-only lookup; Edition 21 stored project facts. Here we prepare for tools that change something.
Why test what happens after the review?
Two public LangChain reports illustrate different problems. A report about edited actions being proposed again, opened November 2, 2025, describes a model returning to the original arguments after a person's edit. A report about rejected calls remaining executable, opened April 29, 2026, describes a rejection not removing a pending call. Both were closed when inspected on October 18. We read their descriptions, not independently reproduced their framework behavior. They are not evidence of current defects in every version.
Research question: Can we inspect a complete proposal, bind a local decision to those exact values, and prove that rejected, changed and repeated requests cannot save another artifact through this program?
For one announcement, writing it yourself is simpler. This exercise becomes useful when a future assistant proposes many drafts and you need a predictable review step. The gate does not decide whether Library Room is correct. That remains your responsibility.
1. Set up an empty folder
You need Python 3.10 or newer, a terminal and a text editor. Check python --version, using python3 instead if that is your system's command. Allow 25 minutes. No package installation, account, API key or private repository is needed.
Save the following as lab.py in an empty folder. The fictional sample is built in. The only allowed operation saves a local notice for one audience. There is no send, shell-command or arbitrary output-path field.
2. Run the complete replay
Run python lab.py. The first checks use a disposable directory. The main replay then creates your artifact in the current folder and refuses to overwrite an existing one.
import argparse
import json
from pathlib import Path
import tempfile
SAMPLE = {"tool": "save_notice", "audience": "workshop",
"text": "Workshop venue: Library Room. Please confirm attendance."}
OUTPUT = Path("approved-notice.json")
def canonical(proposal):
if type(proposal) is not dict or set(proposal) != {"tool", "audience", "text"}:
raise ValueError("expected exactly tool, audience and text")
if proposal["tool"] != "save_notice" or proposal["audience"] != "workshop":
raise ValueError("only save_notice for workshop is allowed")
text = proposal["text"]
if type(text) is not str or not text.strip() or len(text) > 160:
raise ValueError("text needs 1 to 160 characters")
if any(not 32 <= ord(char) <= 126 for char in text):
raise ValueError("demo text must be printable ASCII on one line")
return json.dumps(proposal, sort_keys=True, ensure_ascii=True)
def unique(pairs):
result = {}
for key, value in pairs:
if key in result:
raise ValueError("duplicate JSON key")
result[key] = value
return result
def load(path):
with path.open("rb") as handle:
raw = handle.read(4097)
if len(raw) > 4096:
raise ValueError("proposal exceeds 4096 bytes")
proposal = json.loads(raw.decode("utf-8"), object_pairs_hook=unique)
canonical(proposal)
return proposal
class Gate:
def __init__(self, proposal):
self.snapshot = canonical(proposal)
self.decision = None
self.used = False
def decide(self, word):
if self.decision is not None:
raise ValueError("review already closed; start a new review")
self.decision = "SAVE" if word == "SAVE" else "REJECT"
def save(self, current, output):
if self.decision != "SAVE" or self.used:
raise ValueError("no unused approval")
if canonical(current) != self.snapshot:
self.decision = "REJECT"
raise ValueError("proposal changed; start a new review")
with output.open("x", encoding="utf-8") as handle:
handle.write(self.snapshot + "\n")
self.used = True
def checks():
invalid = [[], {**SAMPLE, "approved": True},
{**SAMPLE, "tool": "send_notice"},
{**SAMPLE, "audience": "everyone"},
{**SAMPLE, "text": ""}, {**SAMPLE, "text": True},
{**SAMPLE, "text": "x" * 161},
{**SAMPLE, "text": "hidden\x1b[2J"}]
for proposal in invalid:
try:
canonical(proposal)
except ValueError:
pass
else:
raise AssertionError("invalid proposal accepted")
with tempfile.TemporaryDirectory() as folder:
output = Path(folder) / "notice.json"
pending, denied, changed = (Gate(SAMPLE) for _ in range(3))
denied.decide("REJECT")
changed.decide("SAVE")
for gate, proposal in [(pending, SAMPLE), (denied, SAMPLE),
(changed, {**SAMPLE, "text": "Different venue"})]:
try:
gate.save(proposal, output)
except ValueError:
pass
else:
raise AssertionError("unapproved write accepted")
assert not output.exists()
return len(invalid) + 3
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--review", type=Path)
args = parser.parse_args()
try:
print(f"NEGATIVE CHECKS: {checks()} passed")
proposal = load(args.review) if args.review else dict(SAMPLE)
gate = Gate(proposal)
print("REVIEW: " + gate.snapshot)
if args.review:
try:
word = input("Decision [SAVE/REJECT]: ")
except EOFError:
word = "REJECT"
else:
print("SCRIPTED decision: SAVE; no person or model was consulted")
word = "SAVE"
gate.decide(word)
if gate.decision != "SAVE":
print("NOT SAVED: review declined or input was not SAVE")
return
current = load(args.review) if args.review else proposal
gate.save(current, OUTPUT)
print("SAVED: approved-notice.json; exact reviewed proposal")
try:
gate.save(current, OUTPUT)
except ValueError:
print("REPEAT BLOCKED: approval already used")
else:
raise AssertionError("approval reused")
print("No message was sent and no website was changed")
except (ValueError, OSError) as error:
parser.error(str(error))
if __name__ == "__main__":
main()
Expected complete stdout:
NEGATIVE CHECKS: 11 passed
REVIEW: {"audience": "workshop", "text": "Workshop venue: Library Room. Please confirm attendance.", "tool": "save_notice"}
SCRIPTED decision: SAVE; no person or model was consulted
SAVED: approved-notice.json; exact reviewed proposal
REPEAT BLOCKED: approval already used
No message was sent and no website was changed
Open approved-notice.json. Its single line must match the JSON following REVIEW:. It contains the tool, audience and text, not an assertion that anyone approved the fictional demonstration. The replay labels its decision as scripted for that reason.
3. Understand the three separate checks
First, validate the proposal. Its fields must match a small contract exactly. An extra approved field cannot grant permission. Unknown audiences and tools are rejected. The one-line ASCII restriction keeps this beginner preview small and prevents terminal control characters from hiding text. It deliberately excludes accented and non-Latin text; it is not a suitable global publishing policy.
Second, keep the reviewed values. canonical converts the validated proposal into a consistently ordered JSON string. That string is the snapshot, a saved representation of what was displayed. Comparison ignores JSON key order and spacing, but not different text. We compare these short values directly; a cryptographic hash is unnecessary here.
Third, enforce the decision at the write. Pending and rejected gates cannot save. A changed proposal invalidates the review. After one successful save, the gate is marked used. The output file's exclusive creation mode also prevents overwriting an earlier artifact. A proposal does not choose this path; trusted program code does.
The eleven negative checks cover malformed fields and pending, denied and changed writes. The replay separately attempts a repeat after success. Tests inspect file creation, not merely a friendly “rejected” message.
4. Make the decision yourself
Use another empty folder containing lab.py, so you preserve the replay artifact. Save this as proposal.json:
{"tool":"save_notice","audience":"workshop","text":"Workshop venue: Library Room. Please confirm attendance."}
Run python lab.py --review proposal.json. Read the complete REVIEW: line. At Decision [SAVE/REJECT]:, type SAVE and press Enter to create the file. Any other response declines. An unavailable input stream also declines. This exact-word rule avoids interpreting “probably fine” as permission.
The program rereads the proposal before saving. To reproduce a changed-input failure, leave the prompt waiting, edit Library Room to Harbor Hall in another editor and save proposal.json. Then type SAVE. The command must fail with “proposal changed,” and no output artifact should exist. Restart, inspect the revised proposal and decide again. Do not carry the first approval forward.
Repeat in another clean folder and type REJECT. Expect NOT SAVED, with no artifact. Changing the input later does not reopen that finished process. Review state is deliberately not persisted; every new invocation needs a new decision. Keep useful files rather than deleting them to force a demonstration to work.
5. Apply it to a future assistant
Optionally ask a model you already use to propose a workshop notice using exactly the three sample fields. Save its response without Markdown fences, run review mode and inspect the wording. That manual model step is not executed or evaluated in this edition. The model proposes; you review; Python checks and saves. It has no publishing credentials.
For your own task, change the audience allowlist, permitted operation and length policy deliberately. Show every value that affects the operation. If a real tool also reads a template, attachment or destination configuration, bind those dependencies to the review too. Comparing only the visible text would not cover a changed attachment.
LangChain's human-in-the-loop documentation describes pausing tool execution for approval, editing or rejection. Our code implements no LangChain API. OWASP's transaction-authorization guidance emphasizes meaningful transaction details, protected state and checks at execution. This local exercise illustrates only a subset of that guidance, not a compliant authorization service.
Troubleshooting, limits and completion
“File exists” protects an earlier output. Use a fresh folder and inspect the retained file. Duplicate JSON keys, extra fields, empty text and hidden control characters require correcting the proposal, not loosening the gate. The loader reads at most 4,097 bytes and rejects anything above 4,096. Invalid JSON cannot become an approved notice.
This is trusted, single-process teaching code. A person or agent able to edit the program, modify its memory or write directly to the folder can bypass it. It authenticates no reviewer, enforces no operating-system sandbox and implements no approval expiry, revocation service or durable audit history. A crash can leave an incomplete file. Inspect it manually; do not assume a retry is safe. A local filename is not an exactly-once guarantee for an external publication.
Production needs authenticated reviewers, protected decision storage, bounded approval lifetimes and an execution service the model cannot bypass. Adding a second model to say “looks safe” supplies none of those controls. Keep this example offline and use fictional data.
Completion check: match complete replay output, inspect the saved JSON, perform one manual acceptance, one rejection and one changed-input failure. Explain why the word approved inside a proposal is not authority. Next task: collect five realistic notices and record corrections, rejection reasons and review effort before considering any sending integration.
Technical references checked October 18, 2026: Python's JSON decoder, including duplicate-key hooks, and file-opening modes, including exclusive creation. Code and fictional notice are original educational material.