openwrt-iac / uapi / docs / 3.0.0 / Roadmap

Roadmap

Things deliberately not yet shipped. Each entry says why and what shape the change would take. Update this file when an item moves between sections.

Shipped in v2.0.0

The 35-item consolidation that paid for the major bump. Authoritative catalog in CHANGELOG.md's v2.0.0 section; migration table in docs/migration-v1-to-v2.md. Highlights:

Shipped in v1.x

(Preserved for historical context; new features land under v3.x.)

Needs more reflection

Items that are interesting but conflict with an architectural principle or have an unresolved design question. Not "later"; "later if the right shape appears".

commit-confirmed timed rollback (built, deferred from 2.3.0)

Built and validated, then deferred out of 2.3.0 stable. The sidecar path won: a separate package (apply-confirm) owns the durable rollback timer and snapshot state so uapi gains no daemon of its own; uapi integrates by invoking its CLI. A confirmed write returns 202 + a token via per-write ?confirm=<seconds>, and the ack is client-driven, which resolves the original state-divergence objection (the client acks only after verifying reachability, so a lost response prevents both the ack and the client marking its own apply complete):

If the router gets no confirmation because of a network temporary issue between the router and Terraform, it reverts, but Terraform thinks it's OK. That state-divergence is worse than the original race we're trying to solve.

The full per-write surface shipped in 2.3.0-rc1 and was soaked on live hardware, then removed before stable (recoverable from commit a85a5cd). Why deferred rather than shipped:

The dependency is not the blocker: apply-confirm 0.1.0 is released and on the apk feed. The hold is the wire-contract commitment.

Plan: ship the whole feature once, coherently, in one minor (per-write ?confirm plus the standalone POST /confirm arm under Features below, with one reviewed authz model). One of the two gates is now closed: the authz model is decided and recorded, so the feature can be scoped into a minor whenever its consumer appears. The other is not. Nothing exercises the surface, and freezing a v2 contract that nothing uses is precisely why it came out of 2.3.0, so there is still no target release. Design reference: docs/commit-confirm.md.

Shipped in 2.5.0

2.5.0 was a minor, and its job was as much to make a clean 3.0.0 possible as to ship features. The v3 list is in ยง Shipped in 3.0.0 below and had grown past what a single major used to be expected to carry, which is the point: a major is expensive to have and cheap to fill. What a minor has to do first is serve the notice docs/deprecations.md requires, because the window runs from the release that announces a removal, not the one that performs it. Cutting a major before a minor announces the rest would break clients with no window, spend the major, and still leave the changes unmade.

Committed, in rough dependency order:

  1. Announce the three v3 changes. Done ahead of the rest, since the window runs from the release that ships it, not from the release that removes: ipaddr deprecated as a write input, and the list-reads-null and dhcp/hosts.tag convention changes recorded under "Announced response-shape changes". The ipaddr case carries no deprecated: true flag on purpose; the reasoning is in the deprecations log.

