valis / Understanding the system
Condition handling: discriminate, recover, never mask
How valis handles failure. The short form: define a condition, bind a handler that can tell one cause from another, and offer a named way to recover. A handler that cannot discriminate between two causes is not error handling: it is a decision to stop looking.
This document is the convention every valis subsystem is written to, and the contract the sister repos adopt in their own trees. Read ARCHITECTURE.org for where the subsystems sit; this file is about how they fail.
The failure mode this exists to prevent
A blanket handler around a body that can fail in several ways collapses every one of those failures into a single value. The caller receives something plausible, so nothing upstream reports a problem. The defect does not surface as a crash: it surfaces later, somewhere else, as data that is quietly wrong.
;; What this actually says: "if anything at all goes wrong, the answer is NIL."
;; A missing zone, a dead connection, a bug in the parser, and a typo in a
;; symbol name are now indistinguishable, to the code and to the operator.
(ignore-errors (load-zone origin))
The cost is not the swallowed condition; it is the lost distinction. A transient socket timeout and a corrupt durable block demand opposite responses: retry the first, refuse and alarm on the second. Code that cannot tell them apart cannot do either, and will always take the path that looks like success.
This is the shape to hunt for in review: not "is there a handler," but can this handler distinguish the causes it is catching, and does the caller learn which one happened.
The rule
- Define a condition class for a failure the caller might reasonably act on.
valis already carries 144 of them (
block-corrupt,capability-refused,mail-open-relay-refused,missing-apex-soa, …). Signal the specific class, notcl:errorwith a formatted string: a string cannot be dispatched on. - Prefer
handler-bindtohandler-casewhen you need to observe a failure.handler-bindruns its handler in the signalling context, before the stack unwinds, so a backtrace taken there names the frame that actually failed.handler-caseunwinds first and hands you a condition with the evidence already destroyed. - Offer a named restart for the recovery the caller wants. A restart makes the recovery an explicit, named decision at the site that understands the consequences, instead of an invisible default buried in a handler.
- Never mask.
ignore-errorsand a bare(handler-case … (error () nil))are prohibited insrc/. If a failure genuinely is ignorable, that is a claim requiring a written reason, see Suppressions. This is about handling; guaranteeing cleanup is a separate concern with its own idiom, see Cleanup is not masking. - A swallowed failure must still be loud. Recovering from a condition and logging nothing is the same silence as masking it. Log the real condition class, not a rendered message, so the log can be searched by type.
The rule is about discarded causes, not about handler clauses
The five rules above are written in the vocabulary of handlers, and I scoped them too narrowly. What I am prohibiting is destroying a cause that was available. A handler clause is one place that happens, and it is not the interesting one, because it is the place everybody already watches.
Here is the same violation with no handler anywhere in it:
(multiple-value-bind (out err code) (ssh-exec node ...)
(declare (ignore err code)) ; the exit status is discarded HERE
... (or out "") ...) ; and a plain value goes out in its place
That is (handler-case … (error () nil)) written in return values. The cause was
present, it was named, and it was thrown away. No handler-case appears, so
nothing here was looking at it: not this document, and not no-ignore-errors,
which fires on a spelling and would not have seen this shape even if one had.
The cost was not hypothetical. A predicate in
proving-ground/driver/liveness.lisp shelled out and reduced the result to a
boolean, so it answered NIL for a thing that was genuinely absent and NIL for an
ssh that never connected. Under refutation a NIC was reported moved out of a host
nobody had reached.
So read every rule above as governing the cause, wherever it is discarded. An
exit status, an error string returned beside a value, a nil standing in for an
answer that was never obtained: these are the same defect as a bare handler, and
they are harder to see precisely because no keyword marks them.
⛔ Do not answer this with a text scan for the spelling. Whether a cause survived is a property of meaning, and a guard written in the vocabulary of spellings catches only the spelling you already thought of. I wrote four candidate predicates for the blanket-handler shape and a real site in this tree refuted every one, because the channel by which a cause reaches its reader is unbounded: re-signalling, a returned value carrying the condition, even a condition handed across a thread boundary in a mailbox. Keep a scan if you like, as a tripwire that is never counted as the guarantee.
The protection that works is the one rule 1 of the next section already names, so
apply it here rather than reading it as being about data alone: I made the
reading itself carry whether it was taken. observe-on-guest answers an
observation that holds the refusal when the guest was never reached, and
transport-refusal is the single place a status becomes a type. A predicate
built on that inherits the refusal instead of reimplementing the check, and a
question asked of a reading that never happened cannot quietly answer off an
empty string.
⚠ Where a typed condition for the event already exists, converge on it rather than inventing a second vocabulary beside it. Two encodings of one event drift, and the drift is invisible at the seam where they meet.
Types are this same rule, applied earlier
Everything above discriminates a failure precisely once it exists. Types are the same discipline moved one step earlier: they make a class of failure unrepresentable, so there is nothing left to discriminate. Rule 1 already makes this argument about failures, and the reason it gives is the whole of the case: a string cannot be dispatched on. That sentence is just as true of data.
Reach for them in this order.
- Make it unrepresentable. A value that cannot hold the wrong thing needs no check, no condition class and no handler. Strictly better than detecting the wrong thing well.
- Signal a specific condition for what the type system cannot express, per the rule above.
- Handle narrowly, at a site that understands the consequence.
The counter-pattern: flattening to a universal representation
⛔ Do not flatten a value to a common representation in order to make an error go
away. Coercing everything to simple-string, or storing everything in a text
column, removes the symptom and the information together. What is lost is the
domain distinction, and the cost is paid later and elsewhere: every consumer
recovers the distinction by inspecting characters, and those inspections
disagree.
This is the data-shaped form of the failure this document exists to prevent. A blanket handler passes because it cannot tell cases apart. A flattened type passes for exactly the same reason.
Where a coercion IS correct
At a foreign boundary that genuinely demands a representation, and there only. A
library that accepts only a simple-string is someone else's requirement, and
adapting to it belongs at that call, narrow and named, not upstream where it
would spread the assumption through code that had no such requirement. One
question settles it: is this coercion serving a real external constraint, or is
it making an error message stop?
Worked example: a nameserver recipient
A DNS NOTIFY recipient is legitimately either a name or an address. It was
stored as text, with a column-name convention and a runtime audit rule
carrying the type information the schema did not. The value arrived as a
non-simple string, passed unchanged through a normaliser (string-trim returns
its argument eq when there is nothing to trim), passed through a codec that is
representation-agnostic, and was refused at the socket boundary by a foreign
function that accepts only a simple string.
Three layers handled the value and only the last had an opinion about its type. The feature was built, wired, armed, and had never once succeeded.
⚠ The detail that makes the case: the one recipient shape that came through
clean was the dotted IP literal, because that was the only path that happened to
run subseq. The ordinary hostname carried the hazard. That is what
discrimination-by-string-inspection buys, and coercing the recipient upstream
would have made every shape uniformly clean and uniformly untyped, hiding the
finding rather than answering it.
The correct fix was one named adapter at the foreign call that had the requirement, with nothing above it changed.
Choosing the shape
| You want to | Use |
|---|---|
| Observe a failure, then let it propagate | handler-bind that logs and declines (falls through) |
| Recover, and the caller picks how | restart-case + a named restart, invoked from a handler-bind |
| Recover locally, one obvious response | handler-case on a specific condition class |
| Catch every error and continue | Almost certainly wrong: say why in a suppression |
Note the asymmetry in rule 3: the handler-bind decides to recover by
invoking a restart, but the restart-case that defines the recovery lives at
the site that knows what recovery means. That separation is the point.
Cleanup is not masking
The rule above is about handling conditions. Guaranteeing cleanup is a different concern, and the two are easy to conflate because both appear near failing code.
unwind-protect with an unconditional cleanup form is the correct idiom and is
never discouraged here. It does not handle a condition at all: it releases a
resource while the condition continues to propagate untouched. Nothing is
swallowed, no distinction is lost, and the caller still sees the original
failure. This is how sockets, file descriptors, locks, and database connections
should be released, and valis uses it in ~70 places. The linter has no rule
against it.
;; Correct. The condition still propagates; the socket is released either way.
(let ((sock (open-socket host port)))
(unwind-protect
(talk-to sock)
(close sock)))
The narrow exception: masking inside a cleanup form
A cleanup form is the one place ignore-errors earns its keep:
(unwind-protect
(serve-session session)
(ignore-errors (close-session session))) ; legitimate
The reasoning is specific, and it does not generalise. During an unwind there is no caller left to act on a secondary failure, and worse, a condition signalled from a cleanup form displaces the original condition, replacing the failure you need to diagnose with a less informative one from the teardown path. Closing an already-dead socket is the common case: it fails precisely because the thing you actually want reported has already gone wrong.
So the test is position, not shape. (ignore-errors (close x)) in a cleanup
form is correct. The same expression in a protected form is masking: it discards
a distinction someone still could have used.
Of the 115 sites frozen in the baseline, 85 are cleanup-position closes of
exactly this kind, about three quarters. Those are not debt and should not be
rewritten into handler-case; they should be suppressed at the site with the
reason stated, and they do not count toward the retirement of the file. Retiring
a listed file means resolving the masking sites, not the cleanup ones. The real
masking debt is 16.
Position is necessary but not sufficient
A cleanup-position ignore-errors wrapped around a call that cannot succeed
is not cleanup. It is a permanently broken teardown wearing cleanup's clothing,
and it is invisible precisely because the masking looks legitimate.
valis shipped one, and I am keeping it here after the repair because the shape is
the lesson. The resident's failed-push cleanup, in %bring-up-declaring-socket
(src/main.lisp), read (ignore-errors (close listen-fd)) in an error path:
textbook cleanup position. But listen-fd is a bare integer descriptor dup'd out
of an iolib socket, the file is (:use #:cl), so that was cl:close, and
(close 7) signals a type-error. The call never once succeeded, the descriptor
was never released, and the ignore-errors was hiding its own cleanup failing.
The repair gave the release a name of its own. close-listening-fd
(src/backends/epoll.lisp) is the exact counterpart of make-listening-fd: it
issues the close(2) syscall on the bare integer, and it keeps iolib confined to
the backend, so a consumer that mints a LISTEN descriptor can release one without
importing iolib and without shadowing close in its own package. The cleanup
site now calls that, and the mask over it is a genuine cleanup mask, suppressed at
the site with the reason stated: a close error there must not displace the push
error being re-signalled.
So add a pass to the triage: for every site classified as cleanup, check that the cleanup call can actually succeed. Match the argument's type against what the function accepts. This is where descriptor- and handle-heavy code needs the most scrutiny, and it is the one check a mechanical position rule will skip.
And the callee must be knowable. The test is three-part, not two: cleanup position, a cleanup call that can succeed, and a checkable callee. Consider
(when teardown (ignore-errors (funcall teardown))) ; in a cleanup form
Position clears it. "Can the call succeed?" has no answer: teardown is an
opaque closure supplied by the caller, so the question resolves to nothing and
any bug anywhere inside any teardown closure is swallowed permanently and
invisibly. A cleanup-position mask over funcall, apply, an open generic
dispatch, or any caller-supplied hook is the shape where both earlier tests pass
and the mask is still unbounded. Narrow what such a site catches; do not bless
it.
Verifying a masked call requires calling it unmasked. A passing test suite
proves nothing about a masked cleanup: an always-signalling call inside
ignore-errors leaves the suite green, which is exactly the invisibility that
hid the resident's broken descriptor release. To establish that a masked cleanup
call works, drive it bare, outside the mask, against a live object and observe
that it returns.
That cuts deeper than it first appears. A mask does not only hide a defect that exists; it hides the absence of a guard against one that has not happened yet. If the teardown behind a mask were to break tomorrow (through a package change, a dependency upgrade, a refactor), no test would notice, because the mask swallows the breakage. So the question for a cleanup site is not only "does this work today" but "would anything fail if it stopped working." Where the answer is no, the guard is missing regardless of whether the code is currently correct.
The gate does not see inside macros
no-ignore-errors skips defmacro outright. The whole form is skipped (the
expander's own body and the expansion template alike), so a cleanup mask written
anywhere inside a defmacro is neither reported nor suppressible. It is simply
unenforced, and the gate's count will disagree with a plain grep by exactly those
sites.
State the coverage as a negative: everything except defmacro is descended
into. A positive list invites drift: defmethod (qualified or not),
defgeneric, define-compiler-macro, macrolet, labels, flet and bare
top-level forms are all checked, and any list of them written here will be
missing one. There is a single skip branch, so the negative form cannot develop a
hole the way an enumeration can.
Three properties of that skip are easy to get wrong:
- Backquote is not the trigger. A backquote inside a
defunis checked normally. The reader yields quasiquote structure, notcl:quote, so nothing about backquote reaches the skip: adefmacrowith no backquote at all is skipped just the same. - The skip is per-node, not top-level. A
defmacronested inside adefun, aprognor aneval-whenis skipped identically, so "my sources have no top-leveldefmacro" does not establish coverage. Check unanchored. quoteis a second skip category. A masking form inside'(…)is not examined either. Defensible (that is data, not code), but it is a second way a site goes unseen.
The macro hole is the smaller one. no-ignore-errors fires on exactly one
head: ignore-errors. It never examines handler-case at all, in any form,
macro or not. So (handler-case x (error () nil)) is not partially gated or
heuristically gated: it is outside the rule entirely, and no configuration
reaches it. Every count this gate reports is a count of a spelling, not of the
defect; and the rule's own message points a reader at the ungated form. Closing
that needs a new rule, not a tuning of this one.
Scope, and the two halves differ. The defmacro skip is written into
no-ignore-errors itself, so it says nothing about any other rule: each
implements its own form handling, and a future rule written to catch the blanket
handler-case will skip defmacro only if its author writes that branch again.
The quote skip is different: it lives in the shared traversal guard, so it
applies to every rule built on it. Do not generalise the first; do expect the
second.
Cross-check your own tree, per file, never in aggregate. A whole-tree total hides the gap behind offsetting differences and reads as clean.
A delta is not a finding until every site in it is attributable. Three
different things produce one, and only the first is a blind spot: a template
site the gate cannot see, a site you suppressed at the site, and a site frozen
by a path block. The latter two are your own configuration working correctly,
and a naive reading of the delta reports them as a coverage hole. Take the
measurement against a neutral configuration first (that separates the causes in
one step), then account for every remaining site against a template you can
point at. valis's 72 were confirmed this way: identical counts under a neutral
config, and the masks in the shared test helper sit inside defmacro forms,
which the gate skips whole. Those particular masks happen to sit in expansion
templates, but that is incidental: the enclosing defmacro is what makes them
invisible, and a mask in the expander's own body would be just as unseen.
In valis, src/ is fully covered: 115 counted directly, 115 reported, zero
per-file delta. tests/ is not, and it is not close: 466 counted against 394
reported, 72 sites the gate cannot see, spread across 26 files. That has no
practical effect today because tests/** is scoped off anyway, but the coverage
claim has to be stated per tree rather than as one number.
Test scaffolding is where this concentrates, which is the worst place for it:
tests/support.lisp reports 0 against 4, and a shared sealing-client helper 0
against 1. Setup and teardown wrapped in a with-… macro is exactly where
masking accumulates and exactly where it is least often reviewed.
Two consequences worth stating plainly. First, planting a probe in ordinary
top-level code proves the file is linted and says nothing about macro
coverage. Plant one inside a defmacro if you intend to claim the latter. A
probe inside a backquote does not test the skip: in a defun it reports like any
other site, so it demonstrates only what the ordinary probe already did. Design
the control against the thing that actually decides (the enclosing defmacro)
and confirm it stays silent, with a second cell outside the macro confirming the
rule fires at all. A silence you cannot distinguish from a rule that never ran is
not evidence.
Second, do not restructure a macro to make its cleanup visible to the linter.
The code is correct as written; contorting it to satisfy a blind spot is the
same error as stripping colons to move a count. Document the gap; do not
engineer around it.
A collision that hides in package boilerplate
That resident cleanup site reached cl:close where a descriptor-specific close
was meant, because the file is (:use #:cl) and nothing shadowed the symbol.
Any domain verb colliding with a CL symbol has this exposure: open, close,
read, write, listen, load, delete, merge, get, map, find,
remove, replace, search, count, position.
Where a :shadowing-import-from is what makes the domain verb win, that single
declaration is all that stands between correct behaviour and a type error landing
next to cleanup masking, in files whose tests stay green. Ask the image what the
symbol resolves to in each package rather than reading the defpackage and
assuming.
The better question is the second-order one: what would have to change for that
resolution to change? A shadowing import is one deleted line away from silently
re-pointing every call in the file. Two shapes have no such line: fully
qualifying the call at each site, and (where the semantics genuinely are "close
this thing") extending the standard generic with a defmethod on cl:close
rather than shadowing it. Then the domain behaviour is reached through the
standard symbol, dispatch does the work, and there is no declaration whose
removal could redirect it.
But that second shape has the opposite fragility, and it is the more dangerous
one. Where a defmethod on cl:close is what makes an interposed behaviour
work (a shield over a borrowed descriptor, say, whose close is deliberately
a no-op so a layered teardown cannot close a socket it does not own), the method
dispatches because the symbol is cl:close. Adding a (:shadow #:close) to
that package silently defines the method on a new symbol. The interposition
detaches, the caller's cl:close finds no applicable method, the default runs,
and the borrowed descriptor is closed by code that had no right to close it.
So there are two directions, both of which look like package boilerplate and both of which are load-bearing:
| Shape | Fragility |
|---|---|
Domain verb wins via :shadowing-import-from |
The declaration must not be removed |
Behaviour interposed via defmethod on the cl generic |
A shadow must not be added |
Audit for both. "Which of my collisions depend on a shadow being present, and which depend on there being none?" A reviewer who has internalised only the first will wave the second straight through.
Three categories, not two
The frozen list is not one thing, and conflating its parts leads either to blind rewrites or to silent blessing:
- Unexamined: not yet triaged. Freeze. The honest claim is "we have not looked at this."
- Examined, not known defective: a masking shape that is behaviourally correct today. Freeze, with the diagnosis and intended fix recorded. The claim is "examined, not yet converted." Converting blind is worse than deferring: a fix prescribed by shape rather than by reading the site is how a correct rejection becomes a silent acceptance.
- Examined, defective: a demonstrated bug. Fix it. Never freeze, never suppress. Separate commit, with a test that fails without it.
A conversion that can be made observably neutral should just be done. Where it cannot, freeze as category 2 and say so. Note that a semantics-preserving conversion does not get a "fails without the fix" test, and claiming one would misrepresent what changed: regression protection, labelled as such, is the correct artifact there.
Reference implementation
The post-commit serving-refresh isolation is the worked example. A structured owner edit commits its row and SOA serial bump inside the transaction, then fires a post-commit hook that rebuilds the served view. The hook is isolated from the edit's acknowledgment: a refresh that signals must never make a committed, durable edit report as a failure to its owner.
See src/edge/dns-controller.lisp (%refresh-serving-index) and
src/operator-state/zones.lisp (the skip-committed-hook restart).
Two details there are easy to get backwards, and both are deliberate:
The nesting order is load-bearing
%refresh-serving-index puts a handler-bind inside a handler-case:
(handler-case ; outer: makes the retry / give-up decision
(handler-bind ; inner: runs FIRST, in the signalling context
((error (lambda (c) (log-the-live-backtrace c))))
(funcall refresh handler))
(error (c) (decide-retry c)))
Common Lisp runs the most recently bound handler first, so the inner
handler-bind sees the condition while the stack is still live, logs the real
signalling frame, and then declines: it returns normally rather than
transferring control. Only then does the outer handler-case unwind and make
the retry decision. Invert the nesting and the backtrace is gone before anything
records it.
Diagnostic logging is part of the fix, not decoration
The handler logs the condition's type and a backtrace, not just its printed message. When a failure is rare or environment-dependent, that captured frame is the only instrument that will explain it: the tests for this path assert that the failure is logged with a backtrace, not merely survived.
Enforcement
The mallet linter gates this convention. The configuration is .mallet.lisp
at the repo root; run it with scripts/lint-lisp.sh.
Beware a name collision: the linter is $LISP_WORKSPACE/mallet/bin/mallet.
A mallet on the system PATH is an unrelated topic-modeling toolkit.
| Rule | Posture |
|---|---|
no-ignore-errors |
Advisory (:warning). Reports in full and does not refuse the commit; the gate fails only on :error. Frozen baseline for the files that predate the gate |
no-eval |
Blocks in src/. Scoped off for tests/, which construct forms deliberately |
error-without-custom-condition |
Advisory (:info) in src/. Signals where error is called with a bare string instead of a class, see the caveat below |
The advisory rule under-reports badly, and its number is a floor rather than
a count. It fires only when the message string contains no colon; format
arguments are irrelevant. valis src/ holds 77 (error "…" sites, of which 65
carry a colon and are invisible to it. The 13 the rule reports reconcile
exactly: the 12 colon-free strings, plus one (error 'simple-error …): the
rule also flags a quoted CL built-in condition, which is not a string at all.
(error "plain message no colon") ; reported
(error "formatted ~S here" 1) ; reported, format args are not the test
(error "prefix: message with colon") ; SILENT
(error "prefix: formatted ~S" 1) ; SILENT
This is a defect in the linter, not a design choice. Its parser represents
symbols as strings carrying a package prefix ("CL:error"), and the colon
test is a heuristic meant to tell a symbol-string from a real literal, but it
is applied to message strings too. The rule is blinded by good message
hygiene: the more consistently you prefix messages with a subsystem name, the
less it sees. A repo with tidy conventions reads as perfectly clean while being
maximally indebted.
So read a low number here as "nobody has measured this," never as "clean," and
grep (error " alongside it. Record both, labelled.
And confirm the rule is switched on before you attribute its zero to any of
this. It lives in the :strict preset, not :default, so a run that does not
enable it reports zero for a much duller reason. A zero from an unenabled rule
and a zero from a colon-blinded one are identical in the output, and now that
the colon mechanism is documented, it makes a ready-made explanation for a
number whose real cause is that nobody turned the rule on.
Do not remove colons from error messages to make this number move. Now that the mechanism is known, that inversion is the live risk: a reader who understands the rule could "improve" the count by stripping the =subsystem: = prefix from every message in the tree, trading real diagnostic quality for a linter artifact. The prefixes are worth more than the metric. The rule is wrong, not the messages, and the fix belongs upstream in the linter, the same principle as never editing source to satisfy a false positive.
Three lessons outlast this particular bug.
Probe what a lint rule actually matches before trusting its count. One that quietly covers a narrower case than its name suggests is indistinguishable from a clean codebase, and a low number is the one nobody thinks to question.
When the tool is ours, read its source instead of probing it. This behaviour was settled in one reading of a six-line predicate, after black-box probing had produced three different and confidently-held theories.
Check your instrument before reporting a discrepancy. Every wrong answer here
came from a measuring apparatus that altered what it measured. Probe messages
were labelled «A: expected to report» for readability, and those labels are
colons, so the probes reported on their own labels while being read as evidence
about form shape. A line-based grep for (error " misses a colon that sits on a
continuation line, which produces a mismatch that looks like the rule
misbehaving. The counts in this section were themselves wrong on the first pass
for that exact reason. Keep a probe's payload minimal and undecorated, and read
the whole payload rather than a convenient slice of it.
A clean, reproducible result from an underpowered design is more dangerous than a noisy one. The refuted format-arguments theory came from a two-case probe whose "with format arguments" case had a colon inside its own English phrasing. The two variables covaried perfectly, so the experiment carried one bit of information and it was read as being about the variable the author had in mind. It reproduced on real files, three times. Nothing about it looked wrong.
The contrast is the lesson: incoherent data prompted a second look and a correct answer; coherent data from a design that could not separate the variables prompted publication. Before trusting a clean result, ask whether the design could have distinguished the hypothesis from its neighbours at all, and count the cases, because three confirmations sharing one confound are one confirmation.
The frozen baseline is a debt list, not an endorsement
.mallet.lisp names the files that carried masking handlers when the gate went
up, each with its count. Those files are exempt so the gate could be turned on
without a codebase-wide rewrite; every other file reports immediately on a new
ignore-errors, in full, without refusing the commit.
The counts are there to be driven down. Retiring a file means converting its sites to the shapes above and deleting its line from the config, at which point the file is permanently gated. Do not add a file to that list. If you are touching a listed file anyway, retiring it is the cheapest it will ever be.
Raw counts do not give the triage order, and following them sends effort at the
wrong files. The four largest (src/main.lisp, src/fabric.lisp,
src/edge/seam.lisp, src/transport/keyed-acceptor.lisp) hold 79 of the 115
sites and look like the obvious place to start. They are in fact the least
indebted files in the repo: 67 cleanup, 5 masking. Daemon lifecycle code is
mostly symmetric teardown, and symmetric teardown is what cleanup looks like.
valis's actual masking debt concentrates on the ACME and certificate axis:
src/active/cert-watch.lisp, src/active/acme-manager.lisp,
src/active/capability.lisp. Triage by position first, then rank by what a
masked failure would actually cost. Counting is not triage.
The shape worth hunting: a guard that masks what it guards
The highest-value finding in valis's own triage was not a large count. It was
that *next-renewal-fn* has exactly two callers (the ACME renewal machinery,
and the certificate-expiry watchdog that exists to catch a silent failure in
that machinery) and both wrap it in ignore-errors. No other belt exists. If
that seam starts signalling, the manager churns a renewal every boot while the
watchdog's next-renewal reads NIL and its check never fires. The watcher shares
the failure mode of the thing it watches, so the mechanism installed to detect
the failure is removed by the same defect.
Look for this shape specifically: a guard, probe, monitor, or watchdog that masks the same call as the thing it guards. It is where masking costs the most, and a count-ordered triage will never surface it.
Control flow through a swallowed condition
A third shape hides among the masking sites and is neither cleanup nor a handler
problem: ignore-errors used as a validity test.
(ignore-errors (parse-integer token :junk-allowed nil)) ; NIL means "not a number"
The obvious-looking fix (reach for :junk-allowed t) is wrong, and wrong in
two opposite directions depending on the call site:
- It accepts what should be rejected.
(parse-integer "12abc" :junk-allowed t)returns 12. A malformed protocol parameter is silently taken as valid, where the masking at least failed closed. - It rejects what is currently accepted. With
:junk-allowedNIL,(parse-integer "2 ")returns 2 with an end index of 2 (the whole string), so a caller checking(end (length text))= accepts it. With:junk-allowed tthe end index is 1, that check fails, and the input is now refused.
:junk-allowed t is not looser or stricter. It is a different parse with a
different whitespace contract, and which way it breaks depends on what the
caller does with the second return value. A reviewer watching for the first
hazard will wave the second one straight through.
The criterion:
handler-caseonparse-error, the default, and the only safe choice for untrusted or externally-authored input. It preserves the exact accept/reject contract including whitespace, and narrows the caught class to the one actually expected, which is what this document asks for anyway.:junk-allowed tonly where both hold: trailing junk is genuinely acceptable, and nothing downstream depends on the end index or on surrounding-whitespace behaviour. In practice, a trusted config value or environment variable. The second condition is the one that gets missed: it is invisible unless you read what the caller does with the second value.- Neither, where the whole token must be digits. valis's own two sites
(
src/main.lisp:1015and:1028) require exactly that, already fail loud, and are correct as written.
The general point: a fix prescribed by shape is the same error as a triage by shape. Work out what the replacement would do at that call site before applying it.
Suppressions
A genuinely ignorable failure is suppressed at the site, with a reason:
; mallet:suppress no-ignore-errors -- <why this failure is genuinely ignorable>
The reason is the point. "Pre-existing" and "not important" are not reasons; a
reader must be able to tell what failure is being accepted and why accepting it
is safe. The linter's stale-suppression rule reports suppressions that are no
longer needed, so these do not silently accumulate once the code beneath them
improves.