A Legal Hold Does Not Expire Inside a Prompt
A night operator approved a retention purge at 02:14 UTC. The plan came from a free model lane on a free server. A legal hold still covered one export bucket. The job deleted three prefixes before a human stopped it. Th
A night operator approved a retention purge at 02:14 UTC. The plan came from a free model lane on a free server. A legal hold still covered one export bucket.
The job deleted three prefixes before a human stopped it. This composite case is a teaching story, not a customer report. No vendor log is cited as evidence here.
The boundary that failed
Retention deletes are not ordinary drafting tasks today. They destroy data that a later audit may require. A model can summarize a written retention policy.
It must not release a legal hold by itself. Free model access is useful for cheap text drafts. A free server is useful for isolated sandbox runs.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. Those two options do not change the control owner. The approve step stays on an audited path.
The free lane may only prepare text notes. It never receives live delete credentials from ops. Any state change stays outside that free lane.
How the teaching case broke
The classifier saw the hold flag and still returned allow. The same run id was copied into the approver field. Production prefixes sat beside a short sandbox note.
No second person checked the hold service first. Delete credentials lived in the same draft environment. The window opened because the plan looked complete.
That sequence is the full anti-pattern here. A fluent plan is not an approval record. A green model score is not a hold lookup.
Red flags
Stop the job when any item below is true. One flag is enough to halt the run. The fix does not belong in the same prompt.
- The source lane equals free, or the field is missing.
- The approver kind is model, or it copies a run id.
- Any listed object still has legal hold set true.
- Hold expiry is blank on a regulated export prefix.
- A hold timestamp still sits in the future.
- The target is production or a shared staging bucket.
- Delete credentials sit beside the draft environment.
- One job both classifies holds and calls the delete API.
- No second human is recorded before the window opens.
- Someone asks a prompt to reinterpret a failing code.
A human clears the flag in the system of record. The same prompt must not clear that flag. A second lane records the human approver id.
Field contract
A purge plan needs a small and boring schema. Extra prose from a model is ignored by the gate. Unknown fields must not grant any access.
Missing required fields must fail closed instead. The checker below follows that closed rule. Operators keep the schema in version control.
Required plan fields
- The source_lane value must be audited or free.
- The approver_kind value must be human or model.
- The target_env value must be sandbox or production.
- The objects value must be a list of key records.
Required object fields
- The key is a relative prefix, never a credential URL.
- The legal_hold flag is a boolean from the hold service.
- The hold_until value is an ISO-8601 time, or null.
Decision table
Use this table before any purge command runs. Treat the rows as team policy, not model advice. The free lane loses every row that changes state.
| Signal | Free lane may | Audited path must |
|---|---|---|
| Policy summary | Draft plain language | Store the signed version |
| Hold lookup | Read a local fixture | Query the system of record |
| Plan JSON | Propose a shape | Reject or accept the shape |
| Credential use | Never receive a token | Issue a short-lived human token |
| Delete API | Never call it | Call only after two checks |
| Exception note | Suggest wording | File the note under a human id |
Mixing those columns recreates the teaching case. Draft text may move from left to right only. State change may not move back into the free lane.
Reason codes
Map each rejection to one fixed operator action. Do not invent a code that means model override. Override codes become the next quiet incident.
-
free_or_missing_lanemeans move the plan to the audited path. -
approver_not_humanmeans record a named human approver. -
target_not_sandboxmeans drop production keys from the draft. - A
holdcode means refresh that flag from the source system. -
hold_openmeans wait, or get a written counsel release.
A local checker
The script below is an unexecuted example only. It does not call a cloud delete API. It only accepts or rejects a local JSON plan.
#!/usr/bin/env python3
# Reject retention plans that a free lane must not approve.
import json
import os
import sys
from datetime import datetime, timezone
def parse_time(value):
if not value:
return None
return datetime.fromisoformat(value.replace("Z", "+00:00"))
def current_time():
override = os.environ.get("REVIEW_NOW")
if override:
return parse_time(override)
return datetime.now(timezone.utc)
def review(plan, now):
reasons = []
lane = plan.get("source_lane")
if lane in (None, "", "free"):
reasons.append("free_or_missing_lane")
if plan.get("approver_kind") != "human":
reasons.append("approver_not_human")
if plan.get("target_env") != "sandbox":
reasons.append("target_not_sandbox")
for item in plan.get("objects", []):
key = item.get("key", "?")
if item.get("legal_hold") is True:
reasons.append("hold:" + key)
expiry = parse_time(item.get("hold_until"))
if expiry is not None and expiry > now:
reasons.append("hold_open:" + key)
return reasons
def main():
plan = json.load(sys.stdin)
now = current_time()
if now is None:
print(json.dumps({"allow": False, "reasons": ["bad_review_now"]}))
return 2
reasons = review(plan, now)
if reasons:
print(json.dumps({"allow": False, "reasons": reasons}))
return 2
print(json.dumps({"allow": True, "reasons": []}))
return 0
if __name__ == "__main__":
sys.exit(main())
The operator saves the file as review_purge.py. The operator runs it only on local fixtures. Production credentials stay off that review machine.
Fixture commands
These commands are a test plan, not a measured run. This draft did not execute any of the commands. No live key belongs in either fixture file.
mkdir -p fixtures
cat > fixtures/bad_free_lane.json <<'EOF'
{
"source_lane": "free",
"approver_kind": "model",
"target_env": "production",
"objects": [
{
"key": "exports/2026-04/a",
"legal_hold": true,
"hold_until": "2026-12-01T00:00:00Z"
}
]
}
EOF
REVIEW_NOW=2026-10-08T00:00:00Z python3 review_purge.py < fixtures/bad_free_lane.json
echo "exit:$?"
The expected result is allow false and exit code 2. Reasons should name the lane, approver, target, and hold. A future hold time also adds hold_open.
The commands set REVIEW_NOW so the result ignores the wall clock. A bad override should fail closed with bad_review_now. After the sample date, keep the override before hold_until.
cat > fixtures/sandbox_human.json <<'EOF'
{
"source_lane": "audited",
"approver_kind": "human",
"target_env": "sandbox",
"objects": [
{
"key": "exports/scratch/b",
"legal_hold": false,
"hold_until": null
}
]
}
EOF
REVIEW_NOW=2026-10-08T00:00:00Z python3 review_purge.py < fixtures/sandbox_human.json
echo "exit:$?"
The second fixture covers a narrow sandbox pass. The expected result is allow true and exit code 0. That pass still deletes nothing on any host.
A later job on another host needs its own approval. The sandbox pass does not travel with the plan. Copying a green JSON file is not a new approval.
Where a free lane still helps
The free lane can still do low-risk text work. It must not cross into a state change. Only four tasks stay inside that safe limit.
- Turn a retention policy into short operator notes.
- Generate extra bad fixtures for this local checker.
- Explain a failed reason code to a new on-call reader.
- Diff two plan shapes before a human review meeting.
A free server option can host the checker and fixtures. The operator confirms current terms before any drill. Delete tokens never land on that host.
Better alternatives
Match the control to the data class in play. A free lane is not the default owner. Use one of the narrower paths listed below.
- Regulated exports stay behind the records system that owns holds.
- Ordinary application logs use a storage lifecycle rule.
- Scratch data stays in a sandbox bucket with no holds.
- Uncertain keys wait while a human receives the page.
- Model help stays on text, with no delete client installed.
A paid or internal control plane should own delete tokens. A free server should not store those tokens. A free model should not see them in a prompt.
Exit criteria
Leave the free lane when any line below becomes true. A real exit moves classification and approval together. Partial exits recreate the same control hole.
- The plan targets production or a shared staging bucket.
- A legal, finance, or security hold appears on any key.
- The checker and the delete client share one secret file.
- On-call expects the model to choose the purge window.
- Fixture tests fail, and someone wants a prompt override.
- Free-lane limits or uptime no longer match the drill.
- An auditor asks who approved a delete, and hears a run id.
Limitations
This checker trusts the JSON fed into stdin. A false plan can omit the legal hold flag. The script cannot see the real storage bucket.
It cannot prove the hold service is current. ISO-8601 is the only time form in this example. Other time forms should fail closed in a fork.
No quota, hardware, or uptime figure is claimed here. This account has no fresh primary measurement of those terms. Operators should read current product docs before a drill.
The opening story is a composite teaching case. It is not an incident record or a log extract. It is not a performance benchmark of any kind.
Who should skip this pattern
Some teams should not adopt this split yet. A checker cannot invent a missing process owner. A free model lane will not replace counsel.
- No second person exists to approve the purge.
- A storage lifecycle product already owns every delete.
- Counsel has not defined what a hold means.
- The only environment available today is production.
- Operators would rather prompt past a failing test.
A narrow next step
The operator copies the script into a scratch repo. The operator runs both fixtures on a free server. Live delete credentials stay off that sandbox host.
Teams can use MonkeyCode's free model access here. The free server option can host these fixtures. The approve path stays on an audited system.
If that split feels awkward, stop the drill. Awkwardness is the signal, not a prompt bug. Keep the next change on the audited path.
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.