Add bindspace capability models

Separate serializable `BindspaceSpec` and `BindspaceIdentity` values
from a process-local `BindspaceHandle` carrying FD and ownership
authority.

Deats,
- add global `ProcessLocal` wire guards for local handle structs
- derive valid kinds and ownership from their `Literal` aliases
- require a positive inode while keeping the mutable name optional
- pin supplied FDs to identity inodes with `fstat()`
- cover round trips, nested encoding and stale capabilities

Caught-during: review remediation
Found-via: `/run-tests` test_bindspace_handle_pins_local_capability

Prompt-IO: ai/prompt-io/opencode/20260822T042026Z_29141f0b_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
Gud Boi 2026-08-25 00:50:51 -04:00
parent 29141f0b71
commit fdccfd7e85
9 changed files with 616 additions and 10 deletions

View File

@ -0,0 +1,64 @@
---
model: gpt-5.6-sol
service: opencode
session: tractor-addr-unpacking
timestamp: 2026-08-22T04:20:26Z
git_ref: 29141f0b
scope: code
substantive: true
raw_file: 20260822T042026Z_29141f0b_prompt_io.raw.md
---
## Prompt
After committing endpoint namespace visibility, the human authorized
continued Layer C implementation.
During staged review, the human requested Literal-derived validation,
ownership documentation, stable-inode clarification, explicit
non-serialization rationale and consolidated invalid-model tests.
## Response summary
Added the foundational bindspace model: serializable declarations and
stable identities are separated from a process-local live capability.
The handle validates names, ownership and FD identity. A global
`ProcessLocal` sentinel blocks default encoding while retaining
msgspec struct behavior.
Review fixes require a positive inode for every realized netns,
derive runtime choices from the Literal aliases and clarify that an FD
integer is not transferable capability authority.
The human then clarified that msgspec structs are useful generic
storage independently of serialization policy, so the live handle now
uses a struct while remaining process-local by contract.
The human first selected an opaque FD wrapper, then recognized that
future process-local handles need the same guard and directed a global
marker under `tractor.msg` instead.
## Files changed
- `tractor/discovery/_bindspace.py` - declaration, identity and live
capability models.
- `tractor/discovery/__init__.py` - public bindspace exports.
- `tractor/msg/_local.py` - reusable process-local struct marker.
- `tractor/msg/__init__.py` - public `ProcessLocal` export.
- `tests/discovery/test_bindspace.py` - serialization and capability
invariant coverage.
- `tests/msg/test_process_local.py` - direct and nested wire rejection.
- `ai/tpt-backends/03_wg_tunnel_bindspace.md` - concrete initial model
contract.
## Human edits
The human selected the foundational Layer C capability model, then
reviewed the staged patch and directed all eight follow-up changes:
remove distracting ownership variation, consolidate invalid-model
tests, derive containment checks from Literal aliases, require a
stable inode and explain why the live handle is not a msgspec struct.
The human subsequently corrected that last design choice: struct use
does not imply IPC authorization, so `BindspaceHandle` should retain
msgspec's generic storage benefits. The agent applied those
human-directed edits. The human first chose an opaque process-local FD
wrapper, then refined that decision to a reusable global marker under
`tractor.msg` for future local handles. No direct manual source edits
were observed.

View File

@ -0,0 +1,27 @@
---
model: gpt-5.6-sol
service: opencode
timestamp: 2026-08-22T04:20:26Z
git_ref: 29141f0b
diff_cmd: git diff HEAD~1..HEAD
---
# Raw output - model bindspace capabilities
The human authorized the next incremental Layer C change after
committing endpoint namespace visibility.
> `git diff HEAD~1..HEAD -- tractor/discovery/_bindspace.py tractor/discovery/__init__.py tests/discovery/test_bindspace.py ai/tpt-backends/03_wg_tunnel_bindspace.md`
Added frozen, serializable `BindspaceSpec` and
`BindspaceIdentity` models for the initial Linux netns kind. Added a
plain slotted `BindspaceHandle` which carries ownership and an optional
namespace FD without being serializable by default msgspec encoding.
The live handle validates declaration/identity kind and name, ownership
values, non-negative FDs and FD/inode correspondence. Serializable
models reject empty identities and kinds without implementations.
Added round-trip, non-serialization, identity-mismatch, empty-identity
and unsupported-kind tests. Ruff passed; discovery plus
message-serialization coverage passed 125 tests with 2 xpasses.

View File

@ -413,11 +413,11 @@ class BindspaceSpec(msgspec.Struct, frozen=True):
class BindspaceIdentity(msgspec.Struct, frozen=True): class BindspaceIdentity(msgspec.Struct, frozen=True):
'''Stable identity of the realized platform resource.''' '''Stable identity of the realized platform resource.'''
kind: str kind: str
key: str|None key: str|None # mutable name, absent after unlink
inode: int|None # Linux namespace identity inode: int # stable Linux namespace identity
class BindspaceHandle: class BindspaceHandle(ProcessLocal):
'''Scoped, non-serializable capability for one live bindspace.''' '''Scoped, non-serializable capability for one live bindspace.'''
spec: BindspaceSpec spec: BindspaceSpec
identity: BindspaceIdentity identity: BindspaceIdentity
@ -437,13 +437,19 @@ async def open_bindspace(
''' '''
``` ```
The exact field set remains design work; the required split does not: The initial model limits `BindspaceKind` to `netns` while preserving
`BindspaceSpec` crosses config/spawn serialization, while the required lifetime split. `BindspaceSpec` and
`BindspaceHandle` contains live OS resources (especially an open `BindspaceIdentity` are frozen msgspec structs which cross
namespace FD), pins identity/lifetime, and must never cross msgpack. config/spawn serialization. `BindspaceHandle` also uses msgspec's
An FD is a stronger capability than a namespace name: it avoids generic struct storage by inheriting the global
name-resolution TOCTOU, survives rename/unlink, and identifies the `tractor.msg.ProcessLocal` marker. Its hidden unsupported sentinel
exact namespace the parent provisioned. blocks direct and nested default msgspec encoding without a recursive
IPC hot-path scan. The handle validates any supplied FD against
`BindspaceIdentity.inode`; explicit FD transfer belongs to the
supervisor bootstrap path. An FD avoids name-resolution TOCTOU,
survives rename/unlink, and identifies the exact namespace the parent
provisioned. Extend the kind/field union only when a second platform
resource is implemented.
`open_bindspace()` is **not** an address factory and does not return a `open_bindspace()` is **not** an address factory and does not return a
`TunnelledAddress`. At the declaration layer, listener allocation can `TunnelledAddress`. At the declaration layer, listener allocation can

View File

@ -0,0 +1,201 @@
'''
Bindspace declaration, identity and live-capability contracts.
'''
from __future__ import annotations
from pathlib import Path
from typing import BinaryIO
import msgspec
import pytest
from tractor.discovery import (
BindspaceHandle,
BindspaceIdentity,
BindspaceOwnership,
BindspaceSpec,
)
from tractor.msg import ProcessLocal
def test_bindspace_declarations_roundtrip() -> None:
'''
Spawn configuration and realized identity must cross actor IPC.
Encode both frozen structs through msgpack and decode with their
concrete types, proving names and stable inode identity survive
without carrying any process-local capability state.
'''
values: tuple[
BindspaceSpec|BindspaceIdentity,
...,
] = (
BindspaceSpec(kind='netns', key='tractor-wg0'),
BindspaceIdentity(
kind='netns',
key='tractor-wg0',
inode=1234,
),
)
value: BindspaceSpec|BindspaceIdentity
for value in values:
encoded: bytes = msgspec.msgpack.encode(value)
decoded: BindspaceSpec|BindspaceIdentity = (
msgspec.msgpack.decode(
encoded,
type=type(value),
)
)
assert decoded == value
def test_bindspace_handle_pins_local_capability(
tmp_path: Path,
) -> None:
'''
A live handle pins one exact FD and realized identity.
Open a stand-in platform handle, record its inode in the realized
identity and construct an owned capability. Prove the generic
msgspec struct retains that exact local state. Its ability to
encode ordinary fields is not authority to transfer the handle.
'''
token_path: Path = tmp_path / 'bindspace'
token_path.touch()
namespace_file: BinaryIO
with token_path.open('rb') as namespace_file:
namespace_fd: int = namespace_file.fileno()
inode: int = token_path.stat().st_ino
spec: BindspaceSpec = BindspaceSpec(
kind='netns',
key='tractor-wg0',
)
identity: BindspaceIdentity = BindspaceIdentity(
kind='netns',
key='tractor-wg0',
inode=inode,
)
handle: BindspaceHandle = BindspaceHandle(
spec=spec,
identity=identity,
namespace_fd=namespace_fd,
ownership='owned',
)
assert handle.spec is spec
assert handle.identity is identity
assert handle.namespace_fd == namespace_file.fileno()
assert handle.ownership == 'owned'
assert isinstance(handle, msgspec.Struct)
assert isinstance(handle, ProcessLocal)
with pytest.raises(
TypeError,
match='_ProcessLocalToken.*unsupported',
):
msgspec.msgpack.encode(handle)
def test_bindspace_handle_rejects_mismatched_identity(
tmp_path: Path,
) -> None:
'''
A name or inode mismatch would make a handle stale authority.
Construct a requested named spec, then prove both a different
realized name and an inode not belonging to the supplied FD are
rejected before either can become a live capability.
'''
token_path: Path = tmp_path / 'bindspace'
token_path.touch()
spec: BindspaceSpec = BindspaceSpec(
kind='netns',
key='tractor-wg0',
)
# Keep ownership and FD fixed so only identity changes below.
ownership: BindspaceOwnership = 'borrowed'
namespace_file: BinaryIO
with token_path.open('rb') as namespace_file:
namespace_fd: int = namespace_file.fileno()
wrong_name: BindspaceIdentity = BindspaceIdentity(
kind='netns',
key='other-wg',
inode=token_path.stat().st_ino,
)
with pytest.raises(
ValueError,
match='Spec.key.*Identity.key',
):
BindspaceHandle(
spec=spec,
identity=wrong_name,
namespace_fd=namespace_fd,
ownership=ownership,
)
wrong_inode: BindspaceIdentity = BindspaceIdentity(
kind='netns',
key='tractor-wg0',
inode=token_path.stat().st_ino + 1,
)
with pytest.raises(
ValueError,
match='FD inode.*identity inode',
):
BindspaceHandle(
spec=spec,
identity=wrong_inode,
namespace_fd=namespace_fd,
ownership=ownership,
)
@pytest.mark.parametrize(
('model', 'kwargs', 'match'),
(
pytest.param(
BindspaceIdentity,
{
'kind': 'netns',
'key': None,
'inode': None,
},
'must be a positive `int`',
id='identity-requires-inode',
),
pytest.param(
BindspaceSpec,
{'kind': 'vrf'},
'Unsupported bindspace kind',
id='spec-rejects-kind',
),
pytest.param(
BindspaceIdentity,
{
'kind': 'vrf',
'key': 'blue',
'inode': 1234,
},
'Unsupported bindspace kind',
id='identity-rejects-kind',
),
),
)
def test_bindspace_models_reject_invalid_values(
model: type[BindspaceSpec]|type[BindspaceIdentity],
kwargs: dict[str, object],
match: str,
) -> None:
'''
Direct msgspec construction does not enforce field annotations.
Parameterize the missing stable inode and future, unimplemented
kinds. Prove neither serializable model can carry invalid
identity or provisioning instructions into spawn configuration.
'''
with pytest.raises(ValueError, match=match):
model(**kwargs) # type: ignore[arg-type]

View File

@ -0,0 +1,57 @@
'''
Process-local struct wire-encoding guards.
'''
from __future__ import annotations
import msgspec
import pytest
from tractor.msg import ProcessLocal
class LocalHandle(ProcessLocal):
'''
Minimal process-local struct used to exercise the global marker.
'''
resource_id: int
@pytest.mark.parametrize(
'nested',
(
pytest.param(
False,
id='direct',
),
pytest.param(
True,
id='nested',
),
),
)
def test_process_local_rejects_default_encoding(
nested: bool,
) -> None:
'''
Process-local values can appear directly or deep in a payload.
Embed the same marked struct at both depths and prove msgspec's
normal traversal reaches the unsupported sentinel without a
tractor-specific recursive payload scan.
'''
handle: LocalHandle = LocalHandle(resource_id=1)
value: object = (
{'nested': [handle]}
if nested
else handle
)
assert repr(handle) == 'LocalHandle(resource_id=1)'
with pytest.raises(
TypeError,
match='_ProcessLocalToken.*unsupported',
):
msgspec.msgpack.encode(value)

View File

@ -24,6 +24,13 @@ Heavier submodules like ``._addr`` and ``._api`` are NOT imported
here to avoid circular imports; use direct module paths for those. here to avoid circular imports; use direct module paths for those.
''' '''
from ._bindspace import (
BindspaceHandle as BindspaceHandle,
BindspaceIdentity as BindspaceIdentity,
BindspaceKind as BindspaceKind,
BindspaceOwnership as BindspaceOwnership,
BindspaceSpec as BindspaceSpec,
)
from ._multiaddr import ( from ._multiaddr import (
parse_endpoints as parse_endpoints, parse_endpoints as parse_endpoints,
parse_maddr as parse_maddr, parse_maddr as parse_maddr,

View File

@ -0,0 +1,187 @@
# tractor: structured concurrent "actors".
# Copyright 2018-eternity Tyler Goodlet.
# This program is free software: you can redistribute it and/or
# modify it under the terms of the GNU Affero General Public License
# as published by the Free Software Foundation, either version 3 of
# the License, or (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
# You should have received a copy of the GNU Affero General Public
# License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
'''
Serializable bindspace declarations and live capability handles.
'''
from __future__ import annotations
import os
from typing import (
get_args,
Literal,
TypeAlias,
)
import msgspec
from ..msg._local import ProcessLocal
BindspaceKind: TypeAlias = Literal[
'netns',
]
BindspaceOwnership: TypeAlias = Literal[
'owned', # manager tears down the resource after final release
'borrowed', # manager leaves the pre-existing resource intact
]
def _validate_bindspace_kind(
kind: BindspaceKind,
) -> None:
'''
Reject platform-resource kinds without an implementation.
'''
if kind not in get_args(BindspaceKind):
raise ValueError(
f'Unsupported bindspace kind: {kind!r}'
)
class BindspaceSpec(
msgspec.Struct,
frozen=True,
):
'''
Serializable declaration of one requested bindspace.
'''
kind: BindspaceKind
key: str|None = None
def __post_init__(self) -> None:
'''
Reject an empty platform-resource key.
'''
_validate_bindspace_kind(self.kind)
if self.key == '':
raise ValueError(
'`BindspaceSpec.key` must be non-empty or `None`!'
)
class BindspaceIdentity(
msgspec.Struct,
frozen=True,
):
'''
Serializable stable identity of one realized bindspace.
`.key` is an optional, mutable namespace name. `.inode` is the
required kernel identity which remains stable after rename or
unlink.
'''
kind: BindspaceKind
key: str|None
inode: int
def __post_init__(self) -> None:
'''
Require a stable platform identity and an optional name.
'''
_validate_bindspace_kind(self.kind)
if self.key == '':
raise ValueError(
'`BindspaceIdentity.key` must be non-empty '
'or `None`!'
)
if (
type(self.inode) is not int
or
self.inode <= 0
):
raise ValueError(
'`BindspaceIdentity.inode` must be a positive `int`!'
)
class BindspaceHandle(
ProcessLocal,
):
'''
Process-local capability for one live realized bindspace.
`ProcessLocal` provides compact typed storage plus a default
wire-encoding guard. Explicit FD transfer and handle construction
belong to the supervisor's spawn/bootstrap path.
'''
spec: BindspaceSpec
identity: BindspaceIdentity
namespace_fd: int|None
ownership: BindspaceOwnership
def __post_init__(self) -> None:
'''
Validate and retain one scoped bindspace capability.
'''
spec: BindspaceSpec = self.spec
identity: BindspaceIdentity = self.identity
namespace_fd: int|None = self.namespace_fd
ownership: BindspaceOwnership = self.ownership
if spec.kind != identity.kind:
raise ValueError(
'`BindspaceSpec.kind` does not match '
'`BindspaceIdentity.kind`!'
)
if (
spec.key is not None
and
spec.key != identity.key
):
raise ValueError(
'`BindspaceSpec.key` does not match '
'`BindspaceIdentity.key`!'
)
if ownership not in get_args(BindspaceOwnership):
raise ValueError(
f'Invalid bindspace ownership: {ownership!r}'
)
if namespace_fd is not None:
if (
type(namespace_fd) is not int
or
namespace_fd < 0
):
raise ValueError(
'`namespace_fd` must be non-negative or `None`!'
)
fd_inode: int = os.fstat(namespace_fd).st_ino
if identity.inode != fd_inode:
raise ValueError(
f'Namespace FD inode {fd_inode} does not match '
f'identity inode {identity.inode}!'
)
def __repr__(self) -> str:
'''
Render capability identity without dereferencing its FD.
'''
return (
f'{type(self).__name__}('
f'identity={self.identity!r}, '
f'ownership={self.ownership!r}, '
f'namespace_fd={self.namespace_fd!r})'
)

View File

@ -27,6 +27,9 @@ from .ptr import (
from .pretty_struct import ( from .pretty_struct import (
Struct as Struct, Struct as Struct,
) )
from ._local import (
ProcessLocal as ProcessLocal,
)
from ._codec import ( from ._codec import (
_def_msgspec_codec as _def_msgspec_codec, _def_msgspec_codec as _def_msgspec_codec,
_ctxvar_MsgCodec as _ctxvar_MsgCodec, _ctxvar_MsgCodec as _ctxvar_MsgCodec,

View File

@ -0,0 +1,54 @@
# tractor: structured concurrent "actors".
# Copyright 2018-eternity Tyler Goodlet.
# This program is free software: you can redistribute it and/or
# modify it under the terms of the GNU Affero General Public License
# as published by the Free Software Foundation, either version 3 of
# the License, or (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
# You should have received a copy of the GNU Affero General Public
# License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
'''
Markers for process-local values which must not cross actor IPC.
'''
from __future__ import annotations
import msgspec
class _ProcessLocalToken:
'''
Unsupported msgspec value embedded in every `ProcessLocal`.
'''
__slots__ = ()
_PROCESS_LOCAL_TOKEN: _ProcessLocalToken = _ProcessLocalToken()
class ProcessLocal(
msgspec.Struct,
kw_only=True,
repr_omit_defaults=True,
):
'''
Generic struct marker which rejects default msgspec encoding.
The hidden sentinel remains part of the encoded field set, so
msgspec encounters `_ProcessLocalToken` and raises `TypeError`
even when this value is nested inside another supported payload.
A custom encode hook may explicitly override that safeguard.
Keyword-only fields let subclasses add required fields after the
marker's default sentinel.
'''
_process_local: _ProcessLocalToken = _PROCESS_LOCAL_TOKEN