Harden `ProcessLocal` struct markers

Add `FrozenProcessLocal` for immutable coordinator snapshots while
retaining the existing default wire-encoding rejection.

Prevent callers and msgspec config from replacing or omitting the
process-local sentinel. Wrap subclass validation hooks with base
checks so mutable and frozen structs cannot reopen the wire bypass.

Cover direct and nested encoding, shallow frozen behavior, sentinel
replacement, omitted defaults and hostile validation hooks.

Prompt-IO: ai/prompt-io/opencode/20260907T011533Z_5966e8aa_frozen_process_local.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
wkt/runtime_net_scopes
Gud Boi 2026-09-06 23:49:16 -04:00
parent 212a8f82ac
commit d2274d3252
5 changed files with 288 additions and 7 deletions

View File

@ -0,0 +1,39 @@
---
model: openai/gpt-5.6-sol
service: opencode
session: ses_fa04e8396ffeEUnWUrYF734CSM
timestamp: 2026-09-07T01:15:33Z
git_ref: 5966e8aa
scope: code
substantive: true
raw_file: 20260907T011533Z_5966e8aa_frozen_process_local.raw.md
---
## Prompt
After landing the dependency-neutral discovery typing layer, the
maintainer approved isolating `FrozenProcessLocal` as the next clean
prerequisite before revisiting bindspace and bootstrap design.
## Response summary
Added and publicly exported the immutable process-local marker. Moved
its behavioral coverage out of dependence on bootstrap models by adding
focused test structs and regressions to the existing process-local test
module. The tests demonstrate default wire rejection, frozen struct
fields, shallow nested mutability, rejection of sentinel replacement and
`omit_defaults=True`, and guarded subclass validation hooks which cannot
replace the sentinel.
## Files changed
- `tractor/msg/_local.py` - immutable process-local marker.
- `tractor/msg/__init__.py` - public marker export.
- `tests/msg/test_process_local.py` - independent marker contract.
## Human edits
The maintainer rejected incremental salvage of the current bootstrap
planner, approved `FrozenProcessLocal` as an independent prerequisite,
and directed implementation before the bindspace re-evaluation. Existing
bootstrap review annotations and artifacts remain untouched.

View File

@ -0,0 +1,25 @@
---
model: openai/gpt-5.6-sol
service: opencode
timestamp: 2026-09-07T01:15:33Z
git_ref: 5966e8aa
diff_cmd: git diff HEAD~1..HEAD
---
Implemented `FrozenProcessLocal` as an independent prerequisite rather
than coupling its contract to the rejected bootstrap planner.
> `git diff HEAD~1..HEAD -- tractor/msg/_local.py tractor/msg/__init__.py tests/msg/test_process_local.py`
The marker inherits `ProcessLocal` wire rejection and msgspec's frozen
field behavior. It is exported from `tractor.msg`, while focused tests
prove direct and nested encoding rejection, field immutability, and the
intentional shallow treatment of referenced mutable values.
Construction now rejects sentinel replacement and `omit_defaults=True`,
and wraps subclass validation hooks with checks before and after their
execution so they cannot skip or replace the base marker.
Verification completed with 44 process-local, WG-config, bindspace, and
tunnel-address tests passing. Ruff and scoped whitespace checks also
passed.

View File

@ -7,17 +7,100 @@ from __future__ import annotations
import msgspec import msgspec
import pytest import pytest
from tractor.msg import ProcessLocal from tractor.msg import (
FrozenProcessLocal,
ProcessLocal,
)
class LocalHandle(ProcessLocal): class LocalHandle(ProcessLocal):
''' '''
Minimal process-local struct used to exercise the global marker. Default marker whose sentinel must fail direct and nested
encoding.
''' '''
resource_id: int resource_id: int
class FrozenLocalHandle(FrozenProcessLocal):
'''
Frozen outer fields whose referenced `labels` list remains
mutable.
Supplying `_process_local=None` would also replace the
unsupported sentinel with an encodable value if construction did
not reject it.
'''
resource_id: int
# Mutating this list distinguishes shallow struct freezing from
# recursively freezing every referenced object.
labels: list[str]
# This class option would omit the default-valued sentinel from
# msgpack.
class OmittingLocalHandle(
ProcessLocal,
omit_defaults=True,
):
'''
Encoder config which would drop the default-valued sentinel.
With `omit_defaults=True`, both `_process_local` and
`resource_id`
equal their defaults, so msgspec could encode this struct as an
empty map without ever traversing `_ProcessLocalToken`.
'''
resource_id: int = 1
class ValidatedLocalHandle(ProcessLocal):
'''
Subclass validator which would shadow the marker's post-init
hook.
`ProcessLocal.__init_subclass__()` must wrap this method so
sentinel validation still runs without requiring a cooperative
`super()` call, while retaining this resource-id validation.
'''
resource_id: int
# Defining this hook normally shadows an inherited
# `__post_init__`; `ProcessLocal` must wrap it rather than rely
# on a `super()` call.
def __post_init__(self) -> None:
if self.resource_id < 0:
raise ValueError('resource_id must be non-negative')
class MutatingFrozenLocalHandle(FrozenProcessLocal):
'''
Hook which uses msgspec's frozen-field escape hatch on the
sentinel.
A check only before this hook would miss the replacement and
leave the completed struct encodable, so the wrapper must check
again after subclass validation.
'''
resource_id: int
# Replacing the sentinel inside this hook defeats a pre-hook-only
# check even though the struct is frozen.
def __post_init__(self) -> None:
# This is msgspec's supported internal mutation path for
# frozen structs, and therefore the strongest sentinel-
# replacement case.
msgspec.structs.force_setattr(
self,
'_process_local',
None,
)
@pytest.mark.parametrize( @pytest.mark.parametrize(
'nested', 'nested',
( (
@ -55,3 +138,74 @@ def test_process_local_rejects_default_encoding(
match='_ProcessLocalToken.*unsupported', match='_ProcessLocalToken.*unsupported',
): ):
msgspec.msgpack.encode(value) msgspec.msgpack.encode(value)
def test_frozen_process_local_contract() -> None:
'''
Preserve wire rejection while freezing process-local struct
fields.
A plain frozen msgspec struct could otherwise cross actor IPC.
Build one `FrozenProcessLocal` with a mutable referenced list,
then prove direct and nested default encoding still reach
`_ProcessLocalToken`. Reject field replacement while allowing
mutation owned by the nested list, demonstrating that the marker
provides shallow immutability.
'''
handle = FrozenLocalHandle(
resource_id=1,
labels=['initial'],
)
for value in (
handle,
{'nested': [handle]},
):
with pytest.raises(
TypeError,
match='_ProcessLocalToken.*unsupported',
):
msgspec.msgpack.encode(value)
with pytest.raises(AttributeError):
handle.resource_id = 2 # type: ignore[misc]
handle.labels.append('changed')
assert handle.labels == ['initial', 'changed']
def test_process_local_rejects_sentinel_bypasses() -> None:
'''
Prevent constructor and encoder options from removing the
sentinel.
`FrozenLocalHandle` supplies an encodable sentinel replacement;
`OmittingLocalHandle` asks msgspec to omit the default sentinel;
`ValidatedLocalHandle` shadows the inherited post-init hook; and
`MutatingFrozenLocalHandle` replaces the sentinel from inside
that hook. Prove construction rejects each wire-safety bypass
while the ordinary subclass value validator still executes.
'''
with pytest.raises(TypeError, match='internal sentinel'):
FrozenLocalHandle(
resource_id=1,
labels=[],
_process_local=None, # type: ignore[arg-type]
)
with pytest.raises(TypeError, match='omit_defaults'):
OmittingLocalHandle()
with pytest.raises(TypeError, match='internal sentinel'):
ValidatedLocalHandle(
resource_id=1,
_process_local=None, # type: ignore[arg-type]
)
with pytest.raises(ValueError, match='must be non-negative'):
ValidatedLocalHandle(resource_id=-1)
with pytest.raises(TypeError, match='internal sentinel'):
MutatingFrozenLocalHandle(resource_id=1)

View File

@ -28,6 +28,7 @@ from .pretty_struct import (
Struct as Struct, Struct as Struct,
) )
from ._local import ( from ._local import (
FrozenProcessLocal as FrozenProcessLocal,
ProcessLocal as ProcessLocal, ProcessLocal as ProcessLocal,
) )
from ._codec import ( from ._codec import (

View File

@ -27,6 +27,12 @@ class _ProcessLocalToken:
''' '''
Unsupported msgspec value embedded in every `ProcessLocal`. Unsupported msgspec value embedded in every `ProcessLocal`.
A sentinel is one private, unique object used as an identity
marker instead of application data. Every `ProcessLocal` holds
the same `_PROCESS_LOCAL_TOKEN` instance so construction can
verify it by identity and msgspec must encounter its unsupported
type on encode.
''' '''
__slots__ = () __slots__ = ()
@ -40,15 +46,71 @@ class ProcessLocal(
repr_omit_defaults=True, repr_omit_defaults=True,
): ):
''' '''
Generic struct marker which rejects default msgspec encoding. Struct whose `_process_local` field blocks msgspec encoding.
The hidden sentinel remains part of the encoded field set, so `_process_local` must remain the unsupported singleton
msgspec encounters `_ProcessLocalToken` and raises `TypeError` `_PROCESS_LOCAL_TOKEN`. Msgspec reaches that field during direct
even when this value is nested inside another supported payload. or nested encoding and raises `TypeError`; replacing it with an
A custom encode hook may explicitly override that safeguard. encodable value or omitting its default would bypass the guard.
A custom encode hook may still explicitly override the safeguard.
Construction therefore rejects a replacement sentinel and any
subclass configured with `omit_defaults=True`. A subclass
`__post_init__()` is wrapped with checks before and after its
body, preventing that hook from skipping or later replacing the
sentinel.
Keyword-only fields let subclasses add required fields after the Keyword-only fields let subclasses add required fields after the
marker's default sentinel. marker's default sentinel.
''' '''
_process_local: _ProcessLocalToken = _PROCESS_LOCAL_TOKEN _process_local: _ProcessLocalToken = _PROCESS_LOCAL_TOKEN
def __init_subclass__(cls, **kwargs: object) -> None:
'''
Check the sentinel around a subclass post-init hook.
'''
super().__init_subclass__(**kwargs)
subclass_post_init = cls.__dict__.get('__post_init__')
if subclass_post_init is None:
return
def checked_post_init(self: ProcessLocal) -> None:
ProcessLocal.__post_init__(self)
subclass_post_init(self)
ProcessLocal.__post_init__(self)
cls.__post_init__ = checked_post_init
def __post_init__(self) -> None:
'''
Require the singleton sentinel and forbid default omission.
'''
if self._process_local is not _PROCESS_LOCAL_TOKEN:
raise TypeError(
'`ProcessLocal._process_local` must retain its '
'internal sentinel'
)
if self.__struct_config__.omit_defaults:
raise TypeError(
'`ProcessLocal` subclasses may not enable '
'`omit_defaults`'
)
class FrozenProcessLocal(
ProcessLocal,
frozen=True,
):
'''
Frozen `ProcessLocal` whose struct fields reject reassignment.
The inherited `_process_local` sentinel still blocks direct and
nested encoding. Freezing is shallow: struct fields cannot be
replaced, while mutable objects referenced by those fields retain
their own mutation semantics.
'''