v2.5.0
Added
-
examples/curl/adopt.sh. Adoption is the operation an operator meets on any router configured before uapi arrived, and it had no runnable example: a section uci named anonymously readsmanaged: false, writes to it are refused with 409, andPOST .../adoptrenames it and hands back the new id. Verified end to end on hardware against a hand-writtenconfig host. -
make lint-wire-names, a gate over the second category a major has to announce: an API field whose name is not the uci option it writes. A rename is one API name for one key and stays; an alias is two API names for one key, which makes a round-trip ambiguous and cannot survive the request/response schema split. All three current aliases are announced for v3, and a fourth can no longer arrive quietly. Two further categories were built and rejected as too imprecise to gate on, recorded in the testing docs so nobody rebuilds them: section-type reachability reports thirteen false positives for every true one because netifd, odhcpd and mwan3 parse uci in C, and the boolean-as-free-text check cannot tell a type error from an ordinary raw comparison. -
scripts/audit-dead-fields.shmakes the dead-field audit re-runnable instead of a one-off claim: it checks all 426 uci options across every curated resource against the reader that consumes them, self-checks that it can see each package's option table before reporting anything, and fails on any option that is neither announced nor a recorded decision. The corpus is the part that goes wrong, and it fails in the direction of looking productive, so three wrong corpora on the first pass reported 63 live firewall4 options as dead. The audit now covers every curated resource. Five packages ship only a Makefile in the SDK feed, which leftfirewall/*, most ofnetwork/*,dhcp/odhcpd,usteer/configandsqm/queuesunverified rather than verified-clean, and 2.5.0 is the last release that can announce a removal for v3. All 174 uci options those modules write were checked against the reader that actually consumes them, using the readers installed on a running device rather than extracted sources. One field was dead:usteer/config.max_assoc_stais now flagged deprecated and announced for removal, because usteer's init forwards a fixed list of uci options to the daemon and this is not on it.
Fixed
-
usteer/config.enabledread the word spellings of true as enabled while usteer did not. The init reads the option withuci -q getand then compares[ "$ENABLED" -gt 0 ], which is numeric rather than a bool parse, soenabled 'true'makes the shell bail with "out of range", andstart_servicereturns without registering a procd instance, while uapi reported the daemon enabled. The read now mirrors that comparison exactly, includingenabled '2'counting as enabled and an absent option defaulting to enabled. -
disabledonnetwork/interfaces,network/routesandnetwork/rules. netifd omits a disabled section entirely, so a section that does not exist on the box read back as ordinary active configuration and a declarative client saw a fully converged resource set with nothing to apply: no netdev, no route in the table, no matching ip rule, and nothing in aGETto say so. It also could not be cleared through the API, because a write cannot unset a field the model does not have, which left destroy-and-recreate as the only way back.network/wireguard_peershas modelled the same flag since it shipped. Verified on hardware: a route created withdisabled: trueis absent fromip route, reads backdisabled: true, and aPATCHclearing it installs the route; the same holds for a rule inip ruleand for an interface, which netifd does not register at all while disabled. netifd does not parse the option the same way on all three: the interface flag is compared literally against1, while route and rule go through the boolean blob converter which also takestrue. Each resource therefore reads it with the helper matching its own parser, measured three times against a reset baseline, because sharing one helper reports an interface as disabled while netifd has it registered and running. -
lldpd/configandvnstat/confignow have integration coverage. Both packages sit outside the bare OpenWrt image, so every call to those resources answered 503init_script_missingand neither was ever exercised against a real daemon, which is howvnstat/interfacesshipped modelling a section type vnstat has never read: an uninstalled package makes a wrong model look exactly like a right one.install_uapinow installs lldpd and vnstat2 at bootstrap, about 560 KiB together, and a new test asserts both of this release's daemon-boundary changes reach the daemon rather than merely reaching uci:lldp_descriptionis checked in theconfigure system descriptionline lldpd compiles into its config, andvnstat/config.interfacesin vnstat's own database viavnstat --dbiflist. Both resources also joined the stock-config round-trip. Verified on hardware in both directions, including that the section-type assertion rejects aconfig interfacesection holding the right device, which is the shape of the original bug. -
Eight deprecated fields now say why they are deprecated.
deprecated: truetells a code generator that a field is going away; the description is the only place an operator learns the reason, and it is what a provider prints in a plan warning. Twenty-one fields carried that text and eight did not, two of them carrying no description at all, so anything downstream had to substitute a generic message that could not name the specific reason. Each now states it:unbound/server.enabledis read only onconfig zone,vnstat/config.database_diris a key of a file that ships from upstream, and so on.make lint-openapi-shapenow requires a description opening withDeprecatedon any property carrying the flag, matched case-insensitively becausenetwork/interfaces.namepredates the convention and opensDEPRECATED in 2.2.0. Documentation only, no behaviour change. -
vnstat/config.interfaces: the devices vnstat tracks, mapping to thelist interfaceinsideconfig vnstatthat vnstat's init actually reads. This is the first time that setting has been reachable through the API. Values are device names as the kernel shows them (br-lan,eth0), which is what vnstat wants. Verified on hardware:PATCH {"interfaces":["br-lan"]}putsbr-lanin the list and, after a service restart, vnstat is trackingbr-lanin its database. -
Three response headers uapi emits are now declared in the OpenAPI document:
X-Kernel-Status,X-Kernel-AppliedandX-Mgmt-Path-Warning. All three were added to the code and todocs/errors.mdwithout reaching the spec, so a generated client had no way to know they exist; the first two shipped earlier in this same release. Scoped to the responses that can actually carry them rather than to every write: the kernel pair comes from the uci transaction, so it is declared on the 163 curated-resource write responses and not on raw, non-uci or batch writes, which never run that path.X-Mgmt-Path-Warningis per-resource and per-verb, so it is declared on exactly three responses.make lint-openapi-shapenow enforces both, deriving the guarded-resource count fromsrc/resources/instead of a hand-kept list, and each rule ships with a self-test probe. Documentation only, no behaviour change. -
dhcp/hosts.macs: the whole ucilist macunder one name, as an array. Every other uci list option in the API surfaces as a JSON array; this one did not. It was split positionally intomac, the first entry, andmac_aliases, the rest, so no single field answered what a reservation actually matched and a client had to concatenate two of them to find out.macswins over both when sent, both old names keep working and keep reading back, andPUTresolves a stale split against the list the waynetwork/interfacesalready does foripaddrsrather than refusing the body a full-replace client cannot avoid sending. Purely additive. -
The first architectural principle is now a test. "No partial-failure states, no config drift" was asserted in ten documentation files and covered by one unit test on the transaction module, never per resource, which is where it can go wrong: the transaction restores a snapshot, but whether a resource's write falls entirely inside that snapshot depends on the resource.
tests/unit/no_partial_state_test.ucruns over the same 45 fixtures as the read-honesty property and injects the failure at reload, the one point where uci is already committed and the daemon then refuses. Validated by neutering the restore, which fails 33 of the 45 cases with the before-and-after uci printed. -
make gate-selftest: every gate now ships with a demonstrated failure. Thirteen probes break each gate on purpose and assert it says so, covering the sixlintsub-targets, each oflint-doc-refs' five checks separately,openapi-checkandcoverage. A gate added without a probe fails the self-test, because the gate list is derived from the Makefile rather than hand-kept. Probes run in a throwaway git worktree that mirrors the working tree, so a mutation cannot reach your checkout and a gate can be validated in the same commit that adds it; a probe that changes nothing is reported as a broken probe rather than as a blind gate, since the two are indistinguishable from an exit code alone. -
make lint-doc-refs, a new lint that fails on a documentation reference which does not resolve: a repo path that is not there, amodule.exporta module does not export, a backtickedmake <target>the Makefile does not define, and an error code documented as returned that nothing insrc/emits, plus the reverse of that last one. Four false claims about this project were found by hand within a few days, each corrected individually with nothing to stop the next; this catches the two shapes that are mechanically checkable, and its own header states the larger half it cannot reach ("this job proves X" where the job exists and does something adjacent, and modal invariants likeneveroratomic). Waivers are keyed by the reference with the reason as the value, because some references are deliberately unresolvable:bench/baseline.jsonis cited precisely because it does not exist. -
Advisory management-path guard.
GET /diagnosticsnow reportsmanagement_path, naming the interface the request arrived through ({address, device, interface}), and anetwork/interfaceswrite that moves that interface'sdisabled,proto,ipaddrornetmask, or deletes it, returns anX-Mgmt-Path-Warningresponse header. This is the lockout class an atomic write cannot help with: a change that reloads cleanly and then severs the only path to the box. Advisory rather than a refusal, because renumbering the management path is a legitimate operation and LuCI warns rather than blocking on the same four field names; no firewall analysis, also matching LuCI, because predicting a firewall lockout means modelling fw4 zone and rule ordering. The interface is derived from the kernel's own route lookup rather than by comparing the caller's address against local prefixes, which is what makes it right for an operator arriving from another network, the case that matters most, and what makes it answer for IPv6 where uapi's prefix helpers are IPv4-only. Structural limit, documented: if the write really does strand the caller the response never arrives, which is whymanagement_pathexists as the pre-flight half. Purely additive. -
GET /diagnostics?validate=1sweeps every section a token may read through the same validation a write performs and reports the ones that would be rejected, with the reason. It answers "which sections on this router will stop being accepted" before an upgrade, instead of one422at a time part-way through an apply. Every section it reports is already broken on the router: a port match onproto allis already matching the whole protocol, and a multi-value redirect is already discarded by fw4, so the sweep is the first time anyone is told rather than new breakage. Opt-in, because it walks the whole configuration and/diagnosticsis polled on an interval; without the parameter the response is unchanged. Each resource is included only if the token permits:roon it as well asuapi:diagnostics:ro, since findings name sections and quote configured values, andskipped_for_scopereports what was left out so an empty result cannot be mistaken for a clean one. Read-only and side-effect free. Closes openwrt-iac/uapi#47. -
X-Kernel-StatusandX-Kernel-Appliedresponse headers on writes, saying whether a write reached the kernel rather than only uci. The kernel apply added in 2.4.1 skips an interface that is down or that netifd does not know, which is correct becauseifupreads the peers from uci, but it left a client unable to tell that200apart from one whose peer is live.X-Kernel-Statusisokwhen every targeted interface was applied,partialwhen some were skipped,skippedwhen none was, andno_kernelfor a resource with no kernel path, mirroring howX-Reload-Statusreportsno_reload.X-Kernel-Appliednames the interfaces actually changed. Documented indocs/errors.md§ Response headers. -
If-None-Matchis now evaluated on writes. It was parsed for every method and then dropped for anything butGET, so a caller asking for "only if this has not changed" or "only if absent" had its condition silently discarded and the write performed anyway. RFC 9110 13.1.2 gives304forGET/HEADand412for every other method; 13.2.2 requires the precondition to be evaluated before the method runs, so the check goes in the existingprecondition_checkseam, which already runs inside the transaction beforeuci_commit. A412therefore means nothing was written, and that is what the tests assert rather than the status alone.If-Matchis still evaluated first, per 13.2.2's ordering.This turns previously-successful writes into refusals, which is the breaking clause in
docs/versioning.md, and the carve-out argument is worth stating because it is arguable either way: the server accepted a request whose conditional semantics it then threw away, so the200was never a contract a caller could deliberately rely on. Anyone appending?if_none_match=to a write, which the integration suite proves is a real pattern, now gets412where they got200.Measured on hardware:
PATCH ...?if_none_match=*on an existing resource returns412with uci unchanged; a matching etag likewise; a non-matching etag still writes and still carries its three transaction headers and its audit line; and conditionalGETis untouched, returning304for both a matching etag and*.Out of scope, deliberately: collection
POSTstill ignoresIf-None-Match: *, since there is no target resource whose etag could be tested, and the existing409-before-412ordering is unchanged because reshuffling it would moveIf-Matchresponses too. -
/rawlet a client writeoption managedinto uci, where it then shadowed the derived value.managedcomes from uci's.anonymousflag and is never stored, but raw has notoUcito drop unknown keys: it copies the request body wholesale. A read-modify-write client therefore wrote the field back, andnormalize_sectionlet the stored option override the derived one, so the response came back as the string"0"where the schema promises a boolean. Measured on hardware, before and after:PATCH /raw/firewall/<id> {"managed": false}used to leaveoption managed '0'in the section and answer"managed": "0"; it now writes nothing and answers"managed": true. A section that already carries the stale option is handled too, which the first version of this fix missed:normalize_sectionderivesmanagedafter copying the section rather than before, so a stored value can no longer shadow it, and a patch sweeps the residue. Previously only a full replace could remove it, and the[]clear idiom could not reach it either, because the guard keeps the key out of the write set. -
managedis annotatedreadOnlyacross all 46 schemas that carry it. NotoUcireads it and the write path hardcodes.anonymous = false, so aPUTsendingmanaged: falsehas always answered 200 withmanaged: true; management state moves only through the adopt endpoint. Emitted as a bare boolean it read to a code generator as an ordinary writable field, so it landed in the request model of every generated client, which then sent a field the server ignores. The annotation is applied generator-side, mirroring howruntimeis handled: adding it to each module'sschema_propertiesinstead would also add it to the runtime type checker and turnmanaged: "true"from 200 into 422, which is a tightening nobody asked for.lint-openapi-shapenow enforces it, with a self-test probe. -
Three fields wrote a uci key their daemon never reads, so the value never reached the device.
lldpd/config.lldp_capabilitieswrotelldp_capabilitieswhile lldpd readslldp_capability_advertisements;unbound/server.dnssec_enabledwrotednssec_enabledwhile unbound readsvalidator;snmpd/system.sys_serviceswrotesysServiceswhile snmpd readssysService, singular, a typo uapi inherited from upstream's own sample config. Each now writes the key its daemon reads, and still reads the old one when the new is absent, so an upgrade does not silently drop a value an operator set and report the default in its place. Every write also clears the legacy key. That is not tidiness: without it the legacy key never enters the footprint aPATCHdeletes from, so clearing one of these fields removed the real key and left the old one behind for the fallback to resurrect, and the API answeredfalseto the write andtrueto the very next read while the daemon had it off.These settings start taking effect, which is the point and also the risk. A value stored months ago and quietly ignored becomes live on the next write to that section. For
sys_servicesthat is inert SNMP metadata. Forlldp_capabilitiesit can stop a TLV being advertised to neighbours. Fordnssec_enabledit turns on DNSSEC validation, which will break resolution on a network whose upstream DNSSEC is broken. Check what those three options hold on your boxes before upgrading if that matters to you. Carve-out (docs/versioning.md): the writes being corrected produced state no caller could rely on, because they never reached the daemon at all.Verified on hardware rather than by reading:
PATCH sys_services:76now producessysService 76in the generatedsnmpd.conf, andlldp_capabilities:falseon a box carrying the old key producesunconfigure lldp capabilities-advertisementsin lldpd's generated config. -
dhcp/hosts.tagnow always reads back as an array. The same reservation answered with"guest iot"on a box storingoption tag 'guest iot'and["guest","iot"]on one storinglist tag, though dnsmasq word-splits the scalar and treats them identically, so a client had to handle two shapes to learn one thing. A stored scalar is split on the way out; a read never touches storage, and the first write-back converges it, which is invisible on the wire because both compile toset:guest,set:iot. Verified on hardware, including aGETfed straight back as aPUTleaving the view unchanged. A space-separated string is still accepted on write, because the 2.4.1 spec declared one and clients generated against it send one; v3 removes that and the field becomes array-only in both directions. Carve-out (docs/versioning.md, branch b): a response type changed in a minor, because the 2.4.1 declaration["string", "null"]was violated by the server's own responses for any section storinglist tag, the ordinary uci spelling for more than one tag. A scalaroption tag 'red'did read back correctly under the old type, so branch (a) does not apply and is not being claimed. This was previously announced as a v3 change to the read shape, which deferred a year of two-shape handling for no benefit: the objection was to normalizing storage, and splitting on read settles the wire shape without that. -
The read-honesty property was blind to most of the fields it was supposed to protect. Its default-masking guard compared each seed against the schema default, but seeds are keyed by uci option name and
schema_propertiesby wire name, so every field whose names differ failed the lookup and was skipped in silence. It inspected 15 of 140 seeded keys. The consequence, measured rather than argued: deleting thePasswordAuthwrite fromdropbear.instances.uc, which silently re-enables SSH password authentication across aGET-then-PUT, passed all 1126 tests. Droppingmasqfromfirewall.zones, which silently disables NAT, did too. The guard now compares the read-back value against the default, which needs no name mapping because it asks the question the property actually depends on: iftoUcidropped this field, would the re-read refill it and hide the loss? That exposed 101 blind pairs across 34 of the 46 cases, every one of which is now seeded off its default. Both deletions above are caught. -
The
/diagnosticssurface added in this release never reachedbuild/openapi.json. The?validate=1parameter was undeclared, andDiagnosticsResponsestill listed the six properties it had at 2.4.1 while the endpoint returns four more:management_path,invalid_sections,swept_resourcesandskipped_for_scope. A client generated from the spec could not see the sweep at all. All five are declared now, including the two error codes that appear only inside a sweep result,unreadableandsweep_failed, which are documented indocs/errors.mdfor the first time. Checked against a live response rather than against a reading of the source: the declared property set matches the emitted keys exactly, and a planted invalid section confirms the item shape down to the slash-formresourceand the{field, code, message}errors. -
The management-path warning was blind to the field the deprecation steers clients toward.
ipaddrsis the same uci option asipaddrunder the name that replaces it, but only the scalar was watched, andmerge_for_patchdeletesipaddrfrom the merged body exactly when the caller sends the list. SoPATCH /network/interfaces/<inbound> {"ipaddrs": [...]}renumbered the interface carrying the request and warned about nothing, as did aPUTnaming only the list, whoseresolve_for_replacereturns early. Both are reported now. Also fixed the same omission indocs/errors.md,docs/operations.md,docs/security.mdand the published header description, all of which named four fields. -
The DELETE arm of that warning had no test. It is not unreachable, contrary to the review that raised it: the delete path hardcodes
changed=removedrather than going through the field comparison. The integration test can only assert the negative half, since exercising the real one would strand the box running it, so this is covered by a unit test instead: the handler now threadsmgmt.uc's existing route-lookup seam through from the request context, which production leaves unset. -
PATCH {"mac": null}on adhcp/hostsreservation with more than one MAC returned422againstmac_aliases, a field the caller never sent.merge_for_patchcollapsedmacandmac_aliasesinto a single "did the body name the split" flag, so namingmacdropped the mergedmacsbut kept the read view'smac_aliases, producing a list with no head, which is exactly the shapevalidaterejects. Clearingmacnow clears the tail with it, since there is no uci list left to hold it. Dropping the DHCPv4 MACs while keeping a DHCPv6 reservation is a legitimate request and was the one shape that could not express it. -
Clearing a list option answered 200 and changed nothing. uci cannot store an empty list, so
[]means "no value"; the uci binding refuses a zero-length array andbus.ucdiscarded that answer. Because the key was still present in the write set, neither diff loop took its delete arm, soPATCH {"tag": []}returned 200 with the field reported cleared while dnsmasq kept applyingoption tag 'guest'to the reservation. An empty array now takes the delete arm, The rule lives inbus.uc, which turns a set of[]into a delete:[]is the absence of a value, so setting one removes the option. Putting it there rather than in each write loop matters, because there are six acrosshandler.ucandraw.uc, and it fixes raw passthrough as a side effect, which had the same silent drop. The test stub accepted[]and stored it, which is why no unit test could see this; it now refuses exactly what the real binding refuses. Affectsdhcp/hosts.tag,openvpn/instancesremote,pushandroute,usteerevent_log_typesandssid_list, andmwan3/interfacestrack_ipandflush_conntrack. -
A write could be answered
304, losing its headers and its audit line.maybe_304ran on every response rather than only on reads, so appending the documented?if_none_match=fallback to aPUTorPATCHrewrote the response of a write that had already committed and reloaded. The caller got noX-Reload-Status, noX-Kernel-Statusand noX-Mgmt-Path-Warning, and because the audit branch logs 2xx writes only, the write left no audit line at all: one query parameter kept a configuration change out of the trail that exists to record it. A304'dPOSTwas also never cached for idempotency, so a retry with the same key created a second section. Measured on hardware: with the defect,PATCH ...?if_none_match=*returned304while uci moved from192.168.222.1/24to192.168.222.77/24; with the fix the same request returns200, three transaction headers and one audit line. Conditional GET is unaffected. RFC 9110 13.1.2 allows304only forGETandHEAD; the strictly conforming412for writes needs the precondition evaluated before the method runs (13.2.2) and is tracked separately. -
An operator-disabled WireGuard peer could be re-enabled and pushed into the kernel.
disabledandroute_allowed_ipsare read bywireguard.shwithconfig_get_bool, which acceptson,yesandenabled; 2.5.0 moved them to the netifd-strict helper, which accepts only1andtrue. A peer spelledoption disabled 'yes'therefore read back as enabled, and because a write persists the read view, any unrelatedPATCHon that peer rewrote uci todisabled='0'and the kernel apply emitted asetthat installed the peer live. Same defect onroute_allowed_ips, where it also skipped route installation and skipped theip route delon delete. -
lint-defaultswas anchored on the literal namenormalize_bool, so reclassifying fields onto the new helpers would have silently removed them from that gate: it went from seeing 75 boolean fields to 25 while still reporting OK, and a droppeddefault:annotation on any of the 50 would have shipped unnoticed. That annotation is what an IaC client reads to keep a field sticky, so losing it produces a perpetually non-converging plan. The lint now matches every helper that takes a default (79 fields), and the self-test gained a probe for the boolean shape, having previously probed only the string one, which is why the loss would have gone unreported. -
mwan3/interfaces.enabledandopenvpn/instances.enableddeclared a default oftruewhile both daemons default them to off: mwan3's init readsconfig_get_bool enabled $interface 'enabled' '0'and openvpn'ssection_enabled()defaults both spellings to 0. A section with the option absent therefore read back as enabled, and because a write persists the read view, any unrelatedPATCHwroteenabled='1'and switched it on. Same failure chain as the WireGuard peer above, reaching the opposite conclusion on adjacent lines, which is what an audit of the accepted set without the default looks like. -
Boolean readers, everywhere. The rule in the docs was "the netifd helper for netifd-owned fields and the wide one for the rest", and there is no "rest": OpenWrt has at least five readers with three different accepted sets, plus one this project cannot verify because ubox is not in the SDK. Auditing all 101 call sites against the actual reader in the OpenWrt sources moved 54 of them to a new
shell_bool(theget_boolset, which the previous helper was short byenabled/disabled), 4 wireless fields to the strict netifd helper they had been reading too widely, and 4 raw-compare fields to a newstrict_bool. The reader table indocs/ucode-quirks.mdnow lists every class with its helper, because that table is what produced the bug. -
Two fields were not booleans at all, and every write destroyed their value.
system.urandom_seedis the filesystem path the entropy seed is saved to (/sbin/urandom_seedtests that it starts with/), andlldpd'slldp_descriptionis the free-text system description emitted verbatim into LLDP frames. Both were typedboolean, so a round trip replaced the operator's value with"1"and, for the seed, silently turned the feature off. Both are now strings. Carve-out (docs/versioning.md): a type change on the wire. It is earned by the write path, not the read path, and the earlier wording of this entry got that wrong. Forurandom_seedthe reader only acts on a value beginning with/(sbin/urandom_seed), so setting a real path was unreachable through a boolean, and asking fortruewrote'1', which leaves the feature off while every later read reportstrue: a lie that persists in uci. Forlldp_descriptionevery write replaced the advertised system description with"1"or"0". No uci value made either boolean a correct answer, which is the test the carve-out now states. What a caller does lose is thefalsea stock box returned forurandom_seed='0'; that reading was correct, but it was the only correct one the type could produce and it could not be acted on. How loudly this lands depends on the client, and it is silent for a whole class of them. A client sending a real JSON boolean is told:{"lldp_description": true}answers422 invalid_type. A client whose configuration language coerces booleans to strings never sends one. HCL does, so an unedited Terraform config carryinglldp_description = trueserialises to"true", which is a valid string and is accepted: verified on hardware, the advertised system description becomes the literal texttrue, andurandom_seed = "true"leaves the seed feature off while every read reports a plausible string. So check for the literal"true"or"false"in these two fields after upgrading rather than relying on an error.dhcp/hosts.tagis unaffected, because no such layer coerces a string into a list, which is why that correction does fail loudly. -
ETagwas declared on 18 responses that never carry one and missing from 43 that always do. The same defect as the reload headers, in both directions at once: raw passthrough,/packages,/tokens,/system/authorized_keys,/auth/whoami,/diagnosticsand the read-only lease views all declared anETagthey never send, while every304emitted one and declared nothing.set_etag_headerhas four call sites, all in the curated CRUD and singleton handlers, andmake_collection.get_onereturns bare, which is why "curated" alone is the wrong test: the lease views are curated and still carry none. The generator now takes explicit intent instead of guessing from the response schema shape, andlint-openapi-shapederives the expected set from the catalog'skindfield. Measured on a real box: a curated item GET, a singleton GET and a curated PATCH each return one, while whoami, diagnostics, raw collection and item GET, a raw PATCH, the lease list andPOST /tokensreturn none, a genuine 304 on both a singleton and a CRUD item (reached through?if_none_match=, since uhttpd's CGI env drops the header) carries the ETag it is now declared with, and a collection-kind item GET returns a real lease body with no ETag, which is the case that makes "curated" the wrong test on its own. -
X-Reload-Status: no_reloadwas documented withsystemas its example, which reloadssystemandlog. All 43 writable resources declare at least one reload service, sono_reloaddescribes a resource shape that does not currently exist and no shipped response carries it. The value stays defined, since the transaction still handles that shape, but the docs and the published enum description now say it is not reachable today instead of illustrating it with a resource that disproves it. -
X-Reload-StatusandX-Reload-Serviceswere declared on 15 response bodies that never emit them: every raw passthrough write,POST /batch, and the non-uci writes under/packages,/tokens,/system/passwordand/system/authorized_keys. None of those paths reachesattach_reload_headers, so a client generated from the spec was told to expect a header that could not arrive. Measured on a real box rather than inferred: curated writes return the pair on POST, PUT, PATCH, DELETE 204 and singleton PATCH, while raw returns neither on any of its four verbs, andPOST /batch,packages/feedsPOST and DELETE,POST /tokensandauthorized_keysPOST and DELETE return neither. The handful not exercised by hand (DELETE /tokens/{id},POST /system/password,packages/installed) share the same response path as the ones that were, which is the thing the code check below actually pins. The declarations now follow the sameuci_txgate the kernel pair uses, leaving all four transaction headers on the 163 curated-resource write responses and nowhere else.lint-openapi-shapeenforces both directions, deriving the curated path set from the generator's ownENDPOINTScatalog so a new resource needs no lint change, and both directions ship with a self-test probe. This removes a declaration rather than adding one, but nothing that was ever sent stops being sent: the header was already absent from these responses, so no client can have depended on receiving it, and OpenAPI response headers are never required. -
dhcp/hostsno longer acceptsmac_aliaseswithoutmac, which silently discarded the MACs. The two are one ucilist mac: the scalar is its first entry and the array the rest, so aliases with no primary describe a list with no head.toUcicould not write it and wrote nothing at all, whilevalidatepassed the body because the identifier requirement is written againstmacandduid; a reservation carrying aduidtherefore answered200with its MACs gone. It now returns422 conflictonmac_aliases. Reported against that field rather thanmacso it cannot collide with the identifier error under the field-and-code dedup. Writing the aliases as the list instead was rejected as a fix:macwould come back non-null, answering a different request than the one sent. Carve-out (docs/versioning.md): a payload accepted before now returns 422, earned because the old behaviour wrote no MACs, which is state no caller could rely on. The response body was already honest about the result, since it is rebuilt fromfromUci; the defect was accepting a body uapi could not honour without saying so. -
coverage's dead-export gate could never fire.used_internallyscanned each module for the export's name as a token, and a module's own export block names every one of its exports, so every export counted as internally used and theexit_code = 1on dead exports was unreachable. A genuinely dead export passed cleanly. The export block is now excluded from that scan, verified in both directions: a planted dead export is reported and exits non-zero, and all 131 real exports still pass. Found by the gate self-test on its first complete run, which is the case for having it. -
lint-emdashhad never run in CI. It lists tracked files withgit ls-files, no CI job installedgit, and|| trueturned the resulting failure into a pass: the lint job logged/bin/sh: git: not foundand reported success without reading a file. The em-dash rule was enforced on developer machines only.gitis installed in the lint container now, and the recipe fails loudly if git is missing or if the checkout has no.git(anactions/checkouttarball export has no tracked-file list), so the next container change cannot silently disable the rule again. -
Seven false statements in the documentation, found by inventorying every checkable reference.
docs/packaging.mdcited.github/allowed-signers, which has never existed, for.github/release-signers.asc. Theverify-arch-build"proves arch-neutrality" claim, corrected in the workflow comment in 2.5.0, survived indocs/packaging.mdanddocs/release-process.md.docs/migration-v1-to-v2.mddocumented dependency-aware ETags as current behaviour, though they were removed during the 2.x line.docs/concurrency.mdsaid lock granularity for different packages is "asserted live" when the only assertion is a unit test over real flocks. Three docs claimed the concurrency test observes "5 distinct PIDs" when it asserts at least 2 and uhttpd caps concurrent CGI children at 3.docs/operations.mdsaid 43 resources anddocs/testing.mdsaid the harness is ~100 lines. Anddocs/lock-state-audit.mdclaimed "17 sites audited" while its own reproduction command finds 36, with nine modules absent from the table entirely, including one added in this release; it now states its real coverage and the completion is tracked on the roadmap. -
A
PATCHwhose body is not a JSON object is rejected with422 invalid_typeinstead of answering200and changing nothing. The merge folded the body into the read view before validating, and the merge yields an object whatever the body was, so a scalar or a bare array reached the write path as a no-op and the response was indistinguishable from a successful partial update. A client sending a malformed body was told it had succeeded. This is a tightening under thedocs/versioning.mdcarve-out: the payloads it now rejects could never have applied, so no caller can have been relying on the old answer, and reporting that a write landed when nothing was written is the one thing a write must not do. Both the collection and singleton PATCH paths are covered. Two shapes are deliberately unaffected: an empty request body, which arrives as null and has always meant "a request naming no fields", and a JSON Patch array underapplication/json-patch+json, whose body is an array of operations by definition. Closes openwrt-iac/uapi#83. -
CI no longer advertises checks it does not perform. The perf bench step was named a regression gate and the release process listed it as one, but the comparison it relies on runs only when
bench/baseline.jsonexists; no such file has ever been committed and no step produces one, so the check has never executed and a latency regression of any size shipped green. It is now named and documented as measurement, which is what it does, and the threshold is marked uncalibrated rather than left looking tuned.verify-arch-build's comment claimed it proved the APK is byte-identical across host arches and that a release should be held on divergence; it compares no digests and its matrix has no x86_64 leg to compare against. What it does enforce, and what 2.4.1 actually relied on, is that every arch's SDK tarball is pinned by checksum and that the package cross-builds under all three; the comment now says that.415 unsupported_media_typewas documented as returned when a body is not JSON, but Content-Type is never inspected:text/plainand no header at all are both accepted and the write lands. It is now documented as reserved, since enforcing it would reject bodies that work today. -
Booleans owned by netifd are read the way netifd reads them. netifd converts uci strings through uci's own converter, which accepts only
1/trueand0/falseand drops the option for anything else, falling back to its own default; uapi read them with a helper that also acceptson/yes/off/no, so it reported the operator's intent instead of the daemon's behaviour. Concretelyoption auto 'no'on an interface read back asauto: falsewhile netifd, having dropped the value, autostarted the interface. The affected reads areauto,nohostroute,peerdns,defaultrouteanddelegateonnetwork/interfaces,disabledandroute_allowed_ipsonnetwork/wireguard_peers,invertonnetwork/rulesandipv6onnetwork/devices. Only netifd-owned fields changed: fw4 and the shell init helpers do accept the wider set, so reading those the strict way would introduce the same bug mirrored, anddocs/ucode-quirks.mdnow carries the per-daemon table. Writes are unchanged and still emit"1"/"0", which every reader accepts. -
A wireguard peer's
route_allowed_ipsroutes are withdrawn even when the option was spelledtruein uci. The kernel apply learns which routes the previous configuration installed from the peer's existing uci section, and that read accepted only"1", so a section written by hand or by another tool withtruewas treated as having installed none: shrinking such a peer'sallowed_ips, or deleting the peer, left its routes in the kernel directing traffic into the tunnel for prefixes it no longer had. Sections written through uapi were never affected, since uapi emits"1"/"0". -
network/interfacesaccepts IPv6 and dual-stack addresses on a wireguard interface. Theaddresseslist was validated with the IPv4-only CIDR check, sofd00::1/64was refused with "must be a valid IPv4 CIDR" and no IPv6-only or dual-stack tunnel could be configured through the API at all, even though the published schema declares the array unrestricted and netifd's own handler parses both families. This is the same class as theallowed_ipsfix in 2.4.1, which corrected the peer field and missed the interface's own addresses. Verified on hardware: a tunnel created with["fd00:99::1/64", "10.99.0.1/24"]comes up with both addresses present in the kernel.ipaddrandipaddrsstay IPv4-only, since those are the static-proto v4 fields. -
dhcp/hostsno longer writes the inverse of adnsrequest.name,taganddnswere written bytoUcibut absent fromschema_properties, so the central type gate never saw them:dns: "0"is a truthy string in ucode and wrotedns=1, enabling DNS for a reservation whose request asked to disable it.nameanddnsnow carry schema properties, and the false lint waiver that excuseddnsas "not surfaced in schema_properties" is gone, its stated reason having been contradicted bytoUciwriting the field.Carve-out (
docs/versioning.md): these payloads were accepted before and now return422, and ship in a minor because the state they produced was one no caller could rely on.dnsearns it outright: the write did the opposite of what was asked, andname: 123was silently coerced into uci and rides the same carve-out.tagis deliberately left undeclared: dnsmasq reads it with a scalarconfig_getand then word-splits it, so a ucilist tagis working configuration that the ubus API surfaces as an array, and a string schema would reject it. Declaringtagneeds a decision about which shape the wire uses rather than just a type. Published types are unchanged, since the spec already inferred all three from a read sample;dnsgains itsdefault: false. dnsmasq readsdnswith the shellconfig_get_bool, which accepts the wider spelling set, so the reader staysnormalize_boolrather than the stricter netifd form. -
wireless/interfaces.has_keyis always present. It was set only when a key existed, so the member was absent on a keyless section while the published schema declares a non-nullable boolean, meaning a plainGETof an open or OWE network violated the spec the server itself ships. Every sibling write-only flag already spelled it the documented way. Verified on hardware: two realencryption: oweinterfaces now readhas_key: falsewhere the field was previously missing. An empty key also counts as no key, which is defensive rather than reachable, since uci does not store an empty option value. -
Two
unbound/servervalidation messages named enum sets their own validator contradicts.protocoladvertisedauto, which is rejected, and omitteddefault,ip6_localandip6_prefer, which are accepted;resource_limitsomitted the accepteddefault. Both messages are now derived from the constant they validate against, asdomain_typealready was, so a new enum value cannot leave the message behind. Reachable on thePATCHdelta path, where the central schema gate does not fire for a field the request did not name, which is why nobody had reported it.
Deprecated
-
Roughly thirty fields that write a uci option no OpenWrt component reads are announced for removal in v3, found by auditing every curated field against its reader in the OpenWrt sources. Each has never had any effect: the write is accepted and stored, and the daemon carries on as before. They are removed rather than corrected because, unlike the three repointed in this release, there is nothing to point them at:
mwan3rtmonhas no polling interval, vnstat's real settings live in a file that ships from upstream, LLDP-MED is a build-time switch, unbound is enabled through procd, and the prometheus exporter enumerates its collectors from disk. Seven of that exporter's seventeen collector toggles name collectors that do not exist in the package at all. Requests carrying these keys are already ignored, so nothing on the write side needs migrating; the read side does, since each is currently returned with adefault:annotation, which is what an IaC client reads to keep an attribute sticky. Each is flaggeddeprecated: trueon the property, which is whatdocs/deprecations.mdrequires and what actually reaches a client: a prose bullet in the spec description is read by a human, the flag is read by codegen. -
managedleaves the request half of every resource schema in v3. It is annotatedreadOnlyfrom this release, which is the notice: a regenerated client stops putting it in request models. -
Each resource gains a separate request schema and response schema in v3. One schema serving both directions is what forces
dhcp/hosts.tagto keepstringin its type for writers while responses are always an array, andnetwork/interfaces.ipaddrto be described in prose rather than asreadOnly. The ledger already promised that consequence while the cause went unannounced. -
vnstat/interfacesis deprecated for removal in v3; usevnstat/config.interfaces. The endpoint has never worked. It modelsconfig interfacesections, and vnstat only ever reads alist interfaceinsideconfig vnstat(vnstat.init:21,28), so aPOSTreturned 200, wrote a section, and vnstat carried on tracking exactly what it tracked before. Confirmed on a real router carrying both shapes at once:vnstat.@vnstat[0].interface='br-lan' 'eth0'was being tracked while two uapi-createdconfig interfacesections forvlan30andlanwere ignored. Migration is a translation, not a copy: the dead endpoint took uci interface names (lan), the working field takes device names (br-lan). All seven of its operations carrydeprecated: truein the spec, so a generated client surfaces the warning; prose in the ledger reaches a human reading the document and nothing else. -
dhcp/hosts.macanddhcp/hosts.mac_aliasesare deprecated in favour ofmacs, for removal in v3. All three name one ucilist mac. Both are flaggeddeprecated: trueon the property, unlikenetwork/interfaces.ipaddrabove, and the difference is not an oversight:ipaddrcorresponds to a real scalar uci option and survives as a read, whereas uci has no scalarmacoption on a host, somacwas never a uci field at all, only uapi's positional half of a list. Nothing is left for it to mean oncemacsexists, so both names go entirely rather than becoming read-only. -
dhcp/hosts.tagwill read back as an array of strings, not a space-separated string, from v3. Both shapes are accepted on write today and the field reads back whichever uci holds, which is the inconsistency being retired: dnsmasq's tag construct is multi-valued and LuCI has always written the list form. Not flaggeddeprecated: trueon the property, because the field itself survives and only the string form goes away; the notice lives in the field's description and indocs/deprecations.md, alongside the other five v3 changes now listed under "Upcoming in v3" in the OpenAPI document's own description. -
network/interfaces.ipaddris deprecated as a write input; sendipaddrsinstead. Both name the same ucilist ipaddrand the list already wins on write, so migrating means dropping the scalar from request bodies rather than changing any value. Reads are unaffected now and after removal:ipaddrkeeps carrying the first entry of the list, and v3 marks itreadOnlyrather than deleting it. That read half is why the property is not flaggeddeprecated: truein the spec, since the flag has no read/write split and would tell a generator the field is disappearing; the announcement is in the field'sdescriptionand indocs/deprecations.mdinstead. Announced now because a full-replace client cannot avoid sending both names, which is what #60 and #65 each cost a release to work around; one writable name per uci option removes the cause rather than resolving it per method. -
List-valued fields will read back
nullinstead of[]when the underlying uci key is absent, targeted at v3 (#39). uci cannot store an empty list, so[]already means "absent" and distinguishes nothing. This is a convention change across every curated resource and it breaks response validation for clients generated against the current{"type": "array"}schema, so it is announced here a major ahead rather than staged per field. Nothing changes in this release.