tag carried work beyond the announcement, because the schema promised a string the resource does not always return: a list tag, which is what LuCI writes, already read back as an array. The read is settled in this minor rather than deferred: a stored scalar is split on the way out, so responses are always an array. Deferring it was a mistake, and the reasoning is worth keeping because the shape of the error recurs. The objection was to normalizing storage, which would rewrite option tag into list tag under a client that had only read the section. That observation was right; the conclusion that the read had to wait did not follow from it. What remains for the major is the write form, since a space-separated string is still accepted for clients generated against 2.4.1. 2. Kernel-apply reporting. Done: X-Kernel-Status and X-Kernel-Applied, mirroring the reload pair. Closes the gap the kernel apply left, where a client writing a peer to a down tunnel got a 200 it could not distinguish from one that reached the kernel. values.platform_bool landed alongside it, because reading a netifd boolean with normalize_bool reported the operator's intent rather than netifd's behaviour. 3. disabled on the network resources (#64). Done. Deferred for a while on the grounds that netifd parses the flag differently per section type, which is real but does not need an upstream fix: each resource reads it with the helper matching its own parser rather than all three sharing one. See the Features entry.

  1. Validation sweep on /diagnostics?validate=1 (#47). Done. Reports the sections a write would now reject, before a write finds them one at a time.

  2. Advisory management-path guard. Done. The cheap protection against the lockout class commit-confirm was meant to cover: GET /diagnostics reports management_path, the interface this request arrived through, and a network/interfaces write that moves that interface's disabled, proto, ipaddrs or netmask, or deletes it, carries X-Mgmt-Path-Warning.

Advisory, not a refusal: renumbering the management path is legitimate, and LuCI warns rather than blocking on the same four field names. Scope is LuCI's deliberately, with no firewall analysis, because predicting a firewall lockout means modelling fw4 ordering and a guess dressed as a warning is worse than silence. The interface comes from the kernel's route lookup rather than prefix containment, which is what makes it correct for an operator arriving from another network, the case that matters most, and what makes it work for IPv6.

Its limit is structural and documented: if the write really does strand the caller, the response never arrives. management_path is the pre-flight half for anyone who wants to check first.

Decided, and deliberately still not scheduled:

No first-party consumer exercises the surface. And a provider cannot be that consumer: the plugin protocol has no apply-scoped hook, and the one lifecycle that looks like it fits, ephemeral resources, closes its window when its own dependency chain finishes rather than when the apply does, so on a successful apply it would confirm while other resources are still being written. Measured; the analysis is in docs/commit-confirm.md. So the consumer can only be an operator wrapper, and an SSH wrapper gets most of the benefit at zero wire surface here.

Two consequences for whenever it is picked up. The per-write ?confirm half should be dropped rather than shipped: it has no consumer named in this repo and a multi-resource apply using it fails at the second write and reverts the first. And the standalone arm needs a renewal path before it is useful, which the NETCONF prior art has had since 2011. The near-term protection for this risk class is the advisory management-path guard under Features, not the timer.

Deliberately not in 2.5.0: the mirrored-name retirement itself (only its announcement lands here, the removal is v3), and anything under Hardening, which carries no wire surface and needs no release to take effect.

Shipped in 3.0.0

Everything 2.5.0 announced, plus the simplification the removals unlocked. The ledger's Removed in past releases section and docs/migration-v2-to-v3.md are the operator-facing record; this is the engineering one.

Removals, all announced in the 2.5.0 spec's own "Upcoming in v3" block:

Convention changes:

Additive, and the one item that was never announced because it did not need to be:

Simplification the removals unlocked, all dead code once the mirrored names were gone: merge_for_patch and resolve_for_replace on both dhcp/hosts and network/interfaces, equal_list, the mirrored-pair conflict rules in both validates, and the resolve_for_replace seam in handler.uc along with its contract in docs/adding-a-resource.md.

Not done, and deliberately: retiring values.normalize_bool's remaining callers. The precondition is reading libvalidate's accepted set out of ubox rather than guessing at it, and that has not happened. It could change which strings are accepted on the wire, so acting on a guess is the opposite of what a major is for. See docs/ucode-quirks.md.

Features (additive, future minor bumps in v3.x)

This is the half that still matters to us. The wg set apply protects the write, not the boot: a peer whose endpoint resolves when written and stops resolving later sits in uci, and the next reboot runs it through the proto handler and drops the tunnel. No amount of local code fixes that, because the platform owns the boot path.

The peer change detection half needs nothing from us. It was reported twice, on the devel list in 2023 and as netifd#66 in 2026, and neither landed in that form; it is instead solved in master by the ucode proto rewrite (package/network/utils/wireguard-tools/files/wireguard.uc), whose config function loads peers so the framework's change detection covers them. That rewrite also fixed route metrics (#23199) but left proto.setup_failed() exactly as it was.

Neither outcome retires src/lib/wg.uc. Even with both fixed, a netifd-driven apply stays asynchronous, so uapi still could not report whether a write reached the kernel, which is what the transaction contract needs. Retiring the local code would take netifd applying peer deltas synchronously, which is not on anyone's roadmap.

Shipped in 2.5.0, with the asymmetry handled rather than waited out. netifd parses the interface flag with a literal compare against "1" while route and rule go through the boolean blob converter, which also takes true (and only true; on and yes are accepted by neither, and uci drops the option instead). So on 25.12.5 option disabled 'true' disables a route or a rule and leaves an interface running, and reading it with one truthy parse would report an interface as disabled while it is up. That is the same lie as the bug being fixed, inverted.

Upstream closed the asymmetry in netifd e97e36f, 2026-07-16, "config: accept 'true' for the interface disabled option", but as of 2026-08-04 no OpenWrt branch pinned it, master included. Waiting turned out to be the wrong call: the parser difference is a fact about the device, so the read follows it per section type. network/interfaces uses strict_bool, the other two use platform_bool, and a unit test pins the difference in both directions, because unifying them puts the lie back whichever way it is unified. When a branch carries e97e36f the interface helper can widen, and that test is what will say so.

The first cut shared one helper across all three and shipped the inverted lie for a day: an interface carrying disabled 'true' read back as disabled while netifd had it registered and running. Caught by re-reading this entry, and measured three times against a reset baseline before and after.

Two alternatives were weighed and turned down, recorded so they are not re-argued from scratch:

Additive, so a minor bump. terraform-provider-uapi needs matching attributes on uapi_network_interface, uapi_network_route and uapi_network_rule once the fields exist.

Install the deferred packages in CI

Partly done in 2.5.0. lldpd and vnstat2 are installed by install_uapi's bootstrap, about 560 KiB together, so lldpd/config and vnstat/config are exercised by tests/integration/49_daemon_gated_resources_test.sh and included in the stock-config round-trip. The install sits in the bootstrap rather than inside a test, which is what avoids the QEMU SLIRP failure that cost hours of CI when it was tried inline.

Still outside the image, so still unexercised: snmpd, mwan3, sqm, usteer, prometheus-node-exporter-lua and openvpn. unbound is installed by 40_unbound_uci_ext_test.sh but only while that test runs, which is not a guarantee another test can lean on.

The cost of leaving it is measurable rather than theoretical: vnstat/interfaces modelled a section type vnstat never reads and shipped that way, because a resource for an uninstalled package is never exercised against real configuration, so a wrong section type is indistinguishable from a right one.

Note for whoever picks up the rest: on the standing test box the distfeeds needed pointing at 25.12.5 and the kmods feed at kernel 6.12.94-1-a7bc15f4..., or every kmod-* dependency is unresolvable.

Hardening (next, no new wire surface)

Everything else this section used to list has shipped, and the section had gone stale enough to be misleading:

Out of scope by design