Model bindspaces as scoped capabilities

Separate serializable bindspace declarations from live namespace
identity, FDs, ownership and teardown resources.

Require child namespace entry during spawn bootstrap, before actor
runtime initialization, then distinguish listen/dial provisioning and
owned/borrowed cleanup without encoding operation role into maddrs.

Prompt-IO: ai/prompt-io/opencode/20260820T021516Z_dfad66a0_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
wkt/addr_unpacking
Gud Boi 2026-08-19 22:31:56 -04:00
parent dfad66a00a
commit ba07e09d2a
4 changed files with 224 additions and 30 deletions

View File

@ -0,0 +1,41 @@
---
model: gpt-5.6-sol
service: opencode
session: tractor-addr-unpacking-followup
timestamp: 2026-08-20T02:15:16Z
git_ref: dfad66a0
scope: docs
substantive: true
raw_file: 20260820T021516Z_dfad66a0_prompt_io.raw.md
---
## Prompt
The human requested that the bindspace plan preserve the agreed
capability, spawn-bootstrap, endpoint-role, namespace augmentation,
random-address, and teardown semantics, using `github/ns_aware` as
prototype input.
## Response summary
Updated plan-03 and the shared backend contract to separate serializable
bindspace declarations from scoped live capabilities, make namespace
entry a pre-runtime spawn operation, keep maddr paths role-neutral, and
define listen/dial provisioning plus ownership-sensitive teardown.
## Files changed
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - layer-C capability,
bootstrap, role, teardown, test, and risk model.
- `ai/tpt-backends/00_shared_backend_contract.md` - distinguish
transport bind selectors from process namespace lifecycle.
## Human edits
The human supplied the core architecture: structured scoped
capabilities, spawn-time namespace entry, orthogonal namespace
augmentation, source/destination-dependent provisioning, and
role-dependent teardown. They also rejected premature assumptions about
`open_bindspace()` returning an address and requested grounding in the
existing namespace prototype. The agent translated those decisions into
the plan text; no direct manual source edits were observed.

View File

@ -0,0 +1,34 @@
---
model: gpt-5.6-sol
service: opencode
timestamp: 2026-08-20T02:15:16Z
git_ref: dfad66a0
diff_cmd: git diff HEAD~1..HEAD
---
# Raw output - bindspace capability design
The human corrected the layer-C design around local network-stack
realization. They established that bindspace state should be both
structured and a scoped capability; namespace entry belongs in
subactor bootstrap; maddrs can describe source or destination network
paths while namespace selection augments them orthogonally; random
address and teardown behavior depend on operation role and ownership.
They directed comparison with the prototype on `github/ns_aware` and
requested these decisions be preserved in the plan.
> `git diff HEAD~1..HEAD -- ai/tpt-backends/03_wg_tunnel_bindspace.md ai/tpt-backends/00_shared_backend_contract.md`
Reworked layer C around serializable `BindspaceSpec`, stable
`BindspaceIdentity`, and scoped non-serializable `BindspaceHandle`
concepts. Namespace FDs pin identity and lifetime; parent/supervisor
provisioning transfers entry capability through spawn; the child enters
before runtime, channels, listeners, sockets, or worker threads and then
drops authority. Listen/dial roles and owned/borrowed teardown are
explicit, while maddrs remain role-neutral network-path declarations.
The shared backend contract now separates transport-level `.bindspace`
selectors from process namespace lifecycle. Added tests/risks for FD
identity, bootstrap ordering, privilege drop, role ownership, and
shared-resource teardown.

View File

@ -112,7 +112,12 @@ Hard constraints learned from the existing two:
- **`.bindspace` semantics**: "the address' bindable space" — - **`.bindspace` semantics**: "the address' bindable space" —
ip/host for `tcp`, the socket-file *directory* for `uds`. For ip/host for `tcp`, the socket-file *directory* for `uds`. For
the new backends: the TIPC *scope* (§1 of plan 01), the iroh the new backends: the TIPC *scope* (§1 of plan 01), the iroh
*ALPN + relay/discovery realm* (plan 02), the netns (plan 03). *ALPN + relay/discovery realm* (plan 02). Do not overload this
transport-level bind selector with process namespace lifecycle.
Plan 03 augments an maddr/address declaration with a serializable
`BindspaceSpec` and a scoped, non-serializable `BindspaceHandle`;
the latter owns namespace identity/FD/lifetime and is consumed at
spawn bootstrap before a concrete address reaches transport bind.
`Address.namespace` is already spec'd in the Protocol as `Address.namespace` is already spec'd in the Protocol as
"the if-available OS-specific network namespace key" and is "the if-available OS-specific network namespace key" and is
currently unimplemented by both backends — plan 03 is the currently unimplemented by both backends — plan 03 is the

View File

@ -329,39 +329,109 @@ for the overlay application socket.
### 5.1 the composition ### 5.1 the composition
The maddr describes the composed network path and can be used as
either a source/listen or destination/dial handle. It does **not**
select the local instance of that network stack. A netns, VRF,
interface, user namespace, or equivalent platform resource is
orthogonal augmentation carried alongside/below the maddr.
Keep two bindspace representations with deliberately different
lifetimes:
```python ```python
class BindspaceSpec(msgspec.Struct, frozen=True):
'''Serializable spawn/config declaration.'''
kind: str # `netns`, later `vrf`, ...
key: str|None # requested name/key, if any
class BindspaceIdentity(msgspec.Struct, frozen=True):
'''Stable identity of the realized platform resource.'''
kind: str
key: str|None
inode: int|None # Linux namespace identity
class BindspaceHandle:
'''Scoped, non-serializable capability for one live bindspace.'''
spec: BindspaceSpec
identity: BindspaceIdentity
namespace_fd: int|None
ownership: Literal['owned', 'borrowed']
@acm @acm
async def open_bindspace( async def open_bindspace(
addr: TunnelledAddress, spec: BindspaceSpec,
) -> AsyncGenerator[Address, None]: *,
role: Literal['listen', 'dial'],
) -> AsyncGenerator[BindspaceHandle, None]:
''' '''
Enter the net-bindspace implied by `addr`'s tunnel stack, Provision/borrow one bindspace and yield its live capability.
yielding the *overlay* `Address` ready to bind/connect.
Nests: one `@acm` per tunnel segment, outermost-first, so
a 2-deep stack is just two nested `async with`s and the
teardown order is guaranteed by `trio`.
''' '''
``` ```
with per-tunnel-kind implementations: The exact field set remains design work; the required split does not:
`BindspaceSpec` crosses config/spawn serialization, while
`BindspaceHandle` contains live OS resources (especially an open
namespace FD), pins identity/lifetime, and must never cross msgpack.
An FD is a stronger capability than a namespace name: it avoids
name-resolution TOCTOU, survives rename/unlink, and identifies the
exact namespace the parent provisioned.
`open_bindspace()` is **not** an address factory and does not return a
`TunnelledAddress`. At the declaration layer, listener allocation can
use the handle to replace an overlay while preserving every tunnel:
```python
async with open_bindspace(
bindspace_spec,
role='listen',
) as bindspace:
listen_decl = declared_addr.get_random(
bindspace=bindspace,
)
transport_addr = strip_tunnels(listen_decl)
```
That sketch intentionally leaves the `.get_random()`/bindspace value
contract open. A concrete transport call returns a concrete overlay;
a declaration-level call may replace the overlay and return a new
`TunnelledAddress`. In either case wrappers remain until the final
transport bind/dial boundary, where `strip_tunnels()` is mandatory.
Per-platform provisioning still composes one resource context per
tunnel/bindspace layer:
```python ```python
@acm @acm
async def open_netns(name: str) -> AsyncGenerator[None, None]: ... async def open_netns(
spec: BindspaceSpec,
role: Literal['listen', 'dial'],
) -> AsyncGenerator[BindspaceHandle, None]: ...
@acm @acm
async def open_wg_iface(spec: WGTunnelSpec) -> AsyncGenerator[WGTunnelSpec, None]: ... async def open_wg_iface(
spec: WGTunnelSpec,
bindspace: BindspaceHandle,
role: Literal['listen', 'dial'],
) -> AsyncGenerator[WGTunnelSpec, None]: ...
``` ```
and a driver that folds a list of specs into nested contexts and a driver that folds a list of specs into nested contexts
(`contextlib.AsyncExitStack` for the N-deep case). The (`contextlib.AsyncExitStack` for the N-deep case). The
`parse_endpoints()` API (`_multiaddr.py:153`) is the front door: `parse_endpoints()` API (`_multiaddr.py:153`) is the front door:
it already returns `dict[name, list[Address]]` and the it already returns
`dict[name, list[Address|TunnelledAddress]]` and the
`multiaddr_declare_eps.md` sketch anticipates the recursive `multiaddr_declare_eps.md` sketch anticipates the recursive
`dict[str, list[Address]]|dict[...]` return for tunnelled `dict[str, list[Address]]|dict[...]` return for tunnelled
entries. Extend it to carry the tunnel stack, not to *enter* it. entries. Extend it to carry the tunnel stack, not to *enter* it.
The caller supplies `role`; do not infer it from maddr shape. The same
composed maddr can name a server source or client destination, and the
required local provisioning/ownership differs (§5.3).
### 5.2 `Address.namespace`, at last ### 5.2 `Address.namespace`, at last
- `TunnelledAddress.namespace``(kind, id)` e.g. - `TunnelledAddress.namespace``(kind, id)` e.g.
@ -377,6 +447,13 @@ entries. Extend it to carry the tunnel stack, not to *enter* it.
`Endpoint.pformat()`, `_server.py:645`). Fill that in; it's `Endpoint.pformat()`, `_server.py:645`). Fill that in; it's
the cheapest possible proof the layer is wired. the cheapest possible proof the layer is wired.
Use `github/ns_aware@e4688cad` as prototype evidence, not code to
cherry-pick unchanged. Its `/proc/<pid>/ns/<type>` inode reader and
`ip netns identify` probe establish the useful `(key, inode)` identity
pair. Layer C should move that shape into `BindspaceIdentity`, avoid a
subprocess where netlink/procfs suffices, and hold the namespace FD in
`BindspaceHandle` to pin the identity.
### 5.3 the netns/process reality — read this before designing ### 5.3 the netns/process reality — read this before designing
**The headline consequence, stated up front**: netns is a **The headline consequence, stated up front**: netns is a
@ -404,26 +481,52 @@ server bound in the old namespace.
- entering a netns is *process-global-ish and irreversible-ish* - entering a netns is *process-global-ish and irreversible-ish*
in practice. Therefore: **netns membership belongs to the in practice. Therefore: **netns membership belongs to the
actor process, decided before the runtime binds**, not to a actor process, decided before the runtime binds**, not to a
mid-life `@acm`. Design: mid-life actor API. Design:
- the root/parent decides the netns for a subactor and passes - the root/parent decides the `BindspaceSpec`, provisions or
it in the spawn spec (there's already borrows it, and passes the spec plus an inherited/transferred
namespace-FD capability through the spawn backend (there's already
`enable_transports`/`accept_addrs` plumbing at `enable_transports`/`accept_addrs` plumbing at
`_runtime.py:1595-1615` — the netns rides alongside). `_runtime.py:1595-1615` — the netns rides alongside).
- the child, in `_runtime.async_main()` **before** - the child spawn/bootstrap trampoline calls `setns()` **before**
`IPCServer.listen_on()`, enters it. `_runtime.async_main()`, `IPCServer.listen_on()`, parent-channel
- the mid-life `@acm` form is then only for the *root* / connection, or creation of any worker thread/socket.
single-actor case, and for iface creation (which is - only after successful entry does the child drop namespace-entry
genuinely scoped). privileges and initialize the actor runtime.
- a root/single-actor process follows the same ordering: enter during
root bootstrap, never after actor runtime startup.
- iface/route/WG provisioning is genuinely scoped and remains under
the parent/supervisor's `BindspaceHandle` context.
- document the constraint rather than hiding it; a - document the constraint rather than hiding it; a
`RuntimeError` if `open_netns()` is entered after any `RuntimeError` if namespace entry is attempted after bootstrap.
listener exists. - capabilities: iface/netns creation/config needs `CAP_NET_ADMIN`;
- privileges: iface/netns creation needs `CAP_NET_ADMIN`. entering an existing Linux namespace normally requires
Never `sudo` from inside the runtime. Two supported modes: `CAP_SYS_ADMIN` in the owning user namespace. Never `sudo` from
inside the runtime. A privileged parent/helper should provision the
stack and open the namespace FD; the child receives only the scoped
capability and temporary authority needed to enter it, then drops
that authority before actor code runs. This separates create/config
authority from enter/use authority and fits user-namespace/capability
deployments without granting every actor broad ambient caps.
Two supported modes remain:
(i) pre-provisioned out-of-band (layers A/B — the default, (i) pre-provisioned out-of-band (layers A/B — the default,
and what #482 documents), (ii) runtime-managed when the and what #482 documents), (ii) runtime-managed when the supervising
process already holds the cap. Detect with a cheap process/helper holds the required caps. Probe exact required caps and
`os.geteuid()==0 or CAP_NET_ADMIN in /proc/self/status` *fail loudly with an actionable message* otherwise.
probe and *fail loudly with an actionable message* otherwise. - role semantics are explicit:
- `listen`: may create/own the local bindspace, iface, routes, WG
peer/listener state, and random local overlay; lifetime normally
extends through all listeners and the actor process.
- `dial`: may borrow an actor-wide bindspace or ensure local routing
and tunnel state reaches the remote stack; it does not own the
remote maddr and may need no new local resource at all.
- source/destination use is an operation property, never permanently
encoded into the maddr or inferred from segment ordering.
- teardown follows capability ownership, not just address type:
- owned listener bindspaces tear down after endpoints/channels and
the actor process have exited;
- borrowed dial/actor-wide bindspaces only release their handle;
- nested resources exit inside-out, but shared resources remain until
their owning supervisor drops the final capability.
- teardown must be idempotent and tolerant: an iface/netns - teardown must be idempotent and tolerant: an iface/netns
already gone must not strand the rest of the teardown — the already gone must not strand the rest of the teardown — the
exact lesson `_uds.close_listener()`'s `FileNotFoundError` exact lesson `_uds.close_listener()`'s `FileNotFoundError`
@ -442,6 +545,13 @@ server bound in the old namespace.
self-contained — no second host, no `sudo` in the test body. self-contained — no second host, no `sudo` in the test body.
- the `to_thread`-netns-mismatch regression from §5.3, written - the `to_thread`-netns-mismatch regression from §5.3, written
**first** (red), then the fix (green), per project convention. **first** (red), then the fix (green), per project convention.
- bootstrap ordering: assert the child reports the expected namespace
inode before parent-channel connect and listener creation.
- FD capability: rename/unlink the namespace name after opening its FD
and prove child entry still selects the pinned inode.
- privilege drop: prove actor code lacks provisioning caps after entry.
- role/ownership: fake listen/dial resources and assert owned listener
teardown versus borrowed dial-handle release.
--- ---
@ -475,8 +585,12 @@ consider doing it *first* for exactly that reason.
| risk | mitigation | | risk | mitigation |
| --- | --- | | --- | --- |
| `to_thread` worker runs in the wrong netns | §5.3; pass `netns=` to pyroute2 or pin a worker; test-first | | `to_thread` worker runs in the wrong netns | §5.3; pass `netns=` to pyroute2 or pin a worker; test-first |
| namespace name is renamed/replaced between provision and spawn | pass an open namespace FD; verify `(key, inode)` after child entry |
| child starts sockets/threads before `setns()` | enter in the spawn bootstrap trampoline before `_runtime.async_main()`; assert inode ordering |
| ambient capabilities leak into actor app code | split provision/enter authority and drop caps before runtime initialization |
| dial path tears down a shared actor bindspace | encode ownership in `BindspaceHandle`; borrowed handles never remove resources |
| py-multiaddr#108 merged but unreleased | PEP 621 direct-revision pin + `_wg_proto_code()` gate; replace with a release floor once published | | py-multiaddr#108 merged but unreleased | PEP 621 direct-revision pin + `_wg_proto_code()` gate; replace with a release floor once published |
| `TunnelledAddress` leaks into `Endpoint` and breaks `inspect.getmodule()` | unwrap at parse/bindspace boundary; assert `not isinstance(ep.addr, TunnelledAddress)` in `Endpoint.__post_init__` | | `TunnelledAddress` leaks into transport reflection/type dispatch | keep wrappers through declaration/bindspace handling, call `strip_tunnels()` at channel/endpoint boundaries, and retain the boundary regressions |
| privileged ops in a library | never `sudo`; explicit cap probe + actionable error; pre-provisioned is the default | | privileged ops in a library | never `sudo`; explicit cap probe + actionable error; pre-provisioned is the default |
| pyroute2 0.9 asyncio core drags a loop into the actor | option (1) is a *thread*, not a loop; forbid `trio-asyncio` here (§4.1) | | pyroute2 0.9 asyncio core drags a loop into the actor | option (1) is a *thread*, not a loop; forbid `trio-asyncio` here (§4.1) |
| netns teardown strands actor teardown | idempotent/tolerant teardown mirroring `_uds.close_listener()` | | netns teardown strands actor teardown | idempotent/tolerant teardown mirroring `_uds.close_listener()` |