diff --git a/ai/prompt-io/opencode/20260822T042026Z_29141f0b_prompt_io.md b/ai/prompt-io/opencode/20260822T042026Z_29141f0b_prompt_io.md
new file mode 100644
index 00000000..0148f31d
--- /dev/null
+++ b/ai/prompt-io/opencode/20260822T042026Z_29141f0b_prompt_io.md
@@ -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.
diff --git a/ai/prompt-io/opencode/20260822T042026Z_29141f0b_prompt_io.raw.md b/ai/prompt-io/opencode/20260822T042026Z_29141f0b_prompt_io.raw.md
new file mode 100644
index 00000000..8fb196f4
--- /dev/null
+++ b/ai/prompt-io/opencode/20260822T042026Z_29141f0b_prompt_io.raw.md
@@ -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.
diff --git a/ai/tpt-backends/03_wg_tunnel_bindspace.md b/ai/tpt-backends/03_wg_tunnel_bindspace.md
index 2b8ad1c6..582ef70b 100644
--- a/ai/tpt-backends/03_wg_tunnel_bindspace.md
+++ b/ai/tpt-backends/03_wg_tunnel_bindspace.md
@@ -413,11 +413,11 @@ class BindspaceSpec(msgspec.Struct, frozen=True):
class BindspaceIdentity(msgspec.Struct, frozen=True):
'''Stable identity of the realized platform resource.'''
kind: str
- key: str|None
- inode: int|None # Linux namespace identity
+ key: str|None # mutable name, absent after unlink
+ inode: int # stable Linux namespace identity
-class BindspaceHandle:
+class BindspaceHandle(ProcessLocal):
'''Scoped, non-serializable capability for one live bindspace.'''
spec: BindspaceSpec
identity: BindspaceIdentity
@@ -437,13 +437,19 @@ async def open_bindspace(
'''
```
-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.
+The initial model limits `BindspaceKind` to `netns` while preserving
+the required lifetime split. `BindspaceSpec` and
+`BindspaceIdentity` are frozen msgspec structs which cross
+config/spawn serialization. `BindspaceHandle` also uses msgspec's
+generic struct storage by inheriting the global
+`tractor.msg.ProcessLocal` marker. Its hidden unsupported sentinel
+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
`TunnelledAddress`. At the declaration layer, listener allocation can
diff --git a/tests/discovery/test_bindspace.py b/tests/discovery/test_bindspace.py
new file mode 100644
index 00000000..ddebc915
--- /dev/null
+++ b/tests/discovery/test_bindspace.py
@@ -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]
diff --git a/tests/msg/test_process_local.py b/tests/msg/test_process_local.py
new file mode 100644
index 00000000..c8e220a2
--- /dev/null
+++ b/tests/msg/test_process_local.py
@@ -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)
diff --git a/tractor/discovery/__init__.py b/tractor/discovery/__init__.py
index cfa01cf5..a765cae9 100644
--- a/tractor/discovery/__init__.py
+++ b/tractor/discovery/__init__.py
@@ -24,6 +24,13 @@ Heavier submodules like ``._addr`` and ``._api`` are NOT imported
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 (
parse_endpoints as parse_endpoints,
parse_maddr as parse_maddr,
diff --git a/tractor/discovery/_bindspace.py b/tractor/discovery/_bindspace.py
new file mode 100644
index 00000000..dcb3650c
--- /dev/null
+++ b/tractor/discovery/_bindspace.py
@@ -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
+# .
+'''
+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})'
+ )
diff --git a/tractor/msg/__init__.py b/tractor/msg/__init__.py
index 88220054..b1a1ce5c 100644
--- a/tractor/msg/__init__.py
+++ b/tractor/msg/__init__.py
@@ -27,6 +27,9 @@ from .ptr import (
from .pretty_struct import (
Struct as Struct,
)
+from ._local import (
+ ProcessLocal as ProcessLocal,
+)
from ._codec import (
_def_msgspec_codec as _def_msgspec_codec,
_ctxvar_MsgCodec as _ctxvar_MsgCodec,
diff --git a/tractor/msg/_local.py b/tractor/msg/_local.py
new file mode 100644
index 00000000..1bdd8b49
--- /dev/null
+++ b/tractor/msg/_local.py
@@ -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
+# .
+'''
+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