Phase 5: Narrow the handlers¶
Phase 5 of PLAN-api-input-validation.md,
following phase 4.
Phase 4 made the validation layer reject. This phase removes the thing
it was built to replace: the except TypeError arm in
handle_authorization_exceptions, which has been handing callers the
interpreter's own words as a 400 since 2021 (ffecee9f9, Refactor API
to be more tractable). That catch is the second
half of the mechanism issue
#3612
describes, and it could not be removed earlier because it was absorbing
the malformed input phase 4 now refuses.
Planning effort: high. The change is four lines of deletion, and
every difficulty in it is in establishing what those four lines are
still load-bearing for. The survey found one deliberate raise
TypeError that depends on them and one documented rollback path whose
behaviour they define.
Review effort: high, for the same reason. A reviewer's job here is to find the TypeError source the survey missed.
Context¶
The catch lives at shakenfist/external_api/base.py:1509:
except TypeError as e:
_authorization_failure_log(e).info('API request rejected as malformed')
return sf_api.error(400, str(e), suppress_traceback=False)
It sits inside handle_authorization_exceptions, alongside three
further except arms covering ten JWT exception classes. That is a category error: a TypeError is not an
authorization condition, and nothing in flask_jwt_extended or PyJWT
signals one with it. The arm is there because a handler called with a
body key it does not accept raises TypeError, and answering 400 was
better than answering 500 — a workaround for the absence of the
validation layer this plan spent four phases building.
With API_VALIDATION_MODE defaulting to enforce, decision D14
answers an undeclared body key with <name>: not declared by this
endpoint before the handler is reached. The arm's original purpose
is therefore already served on the default path, and what remains
behind it is the thing it was never meant to catch: a genuine
TypeError from inside a handler, currently reported to the caller as
a 400 with a Python message and to the operator as an INFO line saying
the request was malformed.
Scope¶
In:
- Delete the
except TypeErrorarm fromhandle_authorization_exceptions, so a handler-internalTypeErrorbecomes a recorded 500 like every other unexpected exception. - Replace
log_request's deliberateraise TypeErrorwith an explicit 400 return first, in its own commit, because the deletion depends on it. - Fix #3523, whose raising frame this survey identified (see F6).
- Update the two unit tests which currently assert interpreter text as
the expected response under
warnandoff. - Remove the two dead
requires_namespace_exist_if_specifieduses phase 4 recorded and did not file (F8). - Document what the
warn/offrollback now does, since this phase changes it. - Stop
suppress_exceptions_to_clientputtingrepr(e)in the caller's error body. Added to the phase after step 2 found it — see F11 and D31.
Out:
- #2094 — the bare 403 on deleting an already-unrouted address. It is response semantics for one endpoint, not the error contract of the chain; phase 6 owns it. See D27.
- Enforcing
required, and semantic validation of the prose formats. Phase 6. - The proxy path answering an unreachable node with a 500 (F9). Real, and a status-code contract change of its own. Filed, not fixed.
- Response validation. Ruled out in phase 0 (D7).
What the survey found¶
The master plan's phase 5 section is stale in one important way and
correct in the rest. Corrections have been made at source in
PLAN-api-input-validation.md and docs/plans/index.md as part of the
planning commit, so this section is the record of why they changed
rather than a second copy of the claims.
F1. Three of the four attribution issues are now closed, not two¶
The master plan says "#3615 landed 2026-08-10, #3606 is in flight as PR #3714, leaving #3523 and #3371". As of this survey:
| Issue | State | Note |
|---|---|---|
| #3615 | Closed | As recorded |
| #3606 | Closed | PR #3714 merged 2026-08-12 |
| #3371 | Closed | Closed since the master plan was written |
| #3523 | Open | The only one left |
So the master plan's prediction — that by the time phases 3 and 4
landed, phase 5 would be "likely to be the single item that actually
depends on them" — is now the fact rather than the forecast. Phase 5
is the except TypeError narrowing, plus one issue.
F2. The catch is still broad, and phases 3 and 4 did not touch it¶
base.py:1509, unchanged by phases 3 and 4. Phase 3's plan said
explicitly that it does not narrow it and that doing so before
enforcement "would turn 400s into 500s". Enforcement has landed, so
that gate is open.
F3. One deliberate raise TypeError depends on the catch¶
log_request raises it on purpose (base.py:1378):
with a comment saying the per-key merge it replaced raised TypeError
for a non-object body and that this guard "keeps it that way". The
400 it produces comes entirely from the arm being deleted. It is
pinned by test_a_non_object_body_is_still_a_400
(shakenfist/tests/external_api/test_request_validation.py:432),
which drives four payloads — ['a', 'b'], 'abc', ['ab', 'cd'] and
5 — through the real stack.
Deleting the arm without changing this first turns every non-object JSON body into a 500. This is why step 1 exists and why it is a separate commit.
grep -rn "raise TypeError" shakenfist/ --include=*.py outside tests
returns exactly this one site; the two other matches are comments in
validation.py.
F4. The rollback modes are what the deletion actually changes¶
In enforce — the default since phase 4 — an undeclared body key
never reaches the handler, so the arm is unreachable for that input.
In warn and off it is reachable, and two unit tests assert the
interpreter text as the expected response:
test_warn_mode_changes_no_response(test_request_validation.py:98) assertsunexpected keyword argumentappears in the response body.test_off_mode_disables_the_layer(:198) asserts the same.
Both are correct today and both describe a leak. After this phase those inputs answer 500. That is the substantive behaviour change of the phase, and it lands on the path an operator uses when they have rolled enforcement back — see D25, which is the decision most likely to be argued with.
F5. No handler can raise TypeError from a missing parameter¶
Worth checking because D17 deliberately leaves missing-required
unenforced, which looks like it should leave a live path from an
omitted parameter to TypeError: missing 1 required positional
argument to a 400 carrying interpreter text.
It does not. Every endpoint handler in the tree gives every non-path parameter a default. Verified with the repository's own AST machinery rather than by reading:
# tools/... (written into step 2's brief; run from the repo root)
from shakenfist.external_api import declarations as d
for path, tree, cls, fn in d.handlers():
allpos = list(fn.args.posonlyargs) + list(fn.args.args)
nd = len(fn.args.defaults)
nodefault = [a.arg for a in (allpos[:len(allpos) - nd] if nd else allpos)]
nodefault += [a.arg for a, dv in zip(fn.args.kwonlyargs, fn.args.kw_defaults)
if dv is None]
...
The count is zero — and it answers a narrower question than the one that mattered, which the review of #4162 later caught. Read the correction at the end of this finding before relying on it.
Recorded with a caution: the first cut of that
script sliced the defaults against a list it had already filtered
self and the *_from_db injected parameters out of, and reported
22 handlers. The number was wrong in the direction that would have
made this phase look gated on phase 6. It was caught by driving one of
the 22 through the real stack — POST /auth without namespace
answers missing namespace in request from the handler's own guard,
not a TypeError — which is the check that should have been run
first. A derived number that nobody drove through the stack is exactly
the sort of assertion this plan's own review rounds keep catching.
The corrected number was still answering the wrong question. The
review of #4162
found that the hazard is not a parameter without a default. It is a
parameter with a None default which a handler then dereferences
without a type guard. Every declared parameter compiles to a field
with allow_none=True, and D17 leaves required unenforced, so
None reaches handlers routinely on the default enforce path —
which an AST check for missing defaults cannot see at all.
InstancesEndpoint.post is the proven instance. It defaults
name=None; validators.hostname(None, ...) returns a falsy
ValidationError rather than raising, so execution reaches
contains_domain = '.' in name, which raises TypeError: argument of
type 'NoneType' is not iterable. Before this phase that answered 400
with interpreter text. After step 2 it answers a recorded 500, on the
default path, for POST /instances {}. Fixed here, with tests.
So F5's zero covers non-defaulted parameters only, and D25's
blast-radius argument covers undeclared body keys under warn/off
only. Neither covered a plain caller mistake against a declared but
nullable parameter. POST /networks next door escapes only by
accident, because ipaddress.ip_network(None) raises ValueError
rather than TypeError.
The class as a whole is unswept, and sweeping it belongs to phase 6,
which already owns required and semantic validation. Filed as
#4167, which
also carries three further latent 500s the fix's author found in the
same handler while probing its other fifteen parameters — cpus and
memory as null, disk[].base as a non-string, and
network[].network_uuid as a non-string. None of the three is a
regression from this phase: they raise pydantic ValidationError or
AttributeError, which the deleted arm never caught, so they answered
a 500 before it too. The last two share a structural cause worth phase
6's attention on its own — an arrayofdict compiles to
fields.List(fields.Dict()) with no value schema, so every nested key
in disk and network is unvalidated by construction, in every
mode. The lesson
worth carrying is not that the script had a bug — it is that the
script was mechanically correct on the second attempt and still aimed
at the wrong property. This phase's own risk register named "a
TypeError source the survey missed becomes a 500" and the survey
still missed one.
F6. #3523's raising frame, found¶
The issue asks for "a live Loki drill-down for the untruncated
traceback to identify the raising frame (single event, so it may take
a recurrence)". It has recurred, and the drill-down is done. Thirty
days of {job="shakenfist"} |= "Server error" on the sfcbr tenant:
KeyError: 'arch_string_raw'
File ".../shakenfist/external_api/base.py", line 933, in proxy_request_to_node
File ".../shakenfist/util/general.py", line 93, in get_user_agent
on GET /instances/<uuid>/consoledata, five occurrences in thirty
days. The code is shakenfist/util/general.py:87-96:
def get_user_agent() -> str:
architecture = cpuinfo.get_cpu_info()
return ('Mozilla/5.0 (%(distribution)s; %(vendor)s %(architecture)s) '
...
'architecture': architecture['arch_string_raw'],
'vendor': architecture['vendor_id_raw'],
py-cpuinfo is not guaranteed to populate either key — it probes
through several mechanisms and returns what it managed to collect —
so when the probe comes back partial the API answers an opaque 500 to
a caller asking for console data. The fix is two guarded lookups in
util/general.py, and it is not in the API at all.
F7. #3523's logging half is already fixed¶
The issue's second complaint — a traceback showing only wrapper frames,
plus a content-free Recorded new exception WARNING 195µs away — was
resolved by the #3433 and #3590 work now visible in
record_exception (already_logged=True, correlation fields stashed
on flask.g) and suppress_exceptions_to_client (one line carrying
exception_class, the full traceback, method and path). The
Loki records in F6 are the proof: the traceback reaches get_user_agent,
which is what the issue said it could not do. So #3523 reduces to F6.
F8. A defect phase 4 recorded and never filed¶
Phase 4's plan records, under Found while implementing, recorded
rather than fixed, that requires_namespace_exist_if_specified is
dead on InstanceEndpoint.delete and NetworkEndpoint.delete because
the ref decorator above it has already popped namespace. Verified:
instance.py:266-270 applies @arg_is_instance_ref above
@api_base.requires_namespace_exist_if_specified, so the ref
decorator wraps it and runs first, and arg_is_instance_ref:1052
opens with kwargs.pop('namespace', None).
No issue was filed. gh issue list --state all --search
requires_namespace_exist_if_specified returns nothing, so the record
exists only in a phase plan nobody has reason to read again. That is
the silent-disappearance failure mode, caught here rather than lost.
The observable effect is small, which is why removal rather than
reordering is the right fix: arg_is_instance_ref already resolves
the namespace itself and answers its own errors, so a caller naming a
non-existent namespace gets instance not found (404) instead of
namespace not found (404). Reordering would change that message;
removing the dead call acknowledges the ref decorator already covers
the case. See D29.
F9. Two more 500 classes on the proxy path — mostly already fixed¶
The full 30-day sample is 266 Server error records, and the five
KeyErrors of F6 are the small half of it:
| n | class | deepest frame |
|---|---|---|
| 260 | ConnectionError |
requests/adapters.py in send |
| 5 | KeyError |
util/general.py in get_user_agent |
| 1 | ConnectionRefusedError |
util/concurrency.py:329 in _node_lock_request |
This finding was substantially wrong, and step 5 caught it. The correction is recorded here rather than only in Progress, because a stale finding left standing is how the next reader inherits the error.
The claim above — "a node being unreachable is answered as a 500, 260
times in a month" — reads a 30-day aggregate as a rate. It is not one.
Split by day, every ConnectionError in the sample falls between
2026-08-12 and 2026-08-19, and 252 of the 260 are a single burst on
08-19. There are none afterwards, because
2ac50193b
— Surface unreachable proxy peers as a 503, issue #3743 — landed
2026-08-21 and already fixed that path. proxy_request_to_node now
catches requests.exceptions.RequestException and answers 503.
So the headline defect was fixed a week into the window this plan measured, and the survey did not notice because it never asked whether the distribution was uniform. That is the same mistake as F5's first cut: a derived number nobody checked against a second axis.
What is actually left, verified by step 5 against the current tree and current Loki data:
- The
ConnectionRefusedErroris live. It recurred 2026-08-26, after the proxy fix, onPOST /instances/<uuid>/poweroff. It is a different mechanism —_node_lock_requestspeaks to a local Unix socket belonging to the node's own nodelock daemon, not to a peer — so2ac50193bnever covered it. - Two more unguarded proxy call sites the survey missed, neither
behind
proxy_request_to_nodeand so neither fixed by2ac50193b:shakenfist/external_api/blob.py:71(_read_remote) and theupload_uuidbranch ofPOST /artifacts(shakenfist/external_api/artifact.py:588). Both callrequests.request()with no exception handling.
Filed as #4161, scoped to what is still true rather than to the table above. D28's reasoning for not fixing it here survives the correction: it still needs a status-code decision, a retry decision and a caller audit, which is a phase and not a step. D31 raises its value, since a caller can no longer see which exception it was.
F10. The arm has fired once in the only window that can be counted¶
handle_authorization_exceptions logged nothing on the TypeError
path until 2026-09-05, when 804b165d8 added
_authorization_failure_log and the line API request rejected as
malformed. Before that the arm returned a silent 400: the leak this
phase closes has had no operator-side record for its whole life, which
is its own small argument for closing it.
Since that line exists, sfcbr has fired the arm exactly once, on 2026-09-08:
"error-class": "TypeError",
"error": "InstancesEndpoint.get() got an unexpected keyword argument 'banana'",
"method": "GET", "path": "/instances"
which is phase 4's own hand probe — the control its measurement log records, not a caller. Five days is a short window and it is the only one there is; the honest statement is that nothing is known about the arm's rate before 2026-09-05, and that in the days since, no real caller has reached it.
F11. The deletion relabels the leak rather than removing it¶
Found by step 2, after this plan was written and committed. It is recorded here rather than silently fixed because it falsifies something the plan assumed.
suppress_exceptions_to_client builds every 500 body as
'server error: %s' % repr(e) (base.py:1684). So after the arm is
deleted, an undeclared body key under warn or off answers:
500 {"error": "server error: TypeError(\"AuthEndpoint.post() got an
unexpected keyword argument 'zzz'\")", "status": 500}
which is #3612's motivating string — endpoint class, method name, and the fact that kwargs are merged into the call — wearing a 500 instead of a 400. The plan assumed the generic 500 body was clean. It is not, and the assumption is load-bearing for D25 and for definition of done item 4.
It is a second and independent leak site, common to every exception
class rather than specific to this one. It is also what puts
KeyError('arch_string_raw') (F6) and the 260 ConnectionError
reprs (F9) into caller-visible bodies. base.py:1684 is the only
production site; grep finds no other.
Step 2 did not fix it, correctly: cleaning that body changes the shape
of every 500 the API emits, which is an error-contract decision of the
same size as D25. Instead it asserted what is true — every
INTERPRETER_TEXT marker absent except the exception class name,
with the exemption named and justified in the test docstring so that
tightening it is a one-line change. That is the right shape for a
finding that outruns its plan.
Nothing else in the master plan's phase 5 section was wrong¶
The description of what the narrowing is, and the reasoning about why nobody picks it up incidentally, both hold.
Decisions¶
D23. The arm is deleted, not narrowed to a subset¶
"Narrow except TypeError to JWT errors" reads two ways. It means
delete the TypeError arm and leave the three JWT arms, not keep a
TypeError arm that tries to distinguish JWT-originated ones: nothing
in flask_jwt_extended or PyJWT raises TypeError to signal an
authorization failure, and there is no attribute on a TypeError that
would let the handler tell where it came from. A conditional there
could only work by matching the message text, which is the technique
this plan exists to remove.
D24. log_request stops raising, in its own commit¶
The non-object body guard becomes an explicit return sf_api.error(400,
'the request body must be a JSON object'). Two reasons it is a
separate commit ahead of the deletion: the deletion is otherwise a
behaviour change bundled with a regression fix and cannot be bisected
apart, and test_a_non_object_body_is_still_a_400 should pass
unchanged across both commits, which is only meaningful if they are
separate.
log_request is a decorator wrapper and already returns responses on
other paths, so this is a return, not a new mechanism.
D25. Under warn and off, an undeclared body key becomes a 500¶
This is the decision most likely to be argued with. The
documented rollback for enforcement is API_VALIDATION_MODE=warn or
off. Today, rolling back restores the old 400-with-interpreter-text
answer for an undeclared body key. After this phase, rolling back
gives a 500 with a recorded server exception instead.
The argument against: a rollback should restore the previous behaviour, and this makes the rollback strictly worse for the caller who triggered it.
The argument for, which wins:
- The 400 it restores is the defect.
str(TypeError)in a caller's error field is #3612, and preserving it in a mode reachable by an operator flag preserves the leak that four phases were spent closing. An operator rolling back is buying "requests that were working keep working", not "malformed requests get a tidier error". - A request whose kwargs do not match its handler's signature is a
server-side surprise once a validation layer exists and is switched
off. 500 with a recorded exception is the honest answer; the
operator gets a file in
/srv/shakenfist/exceptions/and a log line carrying the class, traceback, method and path, which is more than the INFO line said. - The population is small and known. Thirty days of sfcbr warn data
(phase 4's measurement log, What thirty days of sfcbr says about
the flip's blast radius) contains exactly one
unknown-parameterfinding — a hand probe on 2026-08-13 which already answered 400. No real caller in that window sent a body key its handler could not accept. The callers who would meet the new 500 are callers who are already being refused. F10 says the same thing from the other side: the arm itself has fired once since it started logging, on a hand probe.
The alternative considered and rejected: keep a TypeError arm gated
on config.API_VALIDATION_MODE != 'enforce'. It preserves the
rollback exactly, and it makes the response to a malformed request
depend on a mode flag in two places instead of one, leaving the leak
in the tree with a longer justification attached. Recorded here so
the option is visible rather than absent.
Documented in the release note and in
docs/developer_guide/writing_an_endpoint.md, because a rollback
whose behaviour changed is a thing an operator finds out at the worst
possible moment otherwise.
D26. #3523 is fixed in util/general.py, not in the API¶
Per F6 and F7 the issue's logging half is done and its remaining half
is an unguarded dict lookup in a utility function. The fix belongs
where the bug is. get_user_agent() gets a default for each key it
reads, and returns a user agent string naming what it does know.
It is in this phase rather than filed separately because #3523 is one of the four issues the master plan assigns to phase 5, and because it is a live 500 on a user-facing endpoint.
D27. #2094 stays out¶
DELETE .../route/<addr> on an already-unrouted address returns a bare
403. Making it idempotent is a decision about what the endpoint means,
not about how the chain reports errors, and it needs the same
judgement phase 6 will be making about required. It is not blocked
by anything this phase does.
D28. F9 is filed, not fixed¶
An unreachable node answering 500 is a real defect, and fixing it means
choosing a status code, deciding whether the proxy retries, and
auditing every caller of proxy_request_to_node and
_node_lock_request. That is a phase, not a step. File it with the
Loki evidence and link it from the master plan's Future work.
D29. The dead decorator uses are removed, not reordered¶
Per F8. Removal is behaviour-neutral because the call cannot fire;
reordering would change a 404's message text on two delete routes and
would need its own justification. The four now-redundant
namespace=None signature parameters phase 4 also recorded are left
alone: they are populated by the body merge and read by nothing, and
removing them is a signature change to four handlers for no observable
gain. Recorded so the next phase does not rediscover them.
D30. A test that a handler-internal TypeError is a 500¶
The deletion is a removal, and a removal is exactly the change a test
suite is least likely to notice. The phase adds a test that mounts a
handler which raises TypeError and asserts the caller gets 500 with
no interpreter text in the body, and that an exception was recorded.
Without it, the property "the API never answers a TypeError in the
interpreter's words" is asserted nowhere and could be undone by
someone restoring the arm.
D31. The generic 500 body stops carrying repr(e)¶
Taken by the operator after step 2 surfaced F11, and added to the phase rather than filed.
base.py:1684 becomes sf_api.error(500, 'server error'). The detail
does not disappear: suppress_exceptions_to_client already logs one
Server error line carrying exception_class, the full traceback,
method, path and the correlation fields for the on-disk record
under /srv/shakenfist/exceptions/, all of which say more than
repr(e) in a response body ever did.
Two things argued for doing it here rather than deferring it. It is
what makes the phase's own claim true — a phase whose purpose is to
stop the API answering in the interpreter's words should not ship a
version that only changes which status code the words arrive under.
And it closes the same leak for the classes F6 and F9 found in
production, which are two orders of magnitude more frequent than the
TypeError this phase started with.
The cost, stated plainly: a caller debugging from a response body
alone loses the exception detail, and gets server error with nothing
else. That is deliberate — the caller is not who the detail is for,
and an operator has both the log line and the exception record. It
goes in the release note for the same reason D25 does.
Step plan¶
| Step | Effort | Model | Isolation | Brief for sub-agent |
|---|---|---|---|---|
| 1 | medium | sonnet | none | log_request returns a 400 instead of raising. In shakenfist/external_api/base.py, log_request's wrapper raises TypeError('the request body must be a JSON object') at line 1378 when the parsed JSON body is not a dict. Replace it with return sf_api.error(400, 'the request body must be a JSON object'). Keep the surrounding comment but rewrite it: it currently explains that the raise reproduces the old per-key merge's TypeError, and after this change the point is that the guard answers directly rather than depending on a catch two decorators out. Do not change anything else in log_request. test_a_non_object_body_is_still_a_400 (shakenfist/tests/external_api/test_request_validation.py:432) must pass unchanged — all four payloads, same status, same message. Verify by running it before and after. Commit subject: Answer a non-object body directly. |
| 2 | high | opus | none | Delete the except TypeError arm. In shakenfist/external_api/base.py, handle_authorization_exceptions (line 1500) catches TypeError and answers 400 str(e). Delete that arm and leave the three JWT arms — DecodeError, ExpiredSignatureError and the eight-class tuple — untouched. Rewrite the NOTE(mikal) comment above the wrapper so it says what the function now catches and why (authorization conditions only; see decision D23 in this plan). Then fix the fallout, which the plan has already enumerated: test_warn_mode_changes_no_response (test_request_validation.py:98) and test_off_mode_disables_the_layer (:198) both assert unexpected keyword argument appears in the response body; under warn and off an undeclared body key now answers 500 (decision D25), so rewrite both to assert the new behaviour and to assert the response carries no interpreter text. Add the D30 test: a handler which raises TypeError internally answers 500, the body contains no Python message, and util.exceptions.record_exception was called. Reuse the INTERPRETER_TEXT list (test_request_validation.py:709) rather than writing a new one; note its comment says a marker added there belongs in shakenfist/deploy/shakenfist_ci/cluster_ci_tests/test_api_validation.py too. Before finishing, enumerate every remaining source of TypeError reachable from a request: grep the whole tree for raise TypeError (step 1 removed the only one in external_api/), and check the @use_kwargs/webargs sites and flasgger. Report what you found even if the answer is nothing. Commit subject: Stop answering a TypeError as a bad request. |
| 3 | medium | sonnet | none | Fix #3523. shakenfist/util/general.py:87 get_user_agent() indexes cpuinfo.get_cpu_info() for arch_string_raw and vendor_id_raw without guarding. py-cpuinfo returns whatever its probes collected, so both keys can be absent, and the resulting KeyError reaches a caller as a 500 on GET /instances/<uuid>/consoledata via proxy_request_to_node. Use .get() with a sensible literal for each ('unknown' reads correctly in a user agent string). Add a unit test in shakenfist/tests/ that patches cpuinfo.get_cpu_info to return {} and asserts get_user_agent() returns a string containing the version — mutation test it by restoring one of the bare lookups and confirming the test fails. Do not change the user agent's format for the case where both keys are present; a test should pin that too. Commit subject: Survive a partial cpuinfo probe. |
| 4 | low | sonnet | none | Remove two dead decorator uses. @api_base.requires_namespace_exist_if_specified is applied below @api_base.arg_is_instance_ref on InstanceEndpoint.delete (shakenfist/external_api/instance.py:268) and below @api_base.arg_is_network_ref on NetworkEndpoint.delete (shakenfist/external_api/network.py:165). The ref decorator wraps it and opens with kwargs.pop('namespace', None) (base.py:1052), so its kwargs.get('namespace') is always None and it can never fire. Delete both applications. Do not reorder them, and do not touch the other ten uses, which are on creation and collection routes and work (decision D29). Confirm no test asserts namespace not found on either route before deleting. Add a short comment to requires_namespace_exist_if_specified in base.py recording that it must be applied above any ref decorator, so the next use does not repeat this. Commit subject: Drop two guards that could never fire. |
| 6 | medium | sonnet | none | Stop the 500 body carrying repr(e). Per D31 and F11. In shakenfist/external_api/base.py, suppress_exceptions_to_client (line ~1629) ends with return sf_api.error(500, 'server error: %s' % repr(e), suppress_traceback=True) at line 1684. Change the message to a bare 'server error'. Do not touch the LOG.with_fields(fields).exception('Server error') line above it or the fields it builds — that line and the on-disk record under /srv/shakenfist/exceptions/ are where the detail goes, and they already carry more than the body did. Add a comment saying why the body is deliberately opaque: the caller is not who the detail is for, and repr(e) in an error field is the same defect as the interpreter text this phase deleted (issue #3612, decision D31). Then find what depended on the old shape: test_a_handler_internal_type_error_is_a_recorded_500 in shakenfist/tests/external_api/test_request_validation.py skips the 'TypeError' marker in its INTERPRETER_TEXT sweep and says in its docstring why — remove the exemption and the paragraph justifying it, so the sweep runs whole. Check shakenfist/tests/external_api/test_server_error_logging.py, shakenfist/tests/test_federation.py (line ~780) and shakenfist/external_api/label.py (line ~59) for assertions or comments naming the old body; a comment which now describes something untrue should be corrected, not left. Finally grep shakenfist/deploy/shakenfist_ci/ for anything reading a 500's error text. Report everything you found. Commit subject: Stop telling callers what exception broke. |
| 5 | medium | sonnet | none | Documentation and close-out. (a) docs/developer_guide/writing_an_endpoint.md:308-316 already describes API_VALIDATION_MODE and calls warn the operator's rollback; extend that passage to say what warn and off now do with an undeclared body key (500, recorded exception). Do not add a new section. (b) docs/release_notes/v07-v08.md:188-220 is phase 4's enforcement entry, including the API_VALIDATION_MODE=warn rollback sentence at line 210; extend that entry, do not start a second one, noting that the rollback modes no longer answer 400 for input the handler cannot accept. (c) File the F9 issue: the proxy path answers an unreachable node with a 500, with the Loki evidence from this plan's F9 and links to proxy_request_to_node and util/concurrency.py:329. (d) Update this plan's Progress section, the master plan's Execution table row for phase 5, and the docs/plans/index.md phase arithmetic. Commit subject: Document what narrowing changed. |
Steps 1 and 2 are strictly ordered, and step 6 follows step 2. Steps 3 and 4 are independent of both and of each other. Step 5 runs last, because it documents what the others did. Step 2 carries a conditional gate — see the Back brief.
Step 6 was added after the plan was committed, when step 2 found F11. It is numbered 6 and sequenced fifth so that the earlier numbers keep pointing at the commits that already reference them.
Risks and mitigations¶
- A
TypeErrorsource the survey missed becomes a 500. The survey enumeratedraise TypeErroracross the tree and reasoned about the handler-signature paths, but a library on a code path with no test could raise one. Mitigation: step 2's brief makes the enumeration an explicit deliverable and asks for a report even when it is empty; the reviewer's job is named as finding the one that was missed. Note what the production sample can and cannot say: theServer errorrecords in F6/F9 carry noTypeErrorby construction, because the arm catches it before the outer wrapper sees it, so that sample is not evidence. F10 is — the arm's own log line, which has fired once since it started existing on 2026-09-05, on a hand probe. - The rollback becomes worse and an operator finds out during an incident. Mitigation: D25 is documented in both the developer guide and the release note by step 5, and the release note entry sits with the enforcement entry an operator reading about the rollback would already be looking at.
- Step 4 removes a guard that is not actually dead. Mitigation:
the claim was verified by reading the decorator application order
and
arg_is_instance_ref's first statement, and the brief requires confirming no test assertsnamespace not foundon those routes before deleting. If either check fails, the step stops rather than proceeding. - The phase looks trivial and is reviewed as though it were. Four lines of deletion is the whole change, and the plan's value is in what the deletion is load-bearing for. Mitigation: the review effort is stated as high above, and D30's test makes the property the phase establishes assertable rather than argued.
Definition of done¶
handle_authorization_exceptionscontains noexcept TypeError, andgrep -n "except TypeError" shakenfist/external_api/base.pyreturns nothing.grep -rn "raise TypeError" shakenfist/external_api/returns no executable statement (comments invalidation.pyexcepted).test_a_non_object_body_is_still_a_400passes with its assertions unchanged, for all four payloads, after both step 1 and step 2.- A test proves a handler which raises
TypeErrorinternally answers 500, that the body carries none of the shapes inINTERPRETER_TEXT(test_request_validation.py:709) with no exemptions, and that an exception was recorded. The test has been mutation-tested by restoring the deleted arm, and fails when it is restored. Step 2 met this with one exemption because of F11; step 6 removes it, and the item is not met until the sweep runs whole. - The generic 500 path carries no exception detail: the body
suppress_exceptions_to_clientreturns is a fixed string, and the item 4 sweep runs unexempted against it. Deliberatestr(e)on a caught exception elsewhere is untouched and stays — nineteen handlers answer a Shaken Fist exception class that way, and there the message is the API's own vocabulary rather than the interpreter's. D31 is about what escapes uncaught, not about whether an exception may ever be quoted.
This item took two attempts to state, both recorded rather than
quietly replaced. It first said grep -n "repr(e)"
shakenfist/external_api/base.py returns nothing; step 5 reported
that as not met, correctly, because step 6's own comment explains
in prose why the body is opaque and so contains the string. The
rewrite then over-corrected to "no sf_api.error() call
interpolates an exception", which those nineteen deliberate sites
falsify. A criterion a comment can break, and a criterion that
condemns correct code, are both the wrong test — and this plan has
now produced one of each.
6. No test in the suite asserts that a response body contains
unexpected keyword argument or missing 1 required positional
argument.
7. get_user_agent() returns a string when cpuinfo.get_cpu_info()
returns {}, pinned by a test which fails if either guarded lookup
is restored to a bare index.
8. #3523 is closed, and the closing comment names
util/general.py:get_user_agent and KeyError: 'arch_string_raw'
as the raising frame the issue asked for.
9. requires_namespace_exist_if_specified is applied above every ref
decorator that uses it, or not at all; the two applications named
in F8 are gone and the other ten are untouched.
10. An issue exists for F9, linked from the master plan's Future work.
11. What warn and off do with an undeclared body key is stated the
same way in docs/developer_guide/writing_an_endpoint.md, the
v07-v08 release note, and this plan.
12. pre-commit run --all-files is clean.
13. The master plan's Execution table, its status line, and the
docs/plans/index.md row all say phase 5 is complete and agree on
the phase arithmetic.
Back brief¶
Before executing any step, back brief the operator on the plan and how the intended work aligns with it.
One gate: step 2 stops and reports before committing if its
enumeration of remaining TypeError sources finds anything beyond the
two the survey already knows about (log_request's, removed by step
1, and handler-internal ones). A third source changes what D25 says
about the rollback, and that is a decision to take in the management
session rather than in the step.
Progress¶
Executed 2026-09-10. Steps 1-4 and 6 landed as five commits, in execution order below; step 6 was added mid-phase, after step 2 found the problem it fixes (F11). This section is step 5, and it runs last as the step plan says it should.
Step 1, efc0ca6eb — Answer a non-object body directly¶
log_request's non-object body guard stopped depending on the catch
step 2 was about to delete: it now returns sf_api.error(400, 'the
request body must be a JSON object') itself instead of raising
TypeError for handle_authorization_exceptions to catch. No
behaviour change — test_a_non_object_body_is_still_a_400 passes
with its four-payload assertions unchanged both before and after,
which is why this is its own commit ahead of the deletion (D24).
Step 2, 6220496df — Stop answering a TypeError as a bad request¶
The except TypeError arm in handle_authorization_exceptions is
deleted (D23); the three JWT arms are untouched. The NOTE(mikal)
comment above the wrapper was rewritten to say what it now catches —
ten JWT exception classes across three arms, nothing else — and why.
Under enforce, the default since phase 4, nothing changes for a
caller: an undeclared body key is refused by name before a handler is
reached. Under warn and off such a key now reaches the handler,
raises TypeError, and answers 500 rather than the
400-with-interpreter-text it used to (D25) — the phase's one intended
behaviour change.
The enumeration D25 rests on was re-run rather than taken on trust
from the plan, and is recorded in the commit message: no production
code raises TypeError outside a handler-signature mismatch, no
handler has a parameter without a default, the four use_kwargs
schemas filter unknown keys before the call, _webargs_error ends in
an HTTPException, and check() itself cannot raise.
Two tests asserted the deleted arm's behaviour without being named in
the plan's step brief: test_findings_are_emitted_with_the_response_status
asserted 400 twice, and test_type_error_400_is_attributable was a
whole test for the arm; both were rewritten rather than deleted, the
second becoming a test that a TypeError escapes the wrapper unlogged.
The D30 test, test_a_handler_internal_type_error_is_a_recorded_500
(shakenfist/tests/external_api/test_request_validation.py:815), was
added here.
Found while implementing, not fixed here: F11.
suppress_exceptions_to_client builds every 500 body as
'server error: %s' % repr(e), so deleting the arm relabelled the
leak instead of removing it: an undeclared body key under warn/off
went on carrying interpreter text, now under a 500 instead of a 400.
This falsified the plan's assumption that the generic 500 body was
already clean, so step 2's own test had to carry an exemption for the
exception-class marker, with the exemption's reason written into its
docstring rather than silently worked around. The operator was asked
whether to clean the body here or file it, and chose to clean it —
which is step 6.
Step 3, dc6019d6a — Survive a partial cpuinfo probe¶
Fixes #3523.
get_user_agent() (shakenfist/util/general.py) now uses .get()
with a literal default for both arch_string_raw and vendor_id_raw
instead of indexing cpuinfo.get_cpu_info() directly. Three tests —
both keys present, an empty probe, and one key present without the
other, the third pinning that the two lookups fall back
independently — mutation-tested by restoring one bare index, which
fails with the production KeyError: 'arch_string_raw' signature.
3523 is closed, with a comment naming the raising frame and this¶
commit.
Step 4, aeb318519 — Drop two guards that could never fire¶
Per F8. @api_base.requires_namespace_exist_if_specified is removed
from InstanceEndpoint.delete and NetworkEndpoint.delete, where it
sat below a ref-resolving decorator that had already popped
namespace out of kwargs before this decorator ever saw it — dead
since phase 4 found it and recorded it without filing an issue. All
twelve uses of the decorator were surveyed, not just the two; the
other ten are on creation and collection routes with no ref decorator
above them and are live. The decorator itself now carries a comment
naming the ordering constraint and the two routes it went wrong on,
so the next use does not repeat the mistake.
Step 6, 92fc4bb6b — Stop telling callers what exception broke¶
Added to the phase after step 2 found F11, above. Sequenced fifth in execution order — after step 4, before this step 5 — while keeping the number 6, so the earlier step numbers keep pointing at the commits that already cite them (per the plan's own note above). The prompt that authorised it: Michael was asked whether the phase should clean the generic 500 body or file it, and chose to clean it here.
suppress_exceptions_to_client's 500 body is now the bare string
'server error'. The LOG.with_fields(fields).exception('Server
error') line immediately above it, and the on-disk record under
/srv/shakenfist/exceptions/, are unchanged and carry the detail
instead — exception_class, the full traceback, method, path, and the
correlation fields. test_a_handler_internal_type_error_is_a_recorded_500
now runs the whole INTERPRETER_TEXT sweep with no exemption, and the
docstring paragraph justifying the exemption is gone with it.
Four comments and two assertions described the old body and were
corrected rather than left. Two are about the present and were
rewritten because they had become false: test_server_error_logging
asserted the exception class appears in the response, and now asserts
it must not; a comment in shakenfist/external_api/auth.py explaining
a guard around a damaged federation rule said the generic 500 handler
"answers with repr(e)", which is no longer true, and was rewritten
to say what the guard is for now (a categorised, evented refusal
rather than an unevented 500) instead of deleting the guard, since its
reason changed but it is not dead. Two are history and were left
alone except for a note: shakenfist/external_api/label.py and
shakenfist/tests/test_federation.py record what a caller saw before
each was independently fixed, and keep the string they quote because
it was true when it happened, with a note added that a 500 no longer
names anything at all.
Step 5, this edit — Documentation and close-out¶
(a) docs/developer_guide/writing_an_endpoint.md now says what warn
and off do with an undeclared body key, in the same passage that
already described the rollback rather than in a new section. (b) The
API_VALIDATION_MODE entry in docs/release_notes/v07-v08.md is
extended, not duplicated, with both behaviour changes: the rollback no
longer answers 400 for input a handler cannot accept, and every 500
body is now the bare string server error for every cause, not only
this phase's. (c) An issue was filed for F9 — see the next section,
because filing it required re-verifying the finding first, and the
finding had partly gone stale. (d) This Progress section, the
Definition of done walk below, the master plan's phase 5 row and its
Where the tracked issues stand section, and docs/plans/index.md's
phase count.
F9 was re-verified before filing, and the picture had changed¶
The plan's F9 table — 30 days of sfcbr Server error records: 260
ConnectionError via proxy_request_to_node, 5 KeyError via
get_user_agent, 1 ConnectionRefusedError via _node_lock_request
— was measured before this phase started executing. Filing it
required re-running the query, which found the table no longer said
the whole truth:
- The 260
ConnectionErrorrecords are not live any more, for that call site. Re-running{job="shakenfist"} |= "Server error" |= "ConnectionError"over the same 30-day window: all 260 timestamps fall between 2026-08-12 and 2026-08-19. #3743 (2ac50193b, PR #3833, merged 2026-08-21) madeproxy_request_to_nodeanswer 503 for exactly this condition, and there have been zero occurrences since. The fiveKeyErrors are fixed by step 3, above. - Two more unguarded proxy call sites exist, found by one grep
pass and absent from the plan's survey:
shakenfist/external_api/blob.py:71(_read_remote, proxyingGET /blobs/<uuid>/datato whichever node holds the blob) andshakenfist/external_api/artifact.py:588(theupload_uuidbranch ofPOST /artifacts, proxying to the node an upload landed on). Neither is covered by the #3743 fix, and neither has log evidence, because nobody has hit the failure window in the last 30 days.
The 1-occurrence ConnectionRefusedError is still live: it recurred
on 2026-08-26, five days after the #3743 fix landed, on
POST /instances/<uuid>/poweroff, while releasing the instance lock.
It is a different mechanism from the other two — a local Unix socket
to the node's own nodelock daemon refusing a connection, not a peer
node being unreachable — which the #3743 fix does not touch and could
not have.
Filed as #4161, scoped to what re-verification found still true rather than to the table as originally surveyed, with the correction recorded in the issue itself so a future reader does not have to reconcile these two sources disagreeing.
Definition of done, item by item¶
- Met.
grep -n "except TypeError" shakenfist/external_api/base.pyreturns nothing. - Met.
grep -rn "raise TypeError" shakenfist/external_api/returns two comments invalidation.py(explaining webargs' own behaviour) and no executable statement, which is exactly what the item excepts. - Met.
test_a_non_object_body_is_still_a_400passes with its four-payload assertions unchanged; step 1's commit message records running it before and after both step 1 and step 2. - Met, as of step 6.
test_a_handler_internal_type_error_is_a_recorded_500(test_request_validation.py:815) asserts 500, everyINTERPRETER_TEXTmarker absent with no exemption, and thatrecord_exceptionwas called once, and it is mutation-tested by restoring the deleted arm. Step 2 met an earlier version of this item with one exemption, which F11 required; step 6 removed the exemption, so this item is met by step 6's commit, not step 2's — exactly the distinction the plan's own text under item 4 called for. - Met, with a literal caveat worth recording rather than hiding.
grep -n "repr(e)" shakenfist/external_api/base.pydoes not return nothing — it returns two hits, both inside the comment step 6 added directly above the fix, explaining in prose why the body no longer carriesrepr(e). No code builds a response body from it any more, and the substantive claim the item is actually checking for — that no response body contains an exception class name, a repr, a traceback or a source path — is whattest_a_handler_internal_type_error_is_a_recorded_500proves with its unexempted sweep. The item's own verification command, read literally, does not pass; its intent does. - Met. No test asserts either fragment appears in a response
body. The surviving text hits are
test_request_validation.py:185, which asserts the absence ofunexpected keyword argument; theINTERPRETER_TEXTlist itself and a docstring quoting the old response as history; and an unrelated comment intest_clusteroperations.pyabout a different code path. - Met. Pinned by the three tests step 3 added, mutation-tested by restoring a bare index.
- Met. #3523 is closed, with a comment naming
util/general.py:get_user_agentandKeyError: 'arch_string_raw'as the raising frame and linkingdc6019d6a. - Met. The two dead applications are gone (step 4);
grep -rn "requires_namespace_exist_if_specified" shakenfist/external_api/*.pyshows the other ten, untouched. - Met. #4161 filed and linked from the master plan's Where the tracked issues stand section. Scoped to what re-verification found still true rather than to the plan's original table — see the F9 section above for why that is not the same thing as the table as surveyed.
- Met. The same two facts — the rollback answers 500 instead of
400 for an undeclared body key, and every 500 body is now the bare
string
server error— are stated indocs/developer_guide/writing_an_endpoint.md, the v07-v08 release note, and this plan (D25, D31, F4, F11). - Met.
pre-commit run --all-filesis clean: 4552 tests passed, 121 skipped, 0 failed, and every other hook (flake8, the fourexternal_apiguard checks,check-plan-status.py, mypy) passed. Recorded honestly: the first run reported thepy3hook itself asFailedwithfiles were modified by this hook, while its ownstestrtotals inside that same run already showed 0 failures andgit status/git diffshowed no tracked file changed beyond this edit's own doc changes. A second, unmodified rerun passed thepy3hook cleanly with no such report, so this was a one-off (most likely a first-run artefact oftoxreinstalling the package into a fresh environment) rather than a reproducible problem, and it is noted here rather than quietly rerun-until-green. - Met by this edit. The master plan's Execution table row, its
status line, and the
docs/plans/index.mdrow all say phase 5 is complete and agree on the arithmetic (6 of 8), checked bytools/check-plan-status.py.