DEV Community

Casey Sun
Casey Sun

Posted on

When a Cleanup Label Must Not Come from a Free Model

A night job once treated a customer export as scratch. The label came from a free model on a borrowed host. The worker removed that export before the first page.

The scene above is a composite, not a measured outage. This account did not run that cleanup job. The same shape still appears in shared cleanup paths.

What the path got wrong

The classifier had no written retention contract to follow. The worker trusted one string returned by the model. The host kept no durable record of that choice.

Deletion is a state change without a cheap undo. A free model lane can drift, throttle, or disappear. A free server can sleep, reset, or lose local disk.

Those limits are acceptable for a draft notebook. They are unacceptable for a live delete decision. The label and the delete must not share one hop.

Red flags

  • One prompt both labels a prefix and enqueues the delete.
  • The worker role can delete outside a reviewed name list.
  • Approval state lives only on the free host disk.
  • A model outage blocks restore, not only new labels.
  • Prefix rules live in a chat log, not versioned policy.
  • The job retries a delete after the label text changes.
  • Apply starts before a controlled signer accepts the plan.
  • Planned names are not checked against a fresh inventory.

Better alternatives

Keep drafter, signer, and applier on separate systems. A drafter may suggest names that look unused. A signer must accept the exact name list.

An applier may delete only those signed names. Place the signer on the audit system already in use. Place the applier on a host with a stable identity.

Retain the decision log outside the applier host. A free server may hold a scratch notebook. It must not hold the only approval record.

It must not hold the only applier binary either. A restart must not erase the reason a prefix died. Write that reason to the audit system before apply.

Four breaks that stack

Four breaks usually stack in this incident shape. Policy lived in a prompt instead of a repo file. The model lane and the delete role were joined.

The approval file sat on a disk that can reset. No inventory diff ran after the worker finished. Remove the joined role and a bad label cannot delete.

That split is the point of the local gate. The gate does not forgive a wide cloud role. Narrow the role even when every label looks correct.

A six-step workflow

Run this workflow on an owned account first. Keep the free lane out of steps three and four. Treat every step as required, not as optional.

  1. Export a prefix inventory from the owned account.
  2. Draft candidate names with a script or a model.
  3. Drop any plan that fails the local gate below.
  4. Require a signer to freeze the exact file hash.
  5. Apply deletes from an owned host with a retained log.
  6. Compare remaining keys with the signed inventory.

A model may help only in step two. Step two must not receive raw object bodies. Send prefixes and sizes, never secrets or payloads.

Run a local gate

The script below is an unexecuted local example. It does not call a model or a cloud API. It only rejects a JSON plan with unsafe labels.

Save the checker in the work directory as delete_gate.py. Save the two sample plans in that same directory. Run the three commands from that same directory.

python3 delete_gate.py bad.json; echo "exit:$?"
python3 delete_gate.py good.json; echo "exit:$?"
python3 -m unittest -v test_delete_gate
Enter fullscreen mode Exit fullscreen mode

A reject result is the expected outcome for bad.json. An accept result is the expected outcome for good.json. The first two exit codes are 2 and 0.

#!/usr/bin/env python3
"""Unexecuted example. Refuse a delete plan owned by a free lane."""

import json
import sys

FORBIDDEN_LANES = {"free", "free-model", "free_model"}
FORBIDDEN_HOSTS = {"free-server", "free_server", "ephemeral"}

def review(plan):
    errors = []
    lane = str(plan.get("classifier_lane", "")).lower()
    host = str(plan.get("applier_host_class", "")).lower()
    if lane in FORBIDDEN_LANES:
        errors.append("classifier lane must not be free")
    if host in FORBIDDEN_HOSTS:
        errors.append("applier host must not be free")
    if not plan.get("approval_ref"):
        errors.append("missing approval_ref")
    names = plan.get("object_names") or []
    if not names:
        errors.append("empty object_names")
    if len(names) != len(set(names)):
        errors.append("duplicate object_names")
    policy = str(plan.get("policy_version") or "")
    if (not policy) or policy.startswith("chat:"):
        errors.append("policy_version must be versioned")
    if plan.get("inventory_count") != len(names):
        errors.append("inventory_count mismatch")
    return errors

def main():
    if len(sys.argv) != 2:
        print("usage: python3 delete_gate.py plan.json")
        return 1
    with open(sys.argv[1], encoding="utf-8") as handle:
        plan = json.load(handle)
    errors = review(plan)
    if errors:
        print("reject")
        for item in errors:
            print("- " + item)
        return 2
    print("accept")
    return 0

if __name__ == "__main__":
    sys.exit(main())
Enter fullscreen mode Exit fullscreen mode

Sample plans

The bad plan sets classifier_lane to the free value. The host class is owned, and a change id exists. The gate still rejects the plan on lane alone.

The good plan uses owned for both lane and host. The object names are unique and match inventory_count. The policy tag is a version, not a chat link.

{
  "classifier_lane": "free",
  "applier_host_class": "owned",
  "approval_ref": "chg-1842",
  "object_names": ["scratch/job-1.tmp"],
  "inventory_count": 1,
  "policy_version": "retention-2026-10"
}
Enter fullscreen mode Exit fullscreen mode
{
  "classifier_lane": "owned",
  "applier_host_class": "owned",
  "approval_ref": "chg-1842",
  "object_names": ["scratch/job-1.tmp"],
  "inventory_count": 1,
  "policy_version": "retention-2026-10"
}
Enter fullscreen mode Exit fullscreen mode

A chat prefix on policy_version is a hard fail. Chat logs are not a retention contract for deletes. Put the contract in the repo, then cite the tag.

The shell block writes both plans in a temp directory. Copy the Python files into that directory first. Then run the three commands from the earlier block.

mkdir -p /tmp/delete-gate
cd /tmp/delete-gate
cat > bad.json << 'EOF'
{
  "classifier_lane": "free",
  "applier_host_class": "owned",
  "approval_ref": "chg-1842",
  "object_names": ["scratch/job-1.tmp"],
  "inventory_count": 1,
  "policy_version": "retention-2026-10"
}
EOF
cat > good.json << 'EOF'
{
  "classifier_lane": "owned",
  "applier_host_class": "owned",
  "approval_ref": "chg-1842",
  "object_names": ["scratch/job-1.tmp"],
  "inventory_count": 1,
  "policy_version": "retention-2026-10"
}
EOF
Enter fullscreen mode Exit fullscreen mode

Tests worth keeping

Save the test module locally as test_delete_gate.py. It imports the gate and checks four paths. The unit test command should report four passes.

import unittest
import delete_gate

BASE = {
    "classifier_lane": "owned",
    "applier_host_class": "owned",
    "approval_ref": "chg-1842",
    "object_names": ["scratch/job-1.tmp"],
    "inventory_count": 1,
    "policy_version": "retention-2026-10",
}

class ReviewTests(unittest.TestCase):
    def test_rejects_free_classifier(self):
        plan = dict(BASE, classifier_lane="free")
        errors = delete_gate.review(plan)
        self.assertIn("classifier lane must not be free", errors)

    def test_rejects_free_host(self):
        plan = dict(BASE, applier_host_class="free-server")
        errors = delete_gate.review(plan)
        self.assertIn("applier host must not be free", errors)

    def test_rejects_chat_policy(self):
        plan = dict(BASE, policy_version="chat:latest")
        errors = delete_gate.review(plan)
        self.assertIn("policy_version must be versioned", errors)

    def test_accepts_split_roles(self):
        self.assertEqual(delete_gate.review(dict(BASE)), [])

if __name__ == "__main__":
    unittest.main()
Enter fullscreen mode Exit fullscreen mode

Why the count check exists

The count blocks a silent truncated name list. A partial list can hide the export prefix. The gate only checks that the plan is self-consistent.

Pair the gate with a server-side list before apply. The applier must list the bucket, then compare names. A mismatch stops the job before the first delete.

Decision table

Use the table as a routing guide, not a score. A single failing row is enough to block apply. Do not average the rows into a soft pass.

Signal Free-lane draft Free-server apply Route
Suggest unused prefixes Yes, without object bodies No Draft only
Store the approval record No No Audit system
Delete signed names No No Owned applier
Retry after a new label No No New signed plan
Hold the only decision log No No External audit log
Scratch notes on policy Yes Scratch disk only Not the applier

Exit checks

Leave even the draft lane when any check below holds. Meeting one check means the team should stop. Move drafts back to a reviewed local script.

  1. Prompts must include raw object bytes or secrets.
  2. The team cannot keep a signer outside the model.
  3. Measured restore time already misses the recovery target.
  4. Policy text changes faster than the signed plan.
  5. The free host is the only place logs survive.
  6. A vanished lane would block a required export.
  7. The delete role can still act without the gate.

Do not weaken the gate to force an accept. Fix the plan, then run the same command again. A second label from the model is not a signature.

Limits of the gate

The gate reads labels that the plan itself supplies. It does not inspect cloud identity or bucket rules. A lying plan can still fake the inventory count.

Keep object versioning enabled on buckets that matter. Treat this file as a seatbelt, not a vault. The example was not executed in this draft.

Run the files on a laptop before trusting the exit codes. Confirm the four tests pass on that laptop. Then wire the checker into the owned apply job.

Who should skip this pattern

Skip the gate if no production delete job exists. A personal toy bucket with disposable files needs less process. Still avoid a wide delete role on a free host.

Do not use the file as a compliance substitute. Regulated retention needs the control that counsel already chose. This checker does not certify a retention program.

Skip free-lane drafts if prompts would hold secrets. A local rules file is the better drafter then. The free server is the wrong home for that rules file.

Where a free option can still sit

A free model lane can still draft a candidate name list. A person or an owned job must sign that list. The free server, if used, stays a scratch pad.

This draft uses MonkeyCode as the named free-lane option. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The operator describes free model access and a free server option.

This draft does not treat either offer as a quota promise. No model name, duration, or hardware claim is made here. Use that lane for unused-prefix drafts, not for the applier.

Confirm current terms before any workflow depends on it. If terms are unclear, keep drafts on a local script. The delete path should not wait on a vendor status page.

Top comments (0)