Skip to content

chore(caps): single source of truth for the capability registry, plus the guard the code already promised#81

Merged
nelsonduarte merged 4 commits into
mainfrom
chore/capability-registry-guard
Jul 18, 2026
Merged

chore(caps): single source of truth for the capability registry, plus the guard the code already promised#81
nelsonduarte merged 4 commits into
mainfrom
chore/capability-registry-guard

Conversation

@nelsonduarte

Copy link
Copy Markdown
Owner

Groundwork before adding a tenth built-in capability. No new capability here, and no behaviour change except one intentional fix.

Why

All nine capabilities date from the initial commit; nobody has ever added one, and the registration has no seam. The handle-bearing set was the literal ("Fs","Net","Db","Proc","Env","Clock") copied verbatim at 21 sites, and the two nominally-authoritative lists (CAPABILITY_NAMES for the front end, BUILTIN_CAPS for the backends) were cross-checked by a property test that the code comment described but which did not exist.

The drift was already real: LSP completion had been missing Proc and Db since those capabilities shipped. Adding a tenth capability by hand through that would have reproduced it.

What changed

  • HANDLE_BEARING_CAPS and a spelled-out ERASED_CAPS in capa/ir/_capa_types.py, replacing all 21 verbatim tuples.
  • WasmHost._invoke_main's param-name to root-handle map is now derived from HANDLE_BEARING_CAPS rather than a parallel hardcoded literal, which removes the possibility of the two diverging rather than merely detecting it.
  • The promised cross-check now exists, in tests/test_cap_handles.py rather than tests/test_properties.py because the latter skips its whole module when Hypothesis is absent and a registry guard must never be skippable. Six guards: the two registries agree, every capability is classified handle-bearing or erased, every handle-bearing capability has a host root handle and that handle is non-zero, and the WIT known-capability set is pinned.
  • Four near-variant capability lists were audited and deliberately left as literals with the reason recorded: Clock.allows is nullary so it cannot join the string-argument set; Env and Clock spell their attenuator restrict_to_keys / restrict_to_after so that split is by method name, not by capability; and Fs/Net have dedicated emitters so the generic indirect-return set is correctly just Db/Proc/Env. Two sites that genuinely did mean the same thing were derived into _WASI_FULLY_MIGRATED_CAPS.
  • _KNOWN_CAPABILITIES omitting Unsafe was judged deliberate rather than drift and left alone, now pinned by a test: Unsafe is rejected before WIT generation, and its surface is Calls rather than MethodCalls, so it never reaches the interface loop. Forcing it in raises UnsupportedCapabilityMethod, confirmed empirically.
  • Intentional fix: LSP completion derives from CAPABILITY_NAMES, so it now offers all nine capabilities.

Evidence

The guards were proven to bite in every direction: a capability present in one registry only, a capability in both but unclassified, and a handle-bearing capability with no host root. Spelling out ERASED_CAPS rather than deriving it by subtraction is what makes the second guard work; a derived complement silently classifies every new capability as erased, which was verified.

Behaviour-neutrality was checked mechanically and empirically: all 21 substituted sites had an identical member set, and 143 WAT and WIT artefacts were byte-identical against the base commit across the whole examples corpus in both default and WASI modes. A both-backends runtime sweep covering every handle-bearing and erased capability, including attenuated capabilities threaded through structs, traits and closures, matched throughout.

Suite 4263 passed, 18 skipped (4257 baseline plus the six new guards).

Found while in here, not fixed

_invoke_main keys that map by main's parameter name, with a fallback to the Fs root. Renaming a parameter therefore changes behaviour on the Wasm backend: five of six handle-bearing capabilities go from allowed on Python to denied on Wasm, and Fs only appears to work because Fs is the fallback. It fails closed, since the handle table is type-checked, so there is no confinement break, but it is a silent Python-versus-Wasm divergence and gets its own ticket.

The six capabilities that are un-erased on the Wasm side (Fs / Net /
Db / Proc / Env / Clock) were spelled out as a verbatim tuple at 21
sites across the Wasm emitter and the WIT generator, with no named
constant anywhere. Adding a seventh would have meant finding all 21
by hand, and missing one would silently drop the cap's handle at
whichever slot the site governs.

Introduce HANDLE_BEARING_CAPS in ir/_capa_types.py next to
BUILTIN_CAPS, plus its deliberately-spelled-out complement
ERASED_CAPS (Stdio / Random / Unsafe), and route all 21 sites
through it. Every site was read first and means exactly the same
set; _emit_wit's private _HANDLE_BEARING_CAPS is folded into the
shared one.

The near-variant sets are audited but NOT forced into the constant,
because each means something genuinely different:

- _caps.py "allows" and _discovery.py "allows" (five caps, no Clock)
  are the handle-bearing caps whose allows takes a STRING argument;
  Clock's is nullary and has its own emitter.
- _caps.py indirect-return (Db / Proc / Env) is the GENERIC path;
  Fs, Net and Clock are dispatched by dedicated emitters before it.
- _discovery.py restrict_to (four caps) splits by ATTENUATOR METHOD
  NAME, not by cap class: Env narrows via restrict_to_keys and Clock
  via restrict_to_after.

Each of those now carries a registry note saying so.

_emit_wit's two copies of the WASI-migrated set DID mean the same
thing and are now _WASI_FULLY_MIGRATED_CAPS. Its _KNOWN_CAPABILITIES
omits Unsafe deliberately, not by drift: the Wasm emitter rejects
Unsafe up front so it never reaches WIT, and adding it would make
the interface loop raise instead of skip. Documented in place.

Behaviour-neutral: no set changes value.
ir/_capa_types.py claimed a property test cross-checked BUILTIN_CAPS
against typesys.CAPABILITY_NAMES. It did not exist, and the two lists
had never been compared by anything. Two copies of the built-in set
had in fact already drifted.

Add TestCapabilityRegistry with five guards:

- CAPABILITY_NAMES and BUILTIN_CAPS agree exactly, naming the
  offending capability in the failure message.
- Every built-in cap is classified as handle-bearing or erased, so a
  new one cannot default silently into "erased".
- The two lowering classes are disjoint and contain no strays.
- Every handle-bearing cap has a host root handle; a missing one
  would fall back to the Fs root, a silent cross-cap substitution.
- The WIT generator knows every built-in except Unsafe, pinning that
  omission as intentional rather than letting it rot.

They live in tests/test_cap_handles.py rather than the promised
tests/test_properties.py because that module skips wholesale when
Hypothesis is absent, and a registry guard must never be skippable.
The stale comment now points here.

Fix the one real drift the guards caught: the LSP completion floor
listed seven of the nine capabilities, so Proc and Db were never
offered by the editor. The list is now derived from
CAPABILITY_NAMES instead of hand-copied.
The guard added in the previous commit docstringed itself as
protecting WasmHost._invoke_main's param-name map, but asserted
against bootstrap_root_handles' output instead. That dict is
strictly larger - it serves the erased caps too - so the guard
passed for any cap bootstrap already supports while the param-name
map omitted it. Reclassifying Stdio as handle-bearing proved the
gap: the guard stayed green even though a main declaring Stdio
would have silently received the Fs root.

Two changes:

- Extract the map into a module-level _root_handle_map so the guard
  can assert against the real thing, and DERIVE it from
  HANDLE_BEARING_CAPS instead of hand-listing six entries. A future
  attenuation slice that un-erases a cap now gets the right root by
  construction rather than by remembering to edit a literal.
- The guard additionally requires each handle-bearing cap to
  resolve to a NON-ZERO handle. Derivation cannot fix the case
  where _invoke_main never bootstraps a root instance for the cap
  at all (Random and Unsafe are not passed), which would hand the
  guest handle 0, the "no cap" sentinel.

Verified both directions: with the map still hand-listed, the fixed
guard fails naming Stdio; after derivation that case passes because
it is genuinely handled, and reclassifying Random instead fails on
the non-zero check.

Also correct the guard's second misstatement: the map is keyed by
main's PARAM NAME, not by the cap type name. The host never sees
the type, only the identifier the program chose - which is the root
of a separate pre-existing divergence being ticketed.

capa.runtime does not import _wasm_host, so transpiled programs do
not pick up the new capa.ir edge; verified no import cycle.

Fold in one stale docstring: _floor_completions no longer describes
capabilities as curated.
…smtime

The root-handle guard reached capa.runtime._wasm_host at module
scope, which imports wasmtime at module load. The plain `test` CI
job does not install the wasm extra, so the whole module failed to
IMPORT - and a failed import is an ERROR, not a skip. All nine
Python matrix jobs went red; the WASI job passed because it has
wasmtime, which is why a local pytest run did not catch it. Same
lesson as PR #73.

Move the _wasm_host import inside the one method that needs it and
gate that method on a local _has_wasmtime() probe, following the
idiom already used by test_aot, test_ir_wasm and the foreign test
modules.

The point of putting these guards in test_cap_handles.py rather
than the skip-happy test_properties.py was that they must always
run, so the fix deliberately keeps that property: only the
root-handle guard, which genuinely needs the host, is skippable.
The five pure-data registry guards (the two registries agree, every
cap is classified, the classes are disjoint, the LSP list is
complete, the WIT known-caps set) still execute with no wasmtime.

Verified against a meta-path shim that blocks wasmtime: the old
code fails to import, the new code collects 16 tests, runs 15 and
skips only the root-handle guard. Re-proved all three guard
directions still bite (fake cap in one registry, fake cap in both,
Random reclassified as handle-bearing).
@nelsonduarte
nelsonduarte merged commit 34b7129 into main Jul 18, 2026
14 checks passed
@nelsonduarte
nelsonduarte deleted the chore/capability-registry-guard branch July 18, 2026 18:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant