macOSSwiftPier

Prefer a Miss Over a Wrong Kill — A Fail-Closed Cleanup Grading Engine

A Pier 3.0 postmortem: one-click cleanup only includes 'green' items, and for a process to go green it has to clear a whole row of hard guards at once. On designing deterministic rules, why green defaults to 'not qualified,' and a list of 'safety invariants' that pin these rules as release gates.

Pier 3.0’s one-click cleanup only touches “green” items — dev residue Pier is confident it can safely stop. The whole product’s safety rests on the question “what earns a process a green rating.” This post is about that Safety Engine’s grading logic, and the core idea is one sentence: green defaults to “not qualified”; to go green, a process must clear a whole row of hard guards at once, and any single one it fails sends it back to yellow or red.

Why deterministic rules, not a model

First the choice: the MVP uses deterministic rules, not machine learning.

The reason is the same as the approval gates for letting an AI type commands — a safety judgment has to be unit-testable, explainable, and reproducible. An ML classifier saying “this process is 87% cleanable” gives you no way to explain that 87% to a user, and no way to write a test pinning “a database never goes green.” Deterministic rules are the opposite: each guard is a visible piece of logic, and the verdict emits a set of stable reason codesknown_dev_server, project_cwd, no_live_connections, database_protected, system_owned, missing_restart_command, editor_still_running — which both localize into plain language for the user and can be asserted one by one in tests.

Green’s hard guards: qualify only if all hold

Of the three colors, red and yellow are “easy in” — any hint of suspicion drops it there. The hard one is green, which is “strict out”: every one of the following hard guards must hold simultaneously, and missing one means out:

  • belongs to the current user, not root or a system account;
  • cwd is in a user project directory;
  • matches a known stateless dev server or watcher pattern;
  • is not a database, queue, persistent container, LaunchAgent, editor, AI agent binary, or system service;
  • no established connections in the sampling window;
  • both recent CPU and network activity are low;
  • sufficient restart evidence: cwd and the full command are available.

Note the shape of this row: it isn’t “collect enough positive signals to pass,” it’s “any single negative signal is a veto.” Database, live connection, system account, missing cwd — each is a hard block. This is exactly what “prefer a miss over a wrong kill” looks like translated into code: set the default to deny and make evidence unlock it one guard at a time, rather than setting the default to allow and then looking for reasons to block.

A few hard rules hidden in the details

The real difficulty of the guards is at the edges, a few rules pinned down by real-world testing:

Only a kernel-argv-backed service fingerprint qualifies for green. Identifying “this is Vite / Next.js” comes at two evidence strengths: the kernel-captured executable path is high-confidence, while the command line, a process-rewritten title, and a regex hit are all low-confidence — the latter can only be a yellow hint. Because a green item later has to restart the service using that command, and the short command from batch ps, the flattened command, simply isn’t enough to restart. Incomplete restart evidence means no green.

AI attribution accepts only exact evidence. A claude or cursor string in the project path produces no attribution. An agent binary is accepted only by exact executable basename or App bundle evidence. And Claude Code, Codex, Cursor — those agent binaries are always protected — but the node, python child services they launch are judged on their own evidence, not protected by association because “the parent is an agent,” and not promoted to green for that reason either.

Parent still alive means yellow at most. A green candidate’s process-tree root must already be orphaned or reparented to launchd. If the parent terminal, editor, or coding agent is still alive, Pier can’t decide for the user whether “this round of development is actually over” — so it’s yellow, for the user to confirm.

A package script can’t go green on a port. An npm / pnpm entry is especially dangerous because a package script can run arbitrary user code. It earns green evidence only when it holds no listening port itself, is same-user same-project, has a fully orphaned process tree, and that tree has a verified stateless listening descendant. A shell is always yellow; build helpers like esbuild / swc must additionally come from the current project’s node_modules path to go green.

Release-level safety invariants: pinning rules as gates

The most interesting output of this logic is a list of “safety invariants” — not optional optimizations, but 3.0 release gates: break any one and you don’t ship. A few that best show the thinking:

  • Raw data source ≠ display data. ProcessScanner can read all system processes, but it’s only raw data. The home page’s memory, counts, and list only tally the dev candidates that passed through the DevResourceBuilder boundary filter. Ordinary Documents or Downloads directories can’t enter the cleanup home page just because “there’s a process in there”;
  • Owner must come from the real user field. Take a process’s owner from ps’s real user; never fall back a root process whose port info you couldn’t read into the current user — that would disguise a system process as cleanable;
  • A single source of truth. The green rules for one-click cleanup have exactly one source of truth: SafetyEngine. The action layer may add realtime guards, but may not copy and loosen the green rules — once safety logic is duplicated, the two copies drift, and one of them eventually gets “conveniently” relaxed;
  • Top-level stats dedup by resource ID. A process tree’s guard nodes are referenced by several views; memory and counts must not double-count them;
  • A diagnosis failure must fail explicitly. When data can’t be fetched, show a distinct failure state, never 0 B or “no residue” — disguising “I couldn’t check” as “it’s clean here” is the most dangerous kind of lie.

That last one is the essence of fail-closed: when uncertain, the system must explicitly express “I’m not sure,” not optimistically pretend it’s safe. A cleanup tool that renders “query failed” as “all clear” is more dangerous than one that doesn’t pretend at all.

Takeaways

  • Set the dangerous default to “deny.” Green (cleanable) defaults to not-qualified, unlocked by evidence one guard at a time — not default-allow-then-find-reasons-to-block. Hand the veto to any single negative signal and “prefer a miss” holds automatically;
  • Grade evidence by strength, don’t treat it all alike. Kernel argv is high-confidence, a command-line regex is low-confidence; high-risk actions (entering one-click cleanup, restarting from it) accept only high-confidence evidence, and low-confidence gets demoted to “needs confirmation”;
  • A single source of truth is uncopyable. Keep exactly one authoritative implementation of the safety rules; other layers may only tighten, never loosen. The duplicated second copy of safety logic is the one that gets quietly relaxed in some future “optimization”;
  • Fail-closed includes “failing honestly.” Query failure, missing evidence, unknown state — all must explicitly express uncertainty, not get papered over with optimistic empty values (0 B, “no residue”). Disguising uncertainty as safety is the one mistake a cleanup tool should never make.

The next post covers a more dynamic layer: the green verdict uses a snapshot from the moment of diagnosis, but between the user clicking cleanup and the signal actually firing, the world keeps changing — how to stop a target that might grow a new connection or a new child process at any moment.

Comments

  • Loading…

Comments are reviewed before publishing; email is visible only to me.