Phase 6: Required and scalar semantics¶
Phase 6 of PLAN-api-input-validation.md,
following phase 5.
Phase 4 turned rejection on for everything except required-ness. Phase 5
deleted the broad except TypeError that had been absorbing the
consequences. This phase answers the question phase 3 deferred as
decision D17 and phase 4 left in place — whether a parameter declared
required is actually required — and gives the semantic type tokens
phase 2 added something to do beyond documenting themselves.
Planning effort: high. The code changes are small; deciding which of 75 declarations is telling the truth is not, and the previous phase demonstrated how to get a survey of exactly this shape wrong.
Review effort: high. A reviewer's job here is to find a parameter this phase marks required which some working client omits today.
Amendments¶
2026-09-13. Two of the issues this plan was written against moved between the planning commit and the phase starting. Both are corrected throughout the text below; this note records what changed and why, so a reader who remembers the original does not think the plan has drifted.
- #534 was fixed outside this plan.
PR #4183 landed
as
2fcc0467fon 2026-09-12, about four hours after the planning PR merged, and closed the issue. The automated issue fixer took it. Its fix is the one step 5 briefed and then some:MACADDR_PATTERNandvalid_macaddr()inshakenfist/util/network.py:417,ARGTYPES['macaddr']['pattern']repointed at the constant (base.py:408), the guard placed in_netdesc_safety_checks(instance.py:347) so both callers are covered — instance create atinstance.py:829and interface hotplug atinstance.py:1153, a drift test pinning the published pattern to the validator, and a guest CI case inguest_ci_tests/test_networking.py:122. Step 5 keeps only #323; definition-of-done item 9 is gone because it is already true. - #936 was closed as a duplicate. Closed 2026-09-12 as a duplicate
of #528 at a
narrower scope, in a cleanup sweep of the oldest open issues. The
work is unchanged — element schemas for
dictandarrayofdictare still unbuilt and still needed — so the phase 7 split stands on the same reasoning. Only the tracker moves: every reference below now cites #528, since a plan that sends an implementing agent to a closed issue wastes their first half hour.
This is the second phase running to lose scope this way. The master
plan already records phase 5 losing three of its four attribution
issues to the same fixer, and phase 6 named #534 in a merged plan
and still lost the race. The mitigation, for whoever files next: apply the
automated-fix-attempted label when filing, which reserves an issue
against the fixer while a branch is in flight.
Context¶
CompiledEndpoint records required-ness and never enforces it
(shakenfist/external_api/validation.py:82), and validate_request
filters it out of the enforceable set even on the default enforce
path (shakenfist/external_api/base.py:1906):
The docstring on CompiledEndpoint explains why, and names the
example: mode on the agent-put endpoint is declared required while
omitting it has always been accepted. The finding is still generated
and still logged, so the measurement apparatus has been running on the
default path since phase 4 — it is telemetry for this phase's decision,
which is what the comment says it is for.
Meanwhile every compiled field is allow_none=True, so an omitted
parameter and an explicit JSON null both reach the handler as None.
#4167, filed by
phase 5, is the consequence: a handler that dereferences such a
parameter without a type guard turns a caller's mistake into a recorded
500. That issue says in its own words that it is phase 6's work, and
this plan agrees — enforcing required is the systematic fix for the
top-level half of #4167, and the two should not be solved twice.
Scope¶
In:
- Decide, per declaration, whether
required=Trueis true, and correct the ones that are not. - Enforce required-ness once the declarations are honest.
- Attach real validators to the semantic type tokens, so
base64,netblock,ipv4,urlanduuidconstrain what they claim to (13 body/query declarations, enumerated below). - #3269 — reject
non-base64
user_dataat the API instead of on the hypervisor. - #323 — reject a network netblock which overlaps the floating network.
Out:
- #534 — the caller-supplied MAC address format. In scope when this plan was written, fixed by #4183 before the phase started; see Amendments.
- Element schemas for
dictandarrayofdict. This is the other half of #4167 and the whole of #528, and it is a vocabulary change the size of phase 2. It gets its own phase — see A phase split below. - Response validation (decision D7, out of scope for the plan, not deferred).
- The error shape, and in particular answering with more than one
finding at a time.
validate_request's docstring offers phase 6 that argument "if callers ask for it". No caller has. - #2094 — the
already-unrouted
DELETEreturning 403. Phase 5 ruled it out of scope as an idempotency question rather than a validation one, and nothing here changes that.
What the survey found¶
The master plan's one-line description of this phase is accurate as far as it goes. Everything below is detail it could not have had, plus one claim that is now wrong.
F1. Every declared-required body parameter is optional in fact¶
There are 349 parameter declarations. 147 are path, where required-ness
is a tautology — the route does not match without the segment. Of the
remaining 76 declared required=True, 75 are body or query parameters
whose handler gives them a default, and one (namespace on
AuthFederatedEndpoint.post) is consumed by a decorator rather than
named by the handler.
Not one is structurally required. Python would not have raised for a single omission.
The census is reproducible; the script is the appendix to this plan. Its shape matters as much as its answer: it asks whether the handler accepts an omission, which is the question that decides whether enforcement is a contract change. Finding F5 of phase 5 asked the adjacent question — whether any parameter lacked a default — got zero, and drew a conclusion the zero did not support. The two questions have the same code and opposite meanings.
So "enforce required" is a contract change for 75 declarations, and the only way to know which of them is safe is to find out what each handler does with the omission. That is step 1, and it is the bulk of this phase.
F2. The production numbers exist, and they are thin¶
26 days of sfcbr ({job="shakenfist"}, 2026-08-13 to 2026-09-08) carry
358 validation findings:
| Reason | Count | Notes |
|---|---|---|
type-mismatch |
336 | 294 of them the metadata value class phase 4 fixed by widening fourteen declarations to any; 40 on POST .../claims; 2 on the events limit |
missing-required |
20 | POST /auth (12) and POST .../claims (8) |
unknown-parameter |
2 |
Every one of the 20 missing-required findings was on a request that
already answered 400. Enforcing required would not have changed a
single observed outcome. Both routes are credential-handling, so
_handles_credentials() redacted the parameter names — the reason code
survives, the name does not.
Two cautions on this table. Every record carries validation-mode:
warn, and the newest is 2026-09-08, the day before phase 4 merged; no
post-enforce record exists, and sf-api start-up lines are not shipped to
this tenant, so the survey could not establish what mode sfcbr runs
today. And sfcbr's traffic is overwhelmingly CI, which exercises a
narrow slice of the API — 2 routes out of the 75 declarations at issue.
That is why this phase does not open a warn window; see D35.
F3. Exactly one type token validates anything¶
Phase 2 added unsignedinteger, macaddr, base64 and netblock and
an optional constraints element. _field()
(shakenfist/external_api/validation.py:112) compiles type,
minimum/maximum, pattern and items. It does not read format
at all, except to recognise the any sentinel.
Every semantic token therefore renders a format string into the
published OpenAPI and compiles to a bare fields.String:
| Token | Compiles to | Validates |
|---|---|---|
macaddr |
String + pattern | the format — the only one that does |
base64, netblock, ipv4, url, uuid, uuidorname, namespace, node |
String | that it is a string |
unsignedinteger |
Integer + Range(min=0) | the bound |
And macaddr, the one token that works, is declared nowhere. Its
only occurrence outside ARGTYPES is a comment in base.py:518 citing
it as the example of an anchored pattern.
The 13 body/query declarations using a semantic token, which are this phase's whole surface for D33:
| Endpoint | Method | Parameter | Token | Line |
|---|---|---|---|---|
ArtifactsEndpoint |
post | url |
url | artifact.py:434 |
ArtifactUploadEndpoint |
post | upload_uuid |
uuid | artifact.py:547 |
ArtifactUploadEndpoint |
post | blob_uuid |
uuid | artifact.py:550 |
ArtifactUploadEndpoint |
post | source_url |
url | artifact.py:555 |
AuthIssuersEndpoint |
post | jwks_uri |
url | auth.py:896 |
AuthIssuerEndpoint |
put | jwks_uri |
url | auth.py:954 |
ClusterOperationsEndpoint |
get | target_uuid |
uuid | clusteroperation.py:305 |
InstancesEndpoint |
post | user_data |
base64 | instance.py:512 |
InstancesEndpoint |
post | nvram_template |
url | instance.py:535 |
InstanceAgentPutEndpoint |
post | blob_uuid |
uuid | instance.py:1895 |
LabelEndpoint |
post | blob_uuid |
uuid | label.py:107 |
NetworksEndpoint |
post | netblock |
netblock | network.py:229 |
NetworkDNSAddressEndpoint |
post | value |
ipv4 | network.py:743 |
namespace (63 body uses) and node (3) are deliberately excluded: a
ref decorator resolves them against the database and answers 404, which
is a stronger check than a format one and already runs.
F4. #3269 is exactly as filed, four years on¶
user_data is declared base64 at instance.py:512, which as F3
establishes enforces nothing. The only b64decode of it is
shakenfist/instance.py:1930, inside config-drive creation, unguarded:
That runs on the hypervisor, after the API has answered 200 and the
instance has been scheduled and placed. A binascii.Error there is the
traceback the issue describes.
F5. #323 is not a format problem, and #534 was not a top-level one¶
Both needed saying, because "semantic validators for #534, #3269, #323" in the master plan reads as though all three are the same kind of work. They are not:
- #323 —
NetworksEndpoint.postcallsipaddress.ip_network()and refuses anything below /29 (network.py:56). There is no check against the floating network. It cannot be a schema check: whether a netblock overlaps depends on the deployed floating network, which is cluster configuration the compiled schema has no access to. It is a handler guard. - #534 — the caller-supplied MAC arrives inside a networkspec, as
netdesc['macaddress'], and is consumed atshakenfist/network/interface.py:143. At planning time_netdesc_safety_checksvalidatednetwork_uuidand the requested address range and never looked atmacaddress. Because networkspecs are declareddict/arrayofdict, no schema can reach it — see F6 — so it had to be a handler guard too. It now is one, added by #4183 rather than by this phase (see Amendments), which leaves the finding standing: the guard is atinstance.py:347precisely because the schema layer cannot see that far down.
F6. Nested structures are unvalidated by construction¶
dict compiles to fields.Dict() and arrayofdict to
fields.List(fields.Dict()), neither carrying a value schema. Every
key inside a diskspec, networkspec or videospec is invisible to the
validation layer in every mode. This is the structural cause behind
528, #534, and items 2 and 3 of #4167 — and #534 is the proof, since¶
the only way to reject a malformed MAC today is a hand-written guard in the handler.
It is real, it is worth fixing, and it is not this phase. See below.
F7. A phase split, and a correction to the master plan¶
The master plan's phase 6 row bundles required-ness with "semantic
validators for #534, #3269, #323, #936". The survey says the last does
not belong with the others: the first three are guards and scalar
validators, while #936 — now closed as a duplicate of
#528, which is
the tracker the rest of this plan cites — is a vocabulary change:
element schemas for dict and arrayofdict, plus a decision about
how much of InstancesEndpoint.post can become declarative at all,
given that roughly 130 of its lines interleave validation with blob,
label and artifact resolution that no schema can express.
This plan therefore splits phase 6 and renumbers the push audit.
The master plan's Execution table and the docs/plans/index.md row are
corrected as part of the planning commit:
| Phase | Was | Now |
|---|---|---|
| 6 | Required and semantics | Required and scalar semantics (this plan) |
| 7 | Push audit | Structured parameter schemas — #528, the declarative form of the #534 guard, #4167 items 2 and 3 |
| 8 | — | Push audit |
The index arithmetic moves from 6 of 8 to 6 of 9. The push audit
stays last by construction: it audits the accumulated diff of every
phase.
Nothing else in the phase description was wrong¶
The except TypeError narrowing the row once described belongs to
phase 5 and is done. The three attribution issues it also listed are
closed.
Sweep results¶
Step 1's measurement, and the evidentiary basis for steps 2 and 3.
Produced by shakenfist/tests/external_api/test_required_sweep.py at
a5f12bec5, which enumerates the declarations from
declarations.handlers() -- the census script in the appendix, not a
hand written list -- and drives one real authenticated request per row
through the whole decorator stack. Step 1 ran it in the default
enforce mode, which was then still exempting missing-required
findings; step 3 deleted that exemption and moved the file to warn,
which reproduces the same pre-enforcement answers and is what the table
is re-measured against on every run (see Progress). For
each declaration it sends a complete valid request (the control) and
then the same request with that one parameter removed, recording the
status and whether record_exception was called.
The controls are not incidental. A request which 404s because a fixture
was not built is indistinguishable, from the outside, from a handler
refusing an omission, and that is precisely the shape of mistake the
phase 5 survey made. Every recipe therefore declares the status its
complete request answers and the test asserts it on every run, so a
rotted fixture fails the test rather than quietly publishing a wrong
verdict. Four verdicts in a first draft turned out to be fixture
artefacts and were caught this way, including one -- blob_uuid on the
agent-put route -- where a blob stub that resolved any uuid made a
fault look like an acceptance.
The table is pinned in the test as SWEEP, so a handler which starts
answering an omission differently fails CI rather than silently
invalidating this evidence.
76 rows, the number the census reports. 75 of them are finding F1's declarations whose handler gives them a default; the 76th is the raw-body marker discussed below.
| Verdict | Count |
|---|---|
guarded -- the handler answers 4xx |
65 |
faults -- 5xx, or an exception was recorded |
5 |
accepted -- 2xx |
6 |
Step 1 measured 63 guarded and 7 faults. The review round moved two
rows by fixing the two faults which were bugs rather than symptoms of
required-ness; see What the sweep says that F1 did not below.
| Endpoint | Method | Parameter | In | Type | Status | Verdict | What the server does |
|---|---|---|---|---|---|---|---|
ArtifactMetadataEndpoint |
put | value |
body | any | 400 | guarded | no value specified |
ArtifactMetadatasEndpoint |
post | key |
body | string | 400 | guarded | no key specified |
ArtifactMetadatasEndpoint |
post | value |
body | any | 400 | guarded | no value specified |
ArtifactsEndpoint |
delete | confirm |
body | boolean | 400 | guarded | parameter confirm is not set true |
ArtifactsEndpoint |
post | shared |
body | boolean | 200 | accepted | defaults to False; the artifact is created unshared |
ArtifactsEndpoint |
post | url |
body | url | 500 | faults | Artifact.new() does source_url.split('/') (artifact.py:355) |
AuthEndpoint |
post | key |
body | string | 400 | guarded | missing key in request |
AuthEndpoint |
post | namespace |
body | string | 400 | guarded | missing namespace in request |
AuthFederatedEndpoint |
post | namespace |
body | string | 400 | guarded | no namespace specified |
AuthFederatedEndpoint |
post | rule |
body | string | 400 | guarded | no rule specified |
AuthFederatedEndpoint |
post | token |
body | string | 400 | guarded | no token specified |
AuthIssuerEndpoint |
put | audience |
body | string | 400 | guarded | no audience specified |
AuthIssuerEndpoint |
put | issuer_url |
body | string | 400 | guarded | no issuer_url specified |
AuthIssuerEndpoint |
put | jwks_uri |
body | url | 400 | guarded | no jwks_uri specified |
AuthIssuersEndpoint |
post | audience |
body | string | 400 | guarded | no audience specified |
AuthIssuersEndpoint |
post | issuer_url |
body | string | 400 | guarded | no issuer_url specified |
AuthIssuersEndpoint |
post | jwks_uri |
body | url | 400 | guarded | no jwks_uri specified |
AuthIssuersEndpoint |
post | name |
body | string | 400 | guarded | no name specified |
AuthMetadataEndpoint |
put | value |
body | any | 400 | guarded | no value specified |
AuthMetadatasEndpoint |
post | key |
body | string | 400 | guarded | no key specified |
AuthMetadatasEndpoint |
post | value |
body | any | 400 | guarded | no value specified |
AuthNamespaceClaimsEndpoint |
post | expires_in_seconds |
body | integer | 400 | guarded | no expires_in_seconds specified |
AuthNamespaceClaimsEndpoint |
post | limit_cpus |
body | unsignedinteger | 400 | guarded | no limit_cpus specified |
AuthNamespaceClaimsEndpoint |
post | limit_disk_gb |
body | unsignedinteger | 400 | guarded | no limit_disk_gb specified |
AuthNamespaceClaimsEndpoint |
post | limit_memory_mb |
body | unsignedinteger | 400 | guarded | no limit_memory_mb specified |
AuthNamespaceKeyEndpoint |
put | key |
body | string | 400 | guarded | no key specified |
AuthNamespaceKeysEndpoint |
post | key_name |
body | string | 400 | guarded | no key name specified |
AuthNamespaceRuleEndpoint |
put | bound_claims |
body | dict | 400 | guarded | missing required field(s): bound_claims |
AuthNamespaceRuleEndpoint |
put | issuer |
body | string | 400 | guarded | missing required field(s): issuer |
AuthNamespaceRuleEndpoint |
put | key_name_prefix |
body | string | 400 | guarded | missing required field(s): key_name_prefix |
AuthNamespaceRuleEndpoint |
put | key_ttl |
body | integer | 400 | guarded | missing required field(s): key_ttl |
AuthNamespaceRuleEndpoint |
put | scopes |
body | arrayofstring | 400 | guarded | missing required field(s): scopes |
AuthNamespaceRulesEndpoint |
post | bound_claims |
body | dict | 400 | guarded | missing required field(s): bound_claims |
AuthNamespaceRulesEndpoint |
post | issuer |
body | string | 400 | guarded | missing required field(s): issuer |
AuthNamespaceRulesEndpoint |
post | key_name_prefix |
body | string | 400 | guarded | missing required field(s): key_name_prefix |
AuthNamespaceRulesEndpoint |
post | key_ttl |
body | integer | 400 | guarded | missing required field(s): key_ttl |
AuthNamespaceRulesEndpoint |
post | name |
body | string | 400 | guarded | no name specified |
AuthNamespaceRulesEndpoint |
post | scopes |
body | arrayofstring | 400 | guarded | missing required field(s): scopes |
AuthNamespaceTrustsEndpoint |
post | external_namespace |
body | namespace | 400 | guarded | no external namespace specified |
AuthNamespacesEndpoint |
post | namespace |
body | string | 400 | guarded | no namespace specified |
BlobMetadataEndpoint |
put | value |
body | any | 400 | guarded | no value specified |
BlobMetadatasEndpoint |
post | key |
body | string | 400 | guarded | no key specified |
BlobMetadatasEndpoint |
post | value |
body | any | 400 | guarded | no value specified |
ClusterOperationsEndpoint |
get | target_object_type |
query | string | 400 | guarded | target_object_type parameter is required |
ClusterOperationsEndpoint |
get | target_uuid |
query | uuid | 400 | guarded | target_uuid parameter is required |
InstanceAgentExecuteEndpoint |
post | command_line |
body | string | 200 | accepted | queues an execute operation whose commandline is null |
InstanceAgentGetEndpoint |
post | path |
body | string | 200 | accepted | queues a get-file operation whose path is null |
InstanceAgentPutEndpoint |
post | blob_uuid |
body | uuid | 404 | guarded | blob not found (was a 500 until the review round fixed self.api_error) |
InstanceAgentPutEndpoint |
post | mode |
body | string | 406 | guarded | invalid mode: a mode must be a string (was a 500 until the review round refused a non-string mode) |
InstanceAgentPutEndpoint |
post | path |
body | string | 200 | accepted | queues put-blob and chmod operations whose path is null |
InstanceInterfacesEndpoint |
post | network |
body | dict | 400 | guarded | network specification should contain JSON objects |
InstanceMetadataEndpoint |
put | value |
body | any | 400 | guarded | no value specified |
InstanceMetadatasEndpoint |
post | key |
body | string | 400 | guarded | no key specified |
InstanceMetadatasEndpoint |
post | value |
body | any | 400 | guarded | no value specified |
InstancesEndpoint |
delete | confirm |
body | boolean | 400 | guarded | parameter confirm is not set true |
InstancesEndpoint |
post | cpus |
body | unsignedinteger | 500 | faults | InstanceData refuses a null cpus (instance.py:360) |
InstancesEndpoint |
post | disk |
body | arrayofdict | 400 | guarded | instance must specify at least one disk |
InstancesEndpoint |
post | memory |
body | unsignedinteger | 500 | faults | InstanceData refuses a null memory (instance.py:360) |
InstancesEndpoint |
post | name |
body | string | 400 | guarded | instance name must be specified |
InterfaceMetadataEndpoint |
put | value |
body | any | 400 | guarded | no value specified |
InterfaceMetadatasEndpoint |
post | key |
body | string | 400 | guarded | no key specified |
InterfaceMetadatasEndpoint |
post | value |
body | any | 400 | guarded | no value specified |
LabelEndpoint |
post | blob_uuid |
body | uuid | 500 | faults | add_index(None) raises BlobMissing (artifact.py:665) |
NetworkDNSAddressEndpoint |
delete | name |
body | string | 406 | guarded | invalid DNS name |
NetworkDNSAddressEndpoint |
post | name |
body | string | 406 | guarded | invalid DNS name |
NetworkDNSAddressEndpoint |
post | value |
body | ipv4 | 200 | accepted | stores hosteddns[name] = None and enqueues the dnsmasq update |
NetworkMetadataEndpoint |
put | value |
body | any | 400 | guarded | no value specified |
NetworkMetadatasEndpoint |
post | key |
body | string | 400 | guarded | no key specified |
NetworkMetadatasEndpoint |
post | value |
body | any | 400 | guarded | no value specified |
NetworksEndpoint |
delete | confirm |
body | boolean | 400 | guarded | parameter confirm is not set true |
NetworksEndpoint |
post | name |
body | string | 500 | faults | NetworkData refuses a null name (network.py:277) |
NetworksEndpoint |
post | netblock |
body | netblock | 400 | guarded | cannot parse netblock: None does not appear to be an IPv4 or IPv6 network |
NodeMetadataEndpoint |
put | value |
body | any | 400 | guarded | no value specified |
NodeMetadatasEndpoint |
post | key |
body | string | 400 | guarded | no key specified |
NodeMetadatasEndpoint |
post | value |
body | any | 400 | guarded | no value specified |
UploadDataEndpoint |
post | body |
body | binary | 200 | accepted | appends nothing and answers the unchanged length; this is the raw body marker, which never reaches required_names |
What the sweep says that F1 did not¶
Step 1 measured 7 faults, and two of them were bugs independent of
required-ness. The review round fixed both, which is why the table above
now reads 5. Each was a fault on far more than the omission path, and
each contradicted the response list the endpoint publishes -- which is
the class of defect this phase exists to close.
InstanceAgentPutEndpoint.postwrote its blob refusal asself.api_error(404, 'blob not found')(instance.py:1955).api_base.Resourcehas noapi_error, so any caller naming a blob which did not resolve -- not merely one who omitted the parameter -- got anAttributeError, a recorded exception and an opaque 500 instead of the documented 404. Enforcing required-ness would have closed the omission path and left the far more common bad-uuid path exactly as it was, so #4194 is fixed here instead: the call is nowsf_api.error(404, 'blob not found'), like every other refusal in the same handler, and the row measures404/guarded.- The
modeguard immediately above it wastry: int(mode) except ValueError, andint(None)raisesTypeError. This is the exampleCompiledEndpoint's docstring cites as a parameter "declared required while omitting it has always been accepted"; that was true whilehandle_authorization_exceptionsstill caughtTypeErrorand answered 400, and phase 5 deleted that arm (decision D23). It became a recorded 500, reachable underwarnandoffwhether or not the parameter was omitted. #4195 is fixed here by refusing a non-string mode with the406the endpoint publishes. Widening theexceptto(TypeError, ValueError)is not sufficient and was measured not to be: the symbolic fallback beneath it calls.split()on its argument, so a null falls out of it as anAttributeErrorand the 500 survives. An explicit type check is what closes the row.
The remaining five faults are a null reaching a constructor:
Artifact.new() splits source_url, Instance.new() and
Network.new() hand a null to a pydantic model which refuses it, and
Artifact.add_index(None) raises BlobMissing. All five are closed by
enforcing required-ness, which is D32's argument restated in
measurements -- and unlike the two above, none of them is reachable by a
caller who supplies the parameter, so there is nothing to fix in the
handler.
The 6 accepted are the contract change. Four of them are the three
agent routes, which cheerfully queue an operation carrying a null
commandline or path for the guest agent to fail on later; one is
NetworkDNSAddressEndpoint.post, which stores a DNS name pointing at
null; and one -- shared on ArtifactsEndpoint.post -- is a genuine
optional parameter with a sensible default which has simply been
declared wrongly. Only that last one is a declaration step 2 should
relax on the evidence here; the other five are cases where the
declaration is right and the handler is what is lenient.
The raw body is a special case, and F1 mis-identifies it. The census
counts one declaration which is not a handler keyword argument. F1 names
that as namespace on AuthFederatedEndpoint.post, "consumed by a
decorator rather than named by the handler". That is wrong: the
signature is post(self, token=None, namespace=None, rule=None), so the
parameter has a default like the other 74. The actual odd one out is
UploadDataEndpoint.post's body, declared with
api_base.RAW_BODY_PARAMETER. compile_parameters()
(validation.py:225) sets raw_body for it and continues before
adding anything to required_names, so this declaration can never
produce a missing-required finding and step 3's enforcement will never
reach it whatever step 2 does to the tuple. The counts F1 reports --
76, 75, 0, 1 -- are unchanged and were re-run on this branch; only the
example is corrected.
The better-message risk is smaller than feared. The plan's risks
section worries that enforcement replaces a handler's specific message
with a generic one. 65 of 76 rows are already a 4xx whose message names
the problem, so step 3 will replace 65 hand-written messages with
<parameter>: declared required but not supplied. (63 when step 1
measured; the two the review round fixed joined them.) Most are already
generic (no value specified on 14 rows, no key specified on
8), but three are materially more informative than the replacement and
are worth listing in step 6's release note:
parameter confirm is not set true (3 rows),
instance must specify at least one disk, and
cannot parse netblock: ....
Step 2: which declarations are really required¶
Step 2's output, and the list the second gate reviews.
One declaration moved. shared on ArtifactsEndpoint.post
(artifact.py:437) is now required=False. Every other declaration in
the sweep table keeps required=True, unchanged.
The rule, and the qualifier it carries¶
The step 2 brief states the rule mechanically -- guarded and faults
keep required=True, accepted becomes required=False. D35 states
it with a qualifier: "If the handler accepts the omission and does
something sensible, the declaration is wrong and gets corrected to
required=False." The six accepted rows are not alike on that
second clause, and the step is decided on it, which is also what the
sweep results section already concluded in prose ("only that last one
is a declaration step 2 should relax on the evidence here").
Four of the six answer 2xx and commit a broken operation: an agent
operation carrying a null commandline or path, or a DNS name
resolving to null. Nothing downstream of the API can do anything with
those but fail, at a point where the caller is no longer holding the
request. Publishing required=false for them would make that broken
acceptance the documented contract, and would deny step 3 the one
change that improves it -- turning a silent bad write into a 400 the
caller sees. Enforcing required-ness on a parameter the handler already
mishandles is not a contract regression; the request never worked.
Against that: an accepted verdict means some client could be omitting
the parameter today and getting a 200. D35's residual risk is exactly
this, and it is accepted here for the four, because what such a client
receives today is a queued operation that cannot succeed -- a 200 which
is not a success. The remaining case for relaxing them would be a
client that omits path and relies on the agent operation failing,
which is not a use.
The six, one at a time¶
| Endpoint | Parameter | Decision | Why |
|---|---|---|---|
ArtifactsEndpoint.post |
shared |
required=False |
The handler's signature is shared=False and the omission creates an unshared artifact, which is the documented default behaviour and what every client that never passes shared already relies on. A real optional parameter, declared wrongly. The only row where D35's "does something sensible" is satisfied. |
InstanceAgentExecuteEndpoint.post |
command_line |
keeps required=True |
Queues an execute operation whose commandline is null (instance.py:2097). The agent has no command to run; the operation exists only to fail. |
InstanceAgentGetEndpoint.post |
path |
keeps required=True |
Queues a get-file whose path is null (instance.py:2032). No file is named, so nothing can be fetched. |
InstanceAgentPutEndpoint.post |
path |
keeps required=True |
Queues put-blob and chmod whose path is null (instance.py:1961), after the blob has been resolved -- so a real blob is queued for delivery to nowhere. |
NetworkDNSAddressEndpoint.post |
value |
keeps required=True |
Stores hosteddns[name] = None and enqueues the dnsmasq rewrite (network.py:765). The record is written and the network reconfigured around an address that does not exist. |
UploadDataEndpoint.post |
body |
keeps required=True, and it is inert |
The raw-body marker. compile_parameters() sets raw_body and continues before required_names is touched (validation.py:228), so this declaration cannot produce a missing-required finding whatever its fifth element says, and step 3's enforcement will never reach it. There is also no such thing as omitting a request body -- the sweep's "omission" is a zero-length one -- so the published required: true is not a false claim. Left exactly as it is. |
The four kept rows carry a one-line comment at the declaration saying the handler does not refuse the omission today, because the next author to read that tuple will not have this plan open.
What this costs elsewhere¶
- The published specification changes by one line.
sharedleaves therequiredarray of thePOST /artifactsbody schema. The parameter-levelrequired: trueon the body object itself is unchanged, becauseurlis still required. Nothing else in the document moves, andtest_openapi_spec.pyneeded no newSTRUCTURED_PARAMETERSentry: that table is about structured and bounded types, andsharedis a plain boolean. - The sweep keeps measuring all 76.
required_declarations()is derived from the declarations, so a relaxed one would otherwise drop out of the census and take its verdict out of the pinned table with it.RELAXED_BY_STEP_2intest_required_sweep.pynames what step 2 moved and keeps it enumerated, and a new test pins the split in both directions -- so neither a quiet revert ofsharednor a later mechanical sweep of the four kept rows can happen without that file saying so. No verdict changed; no handler was touched. - The census script in the appendix now prints 75, not 76. It knows
nothing about
RELAXED_BY_STEP_2, and one declaration it used to count is no longer declared required. Definition-of-done item 1 is a statement about step 1's deliverable and is unaffected; a reader re-running the script after step 2 should expect 75. test_request_validation.pylost its example.test_an_omitted_required_parameter_still_reaches_the_handlerusedsharedprecisely because it was declared required and optional in fact. It is repointed atcommand_lineon the agent execute route, which is a sharper example of the same property and asserts the nullcommandlinethe omission queues.
Definition of done item 3¶
Item 3 as written -- "every declaration still carrying required=True
in a body or query location has a row in the sweep table whose
verdict is not accepted" -- is the mechanical rule, and this step
does not satisfy it: four accepted rows deliberately keep
required=True. It was written before the sweep existed, when
accepted was expected to mean "the handler copes". It does not mean
that in four cases out of six. The item should read: every declaration
still carrying required=True in a body or query location has a
row in the sweep table, and any row whose verdict is accepted has a
recorded reason in this section for staying required.
Messages and statuses enforcement will change¶
For step 6's release note, and the Risks section's instruction to
count them. After step 3, an omitted required parameter answers
400 <parameter>: declared required but not supplied. 65 rows are
already a 4xx, and on 57 of them the existing message is a bare
restatement of the same fact (no value specified on 14 rows, no key
specified on 8, missing required field(s): X on 10, no X
specified on most of the rest), so the replacement loses nothing.
Materially worse, all of them only on the pure-omission path:
| Request | Today | After step 3 |
|---|---|---|
DELETE /artifacts, /instances, /networks with no confirm |
400 parameter confirm is not set true |
400 confirm: declared required but not supplied |
POST /instances with no disk |
400 instance must specify at least one disk |
400 disk: declared required but not supplied |
POST /instances/<i>/interfaces with no network |
400 network specification should contain JSON objects |
400 network: declared required but not supplied |
Each of those three messages survives for the case that actually
carries the information: confirm: false, disk: [] and a malformed
networkspec all still reach the handler and still get the specific
message. Only the omission, where the specific message was not telling
the caller anything the generic one does not, changes. That is five
rows, and on the Risks section's threshold ("more than two or three is
an argument to revisit") it is worth the gate looking at, but three of
the five are the same message on three routes.
A status code change, which is not a message change and is worth the release note stating separately:
| Request | Today | After step 3 |
|---|---|---|
POST/DELETE /networks/<n>/dns with no name |
406 invalid DNS name |
400 name: declared required but not supplied |
And two improvements, recorded so the release note is not one-sided:
POST /networks with no netblock currently answers 400 cannot parse
netblock: None does not appear to be an IPv4 or IPv6 network, which
tells the caller the server saw a None it should never have been
sent; and every one of the 5 remaining faults rows stops being a 500
with an exception record and becomes a 400 naming the parameter. The two
faults the review round fixed are a larger improvement still, because
they were 500s on paths a caller reaches without omitting anything.
Step 4: how wide each format really is¶
F3's set of thirteen is unchanged -- re-derived from ARGTYPES and
declarations.handlers() rather than from the table's line numbers,
which drifted by up to sixteen lines when step 2 added comments to the
declarations it touched. The same thirteen endpoints, methods and
parameter names; uuidorname still has no body or query use at all,
and macaddr still has none.
The step's whole risk is a validator stricter than its handler, so what each handler accepts today was established by reading it, and by reading what the functional suite and the shipped client send. The answers, and what each validator does with them:
| Declaration | What the handler accepts today | Validator |
|---|---|---|
InstancesEndpoint.post.user_data |
whatever base64.b64decode() decodes at the config drive (instance.py:1930), which in practice means base64 wrapped at 76 columns |
b64decode(''.join(value.split()), validate=True) |
NetworksEndpoint.post.netblock |
whatever ipaddress.ip_network() parses; the handler already 400s on a ValueError |
ip_network(value) |
NetworkDNSAddressEndpoint.post.value |
anything at all -- the handler never looks at it, and it is rendered straight into dnsmasq's hosts file by dnshosts.tmpl |
ip_address(value) |
ArtifactsEndpoint.post.url |
a URL or an image shortcut with no scheme: cirros, cirros:0.4.0, ubuntu:20.04, expanded by images._resolve_image() |
urlparse(value) only |
ArtifactUploadEndpoint.post.source_url |
anything; it is the URL the artifact claims, and its own default is sf://upload/<ns>/<name> |
urlparse(value) only |
InstancesEndpoint.post.nvram_template |
label:<name>, sf://blob/<uuid>, or anything else passed through to Blob.from_db() |
urlparse(value) only |
AuthIssuersEndpoint.post.jwks_uri, AuthIssuerEndpoint.put.jwks_uri |
only https://..., enforced by _validate_issuer_arguments() in the handler |
urlparse(value) only |
ArtifactUploadEndpoint.post.upload_uuid, .blob_uuid; InstanceAgentPutEndpoint.post.blob_uuid |
any string; a miss in the following from_db() is a 404 |
uuid.UUID(value) |
LabelEndpoint.post.blob_uuid |
any string, unchecked: it goes to Artifact.add_index() and the route answers 200 with a label version pointing at a blob that does not exist |
uuid.UUID(value) |
ClusterOperationsEndpoint.get.target_uuid |
any string; a miss in cls.from_db() is a 404 |
uuid.UUID(value) |
Three of those readings changed what the step would otherwise have built.
base64 strips whitespace and then validates strictly. The step
first wrote this as a lenient b64decode(value), reasoning that
validate=True refuses base64 wrapped at 76 columns -- which is what
base64 user-data.yaml emits and therefore what a caller running
sf-client instance create -U "$(base64 user-data.yaml)" sends, and
which the config drive decodes today. That reasoning is half right and
the conclusion was wrong, and the review of this phase caught it.
b64decode's default does not merely tolerate the newlines in a wrapped
value: it discards every character outside the alphabet, so it throws
away the hashes, colons and spaces of raw cloud-config too and decodes
whatever alphabet characters are left. Whether a raw payload is refused
is then a coin flip on the filtered length modulo 4. Measured over five
realistic raw cloud-config samples, two passed -- so the lenient
validator left most of #3269 open.
Stripping the whitespace before a strict decode gets both properties
at once: a wrapped value survives the strip and decodes, and raw
cloud-config reaches a strict decoder with its non-alphabet characters
intact and is refused deterministically. All five samples are refused,
and the wrapped output of base64(1) is still accepted. Three tests
pin it: test_byte_takes_what_the_config_drive_decodes and
test_wrapped_base64_user_data_still_reaches_placement hold the
wrapped half, and
test_byte_refuses_raw_config_the_lenient_decode_accepted carries the
two samples which used to pass, asserting first that the lenient decode
accepts them so the sample cannot silently stop demonstrating the
hole.
url cannot require a scheme, and so barely validates. Of the
five declarations it covers, only jwks_uri is a web URL, and the
handler already enforces https:// there. The other four take
scheme-less shortcuts (cirros), non-hierarchical schemes
(label:mylabel) and Shaken Fist's own sf:// family. What is left
that is still a check is "urllib.parse can parse it", which in
practice refuses an unbalanced bracket in the authority and nothing
else. That is thin, and it is recorded here as thin rather than
dressed up: the alternative considered was leaving url out of
_FORMATS entirely, which the step brief permits. It is in because
the rule it enforces is the stdlib's own definition of "not a URL" and
costs nothing, not because it closes anything.
target_uuid is safe for a reason that had to be checked. It is
the one uuid declaration that could have been carrying a name, since
namespace and node are keyed by name rather than uuid and
get_object_class() resolves both. It is not: every object type a
cluster operation can target comes from a target_fields declaration
in shakenfist/schema/operations/, and the complete set is ARTIFACT,
INSTANCE, BLOB, NETWORK, INTERFACE and AGENTOPERATION.
Two smaller notes. ipv4 compiles to ipaddress.ip_address() rather
than IPv4Address, so it accepts IPv6 as well as the published format
promises -- the value's only consumer is a hosts file, which takes
either, and enforcing narrower than the server is the failure this
step exists to avoid. And netblock accepts a bare address, because
ip_network('10.0.0.0') is a /32: the handler takes it and then
refuses it for being below the minimum size of /29, which is a policy
about how small a network may be and stays in the handler (D34).
Statuses this changes, in addition to the messages table above: a
non-uuid blob_uuid, upload_uuid or target_uuid that used to
answer 404 from a from_db() miss now answers 400 from the validation
layer. Both are refusals of a string that could never have named an
object, and warn and off restore the 404.
Decisions¶
D32. The declarations get corrected before required-ness is enforced, not after. F1 says all 75 are optional in fact, which means the declarations and the handlers disagree in every single case — so "enforce what is declared" would be enforcing something nobody has checked. Step 1 classifies each parameter by what the handler actually does with the omission, and step 2 rewrites the declarations to match. Only then does enforcement turn on. This is the same shape as phases 3→4: fix the declarations the measurement found wrong, then reject.
D33. Semantic validators attach to the published format string,
not to a parallel table. _field() grows a _FORMATS mapping keyed
on the exact string ARGTYPES renders, so what the OpenAPI document
promises and what the server enforces are the same string by
construction. This is the argument ANY_VALUE_FORMAT already makes in
its own comment, generalised. A validator rather than a pattern,
because the netblock comment in base.py:409 is right that a CIDR
regex would describe the API as narrower than ipaddress.ip_network()
makes it — a function can call ip_network() and be exactly as wide as
the server.
D34. Handler guards stay, even where enforcement makes them
unreachable. warn and off are the operator's rollback and hand
the handler whatever the caller sent, so a guard deleted as "dead" is a
500 waiting for the next rollback. Phase 5 established this precedent
deliberately in InstancesEndpoint.post, where the isinstance(name,
str) arm is documented as unreachable under enforce and kept anyway.
The new guard for #323 is written to the same standard, and #4183's
MAC guard already meets it — it runs in the handler, so it holds in
every mode.
D35. No warn window for required-ness. This is the departure from the phase 3→4 pattern and the decision most likely to be argued with.
The case for a window: enforcing required is the change the master plan itself flags as most likely to break working clients, and every previous tightening in this plan was measured before it was turned on.
The case against, which this plan takes: a warn window measures which omissions happen, and the question is which omissions the server accepts. F2 is the evidence — 26 days of production produced 20 missing-required findings across 2 routes, every one on a request that already answered 400, leaving 73 of the 75 declarations untouched by any traffic at all. Step 1's sweep exercises all 75 deterministically and answers the question directly. Measurement by traffic is the weaker instrument here, and waiting for it would defer the phase by weeks to learn less.
The residual risk is a caller that omits a parameter the sweep judges
required. Step 1 resolves that class toward the caller: a parameter
is only marked required if the handler refuses the request without it
today. If the handler accepts the omission and does something sensible,
the declaration is wrong and gets corrected to required=False —
which also removes a false claim from the published OpenAPI.
D36. The sweep drives real requests, not the AST. #4167's closing
note asks for this by name, and phase 5's F5 is why. The apparatus
already exists: AuthenticatedStackTestCase in
test_request_validation.py drives authenticated requests through the
whole decorator stack, which is what caught the difference between a
handler tested in isolation and the deployed behaviour in phase 3's
review.
D37. missing-required keeps its own finding reason after
enforcement. It would be tidier to fold it into the enforceable set
and delete the filter, but the reason code is what makes the 20 events
in F2 legible, and an operator rolling back to warn needs to see
required failures distinguished from type failures. The filter goes;
the reason code stays.
D38. #528 does not get a partial answer here. Declaring a diskspec's shape without also handling the resolution logic tangled through it would leave two validation paths for one parameter, which is the state this whole plan exists to end. Phase 7 takes it whole.
Step plan¶
| Step | Effort | Model | Isolation | Brief for sub-agent |
|---|---|---|---|---|
| 1 | xhigh | opus | worktree | Classify all 75. Write shakenfist/tests/external_api/test_required_sweep.py, driving a real request per declaration through AuthenticatedStackTestCase (test_request_validation.py; test_instance_create_validation.py is the worked example, including its mock.patch.object(instance_api, 'SCHEDULER', None) containment — copy it, an escaped Scheduler breaks unrelated tests with a 507). Enumerate the 75 from declarations.handlers(), not by hand; the census script is the appendix below and its _defaults() is the shape to reuse. For each, send an otherwise-valid body with that one parameter omitted, and record the status and whether record_exception was called. Emit a table into docs/plans/PLAN-api-input-validation-phase-06-required.md under Sweep results with one row per parameter and a verdict of guarded (handler answers 4xx), faults (5xx or an exception record) or accepted (2xx/3xx). Do not change any declaration in this step. Commit subject: Ask every handler what an omission does. |
| 2 | high | opus | none | Correct the declarations. Using step 1's table: guarded and faults keep required=True; accepted becomes required=False, with a one-line comment on any that is surprising. Touch only the fifth element of the declaration tuples. tools/fix-api-parameter-locations.py does not do this, so it is by hand; test_parameter_declarations.py and test_openapi_spec.py must both stay green, and the published specification changes, so expect test_openapi_spec.py's expectations to need updating. Commit subject: Say which parameters are really required. |
| 3 | medium | sonnet | none | Enforce it. Delete the MISSING_REQUIRED filter at shakenfist/external_api/base.py:1906 so a missing-required finding is enforceable like any other, keeping the reason code (D37). Rewrite the CompiledEndpoint docstring at validation.py:82 and the validate_request docstring at base.py:1817, both of which say required-ness is recorded and never enforced and both of which name phase 6 as the decider. Add tests that an omitted required parameter answers 400 naming the parameter, that an explicit JSON null does the same, and that under warn it answers whatever it answered before. Commit subject: Refuse a request missing a required parameter. |
| 4 | high | opus | none | Make the type tokens mean something. Add a _FORMATS table to validation.py keyed on the exact format strings in ARGTYPES (byte, a CIDR netblock, an IPv4 address as a string, url, uuid), each mapping to a validator function, and consult it in _field() alongside the existing pattern handling. Use base64.b64decode(..., validate=True), ipaddress.ip_network(), ipaddress.ip_address(), urllib.parse.urlparse() and uuid.UUID() rather than regexes — D33 explains why. Every validator must be a no-op on None (fields are allow_none=True and required-ness is step 3's business, not this one's) and must raise marshmallow.ValidationError, never let a library exception escape. The 13 affected declarations are tabulated in F3; check each still accepts what its handler accepts today, particularly nvram_template and source_url, which may carry scheme forms a strict URL parser would refuse. This closes #3269. Commit subject: Enforce the formats the API publishes. |
| 5 | medium | sonnet | none | Guard the one that no schema can reach. In NetworksEndpoint.post (network.py:42), after the existing /29 check, refuse a netblock overlapping the floating network — read it the way network.floating_network() does at network.py:707, and answer 400 naming the conflict; if no floating network is configured, do not refuse. It keeps working under warn/off (D34). Closes #323. Unit tests, plus a cluster CI case for the overlap in shakenfist/deploy/shakenfist_ci/cluster_ci_tests/. The MAC guard this step also briefed landed in #4183 (see Amendments); _netdesc_safety_checks already refuses a malformed macaddress at instance.py:347, so do not add a second check — if anything here needs the published pattern, use util_network.MACADDR_PATTERN. Commit subject: Refuse an overlapping netblock. |
| 6 | medium | sonnet | none | Documentation and close-out. docs/developer_guide/writing_an_endpoint.md currently tells authors required-ness is not enforced — correct it, and say what a format token now costs them. Add a v07-v08 release note entry covering the required-ness change, the format enforcement, and the new netblock guard, listing any declaration step 2 moved to required=False that a caller could notice. Update the master plan's Execution table, the phase split from F7, and the docs/plans/index.md row. Commit subject: Document what required now means. |
Risks and mitigations¶
A parameter marked required that a real client omits. The one that matters. Mitigated by D35's rule — required only where the handler refuses today — so a client omitting it is already failing. The reviewer's specific job (stated in the review effort note) is to look for a counter-example. Residual: a client that omits a parameter and relies on the resulting 4xx changing shape. Accepted.
The sweep repeats phase 5's F5. The same family of analysis over the same declarations, and it got the wrong answer last time by asking a well-formed question about the wrong property. Mitigated by D36 — driving real requests answers "what does the server do" directly rather than by inference — and by step 1 producing a table the management session can spot-check by hand against three handlers it picks.
Enforcement degrades a better error message. POST /instances with
no disk answers instance must specify at least one disk today and
would answer disk: declared required but not supplied. Mitigated
weakly: the parameter is named either way and the shape is unchanged
(D4). Step 2 should note any message it judges materially worse, and
step 6 should list them; if there are more than two or three, that is
an argument to revisit.
A format validator is stricter than the handler. url in
particular: urlparse() accepts almost anything, but a validator that
demands a scheme would refuse nvram_template values the handler
accepts. Mitigated by step 4's instruction to check each of the 13
against its handler, and by the functional suite, which creates
artifacts from URLs.
The published OpenAPI changes shape. Moving declarations to
required=False changes the specification, which test_openapi_spec.py
validates and which downstream consumers read. Mitigated: this is a
correction, not a regression — the specification currently claims things
the server does not require. Step 6's release note says so explicitly.
Definition of done¶
docs/plans/PLAN-api-input-validation-phase-06-required.mdcarries a Sweep results table with one row for each of the 75 declarations from F1, each with a verdict ofguarded,faultsoraccepted, and the count of rows equals the count the census script reports.grep -n "MISSING_REQUIRED" shakenfist/external_api/base.pyshows no line that filters it out of an enforceable set.- Every declaration still carrying
required=Truein abodyorquerylocation has a row in the sweep table, and any row whose verdict isacceptedhas a recorded reason for staying required. (Amended by step 2, which found fouracceptedrows where the handler accepts the omission and commits a broken operation, so D35's "does something sensible" qualifier keeps them required. The original wording, which said noacceptedrow may stay required, is the mechanical rule without that qualifier -- see Step 2: which declarations are really required.) - A test proves that omitting a required body parameter answers 400
naming that parameter, and that sending it as an explicit JSON
nullanswers the same; both mutation-tested by restoring the filter from item 2, and both failing when it is restored. - A test proves that under
API_VALIDATION_MODE=warnan omitted required parameter answers what it answered before this phase, for at least oneguardedand onefaultsparameter. POST /instanceswith auser_datathat is not valid base64 answers 400, andshakenfist/instance.py:1930is never reached with it — pinned by a test, not by reading.- Each of the five format validators has a test for a value it accepts
and a value it refuses, and a test that
Nonepasses through untouched. POST /networkswith a netblock overlapping the configured floating network answers 400, and with no floating network configured answers what it answers today.-
3269 and #323 are closed, each closing comment naming the commit¶
and the check that closed it. (#534 was closed by #4183 before this phase started; a networkspec carrying"macaddress": "not-a-mac"already answers 400 rather than reachingshakenfist/network/interface.py:143, andshakenfist/tests/test_macaddr_validation.pyholds it there.) - No page states required-ness differently: the
CompiledEndpointdocstring, thevalidate_requestdocstring,docs/developer_guide/writing_an_endpoint.mdand the v07-v08 release note all say it is enforced. - The master plan's Execution table has nine rows, phase 7 is
Structured parameter schemas, phase 8 is the push audit, and the
docs/plans/index.mdrow reads6 of 9. pre-commit run --all-filesis clean.
Definition-of-done audit (step 6)¶
- Met. The Sweep results table above carries 76 rows (65
guarded, 5faults, 6acceptedafter the review round; 63/7/6 as step 1 measured them), matching whattest_the_census_still_finds_seventy_sixandtest_the_sweeppin againstrequired_declarations()/SWEEPinshakenfist/tests/external_api/test_required_sweep.py. The item's own wording says "75 declarations from F1"; F1's own correction (What the sweep says that F1 did not) already notes the true count is 76 (75 defaultable plus the raw-body marker), so the table is right and the item's prose is the thing carrying the stale number, not the deliverable. - Met.
grep -n "MISSING_REQUIRED" shakenfist/external_api/base.pyreturns nothing at all now — not merely no filtering line, the name is not present in the file. - Met, as amended.
sharedonPOST /artifactsisrequired=False(shakenfist/external_api/artifact.py:437, with the comment step 2 added). The four otheracceptedrows (InstanceAgentExecuteEndpoint.post.command_line,InstanceAgentGetEndpoint.post.path,InstanceAgentPutEndpoint.post.path,NetworkDNSAddressEndpoint.post.value) keeprequired=Truewith the reasoning recorded in Step 2: which declarations are really required, andtest_step_2_relaxed_exactly_what_it_said_it_didpins the split in both directions. - Met, with one caveat on how it was checked. The omission and
explicit-
nulltests (test_enforce_mode_rejects_missing_required,test_enforce_mode_rejects_an_explicit_null_the_same_wayintest_request_validation.py) pass today, and by inspection the filter step 3 deleted is exactly what stood between them and a fall-through to the handler — restoring the[f for f in findings if f.reason != validation.MISSING_REQUIRED]guard makes both a no-op path when the only finding is missing-required, so both tests would fail against it. I did not re-run that reversion live in this sandbox (a mid-session policy restriction blocked writing a temporary revert to production code from this documentation-only step), so this is verified by reading the diff rather than by executing the mutation myself; the step 3 commit message records that the author did run it. - Met.
RequiredSweepTestCaseintest_required_sweep.pynow runs atmode = 'warn'and asserts, for all 76 rows including the 5faultsand 6acceptedpopulations, that the response is unchanged from the publishedSWEEPtable — not just the two the step brief named.test_warn_mode_still_answers_the_handlers_own_messageintest_request_validation.pyis the single readable pin for theguardedcase. - Met.
test_unencoded_user_data_is_refused_before_the_hypervisordrives a realPOST /instanceswith unencoded cloud-config, asserts 400 naminguser_data, and assertsInstance.newwas never called — the only path toinstance.py:1930. - Met. Each of the five validators in
shakenfist/tests/external_api/test_format_validation.pyhas an accept test, a refuse test, and sharestest_every_validator_passes_none_through_untouched, which checks all five in one pass. - Met.
NetworkCreateFloatingOverlapTestCaseintest_network.pycovers identical, contains, contained-by and no-floating-network-configured cases, plustest_a_partial_overlap_cannot_be_expressedrecording why no fifth case exists. - Not yet met, deliberately. Per this step's brief I have not
closed either issue. #3269 and #323 both carried
Fixes #NNNNin their commits and auto-closed on merge. This item originally named those commits as03ea26514andd6b84b365, which is what they were before #4199 was rebased ontodevelopshortly before it merged; those objects exist in no clone but the one that wrote them. They landed asec406a78aandc7a432886. #4222 corrected the same pair in the master plan and did not reach this file; phase 8's survey found it here. A SHA recorded while a branch is in flight is a SHA a rebase can invalidate — read them off the first-parent range after the merge.
The commit and the check that proves each is named in my
final report and in the master plan's Where the tracked issues
stand section, for the management session to post as closing
comments. #534 was already closed by #4183 before this phase
started, as the item itself notes.
10. Met. CompiledEndpoint's and validate_request's docstrings
were rewritten in step 3;
docs/developer_guide/writing_an_endpoint.md's required-ness
paragraph and docs/release_notes/v07-v08.md were corrected in
this step (two stale in-progress mentions of "required is recorded
and never enforced" earlier in the release note were also updated
to point at the new entry, rather than left standing as
contradictions).
11. Met, except the index number is different from the item's own
wording. The master plan's Execution table has nine rows, phase
7 is Structured parameter schemas, phase 8 is Push audit. The
item says the index row should read 6 of 9, but that number was
fixed as part of the planning commit before this phase's work
started, when 6 phases (0-5) were complete; completing phase 6
itself makes 7 phases complete, and every other "In progress" row
in docs/plans/index.md counts completed phases, not the phase
number in flight (compare PLAN-scheduler-reservations.md's
11 of 14, PLAN-cluster-op-visibility.md's 2 of 7). I have set
it to 7 of 9 rather than the 6 of 9 the item names, per this
step's own instruction not to leave the arithmetic unchanged when
the completed-phase count genuinely changed.
12. Met. pre-commit run --all-files and the full stestr suite
were run for this step; results are in the final report.
Back brief¶
Before executing any step, back brief the operator on the understanding of this plan and how the intended work aligns with it.
Two gates, both before code moves:
- After step 1, before step 2. The sweep table is the entire evidentiary basis for the phase, and step 2 acts on it irreversibly. Present the table, the counts by verdict, and any parameter whose verdict surprised the agent. The management session picks three rows and checks them by hand against the handlers before step 2 starts.
- After step 2, before step 3. Enforcement is the contract change.
Present the list of declarations moved to
required=Falseand the list still required, and confirm the split before the filter is deleted.
Appendix: the census script¶
Run from a checkout root with .tox/py3/bin/python. It answers F1, and
step 1 enumerates its 75 rows rather than a hand-written list. Keeping
it here rather than in a scratch directory is deliberate: the number it
produces is the premise of D32 and D35, and a reviewer who cannot re-run
it has to take those on trust.
import ast
import sys
sys.path.insert(0, '.')
from shakenfist.external_api import declarations
def _defaults(fn):
"""Parameter names of fn which have a default, so may be omitted."""
args = fn.args
positional = args.posonlyargs + args.args
out = set()
if args.defaults:
for a in positional[-len(args.defaults):]:
out.add(a.arg)
for a, d in zip(args.kwonlyargs, args.kw_defaults):
if d is not None:
out.add(a.arg)
return out, {a.arg for a in positional} | {a.arg for a in args.kwonlyargs}
rows = []
for _, _, cls, fn in declarations.handlers():
have_default, accepted = _defaults(fn)
for dec in fn.decorator_list:
if 'swagger_helper' not in ast.unparse(dec):
continue
call = dec.args[0] if isinstance(dec, ast.Call) and dec.args else None
if not (isinstance(call, ast.Call) and len(call.args) >= 3
and isinstance(call.args[2], ast.List)):
continue
for item in call.args[2].elts:
if not (isinstance(item, ast.Tuple) and len(item.elts) in (5, 6)):
continue
name = declarations.literal(item.elts[0])
location = declarations.literal(item.elts[1])
argtype = declarations.literal(item.elts[2])
required = declarations.literal(item.elts[4])
if location not in ('body', 'query') or required is not True:
continue
rows.append((cls.name, fn.name, name, location, argtype,
name in have_default, name in accepted))
print('declared required, body/query:', len(rows))
print(' handler gives it a default (omitting works today):',
len([r for r in rows if r[5]]))
print(' no default (already effectively required):',
len([r for r in rows if not r[5] and r[6]]))
print(' not a named handler kwarg:', len([r for r in rows if not r[6]]))
for r in sorted(r for r in rows if r[5]):
print(' %-34s %-8s %-22s %s' % (r[0], r[1], r[2], r[4]))
As of 92b9caf12 this prints 76, 75, 0, 1. Ask it the other question --
which parameters lack a default -- and it prints zero, which is finding
F5 of phase 5 and is true and useless. The difference is one not.
Progress¶
Step 1 done: the sweep is
shakenfist/tests/external_api/test_required_sweep.py and its table
is published above under Sweep results.
Step 2 done: one declaration moved to required=False, and the
reasoning for each of the six accepted rows is above under Step 2:
which declarations are really required.
Step 3 done: the MISSING_REQUIRED filter at base.py:1914 is
deleted, so every finding is enforceable and the reason code survives
only as telemetry (D37). An explicit JSON null on a required
parameter is now treated the same as its absence (validation.check()),
which the CompiledEndpoint and validate_request docstrings no
longer contradict. RequiredSweepTestCase now runs at warn rather
than enforce -- an enforce run would answer the layer's own generic
refusal for every omission before any handler saw it, which is not the
question that file measures -- so its 76-row sweep is now also the
evidence for definition-of-done item 5, not merely the guarded/faults
pair the step brief names. Tests were added or corrected in
test_request_validation.py, test_instance_create_validation.py,
test_auth.py and test_external_api.py for every message the
Messages and statuses enforcement will change table above predicted.
Step 4 done: validation._FORMATS gives five published format
strings a validator apiece and _field() consults it, which closes
3269. The width each validator was written to, and the three readings¶
that changed what it does, are above under Step 4: how wide each
format really is. test_format_validation.py holds each validator to
an accepted value, a refused value and a None passed through
untouched (item 7), and drives the unencoded-cloud-config body of
3269 through a real POST /instances to prove the config drive¶
decode is never reached with it (item 6).
test_validation_compiler.py's format test was inverted: it used to
assert that no format ever becomes a validator, and now names all
thirteen declarations that do.
Step 5 done: NetworksEndpoint.post refuses a netblock overlapping the
deployed floating network with a 400 naming both blocks, closing #323.
It reads the floating network the way network.floating_network()
does, without creating one, and falls back to config.FLOATING_NETWORK
when no floating network row exists yet -- the row wins when both
exist. shakenfist/tests/external_api/test_network.py's
NetworkCreateFloatingOverlapTestCase covers the identical, contains,
contained-by and no-floating-network cases and records why a partial
overlap cannot be expressed (CIDR prefixes form a tree); a cluster CI
case was added to
shakenfist/deploy/shakenfist_ci/cluster_ci_tests/test_networking.py.
Only the floating network is guarded, as the plan scoped -- other
reserved ranges (node egress, mesh addressing) are untouched. The MAC
guard step 5 also briefed had already landed in #4183 before the phase
started (see Amendments); nothing further was added for it.
Step 6 done: this document, docs/developer_guide/writing_an_endpoint.md
and docs/release_notes/v07-v08.md all now say required is enforced,
the master plan's Execution table has nine rows with phase 6 marked
Complete, and the docs/plans/index.md row reads 7 of 9. The phase is
complete; see Definition of done, below, for the item-by-item audit.
Review round done: the automated reviewer's findings on PR #4199 were worked through and eleven of the twelve applied. What changed, and what did not:
- The base64 validator was wrong, and the plan overstated what it
closed.
b64decodewith its default arguments discards every character outside the alphabet, so it decoded raw cloud-config too whenever the filtered length happened to be a multiple of four -- two of five realistic samples. Stripping the whitespace and then decoding withvalidate=Truerefuses all five and still accepts the wrapped output ofbase64(1). See Step 4: how wide each format really is. - The two bug rows in the sweep were fixed rather than pinned
(#4194 and
#4195), which
moves the verdict table to 65
guardedand 5faults. Both were faults on paths a caller reaches without omitting anything, and both contradicted the endpoint's own published response list. The reviewer's suggested one-token widening ofexcept ValueErrortoexcept (TypeError, ValueError)was measured and does not close #4195: the symbolic fallback beneath it raisesAttributeErroron a null, so the 500 survives. An explicit type check is what closes it. - The overlap guard moved below the authorisation check, so an unauthorised create gets its 401 rather than a 400 naming the cluster's floating netblock. The netblock parse stays above it: that is a pure input check and can answer anyone. The guard also no longer turns itself off when the floating network row exists but carries an empty netblock, and it now logs a warning when an unparseable configuration makes it fail open.
- Functional coverage was added for both contract changes.
cluster_ci_tests/test_api_validation.pynow drives a delete-all with noconfirm, raw cloud-config asuser_data, and -- the regression that matters -- a wrapped base64user_datainstance which must still boot. The last one is the only honest test of the format validator's width: the decode it stands in for happens on a hypervisor, in another daemon, from a config drive no unit test builds. - The sweep now reads declarations through
declarations.declarations(). It had grown its own copy of that AST walk to reach the type token, whichDeclarationdid not carry;test_parameter_declarations.pyhad grown a second copy for the same reason.argtypeis now a field onDeclarationand both copies are gone, so there is one reading of a declaration tuple in the tree. The copies also silently skipped a declaration they could not read statically, where the shared helper returns a marker row --required_declarations()now raises on one by name, because a census which claims completeness must not skip what it could not parse. KNOWN_DEFECTSwas considered and not added. The reviewer asked that thefaultsrows be labelled so a developer who fixes one gets a helpful message rather than "you broke something". The two rows that argument was about are the two fixed above. The five that remain are all a null reaching a null-hostile constructor on a path only an omission reaches; enforcement closes every one, and this file runs atwarnto measure exactly that pre-enforcement behaviour. They record the rollback's honest answer, not an unfixed bug, so there is nothing for a label to point at. The reasoning is recorded aboveSWEEPwhere the next person to read the table will meet it.