From d2274d32521aed861b654b1ed9a72a8acab3b7cf Mon Sep 17 00:00:00 2001 From: goodboy Date: Sun, 6 Sep 2026 23:49:16 -0400 Subject: [PATCH] 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`)) --- ...7T011533Z_5966e8aa_frozen_process_local.md | 39 +++++ ...1533Z_5966e8aa_frozen_process_local.raw.md | 25 +++ tests/msg/test_process_local.py | 158 +++++++++++++++++- tractor/msg/__init__.py | 1 + tractor/msg/_local.py | 72 +++++++- 5 files changed, 288 insertions(+), 7 deletions(-) create mode 100644 ai/prompt-io/opencode/20260907T011533Z_5966e8aa_frozen_process_local.md create mode 100644 ai/prompt-io/opencode/20260907T011533Z_5966e8aa_frozen_process_local.raw.md diff --git a/ai/prompt-io/opencode/20260907T011533Z_5966e8aa_frozen_process_local.md b/ai/prompt-io/opencode/20260907T011533Z_5966e8aa_frozen_process_local.md new file mode 100644 index 00000000..dcf9250c --- /dev/null +++ b/ai/prompt-io/opencode/20260907T011533Z_5966e8aa_frozen_process_local.md @@ -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. diff --git a/ai/prompt-io/opencode/20260907T011533Z_5966e8aa_frozen_process_local.raw.md b/ai/prompt-io/opencode/20260907T011533Z_5966e8aa_frozen_process_local.raw.md new file mode 100644 index 00000000..8f101dab --- /dev/null +++ b/ai/prompt-io/opencode/20260907T011533Z_5966e8aa_frozen_process_local.raw.md @@ -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. diff --git a/tests/msg/test_process_local.py b/tests/msg/test_process_local.py index c8e220a2..c0482e62 100644 --- a/tests/msg/test_process_local.py +++ b/tests/msg/test_process_local.py @@ -7,17 +7,100 @@ from __future__ import annotations import msgspec import pytest -from tractor.msg import ProcessLocal +from tractor.msg import ( + FrozenProcessLocal, + 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 +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( 'nested', ( @@ -55,3 +138,74 @@ def test_process_local_rejects_default_encoding( match='_ProcessLocalToken.*unsupported', ): 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) diff --git a/tractor/msg/__init__.py b/tractor/msg/__init__.py index b1a1ce5c..bfe31e11 100644 --- a/tractor/msg/__init__.py +++ b/tractor/msg/__init__.py @@ -28,6 +28,7 @@ from .pretty_struct import ( Struct as Struct, ) from ._local import ( + FrozenProcessLocal as FrozenProcessLocal, ProcessLocal as ProcessLocal, ) from ._codec import ( diff --git a/tractor/msg/_local.py b/tractor/msg/_local.py index 1bdd8b49..9d06c32a 100644 --- a/tractor/msg/_local.py +++ b/tractor/msg/_local.py @@ -27,6 +27,12 @@ class _ProcessLocalToken: ''' 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__ = () @@ -40,15 +46,71 @@ class ProcessLocal( 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 - 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. + `_process_local` must remain the unsupported singleton + `_PROCESS_LOCAL_TOKEN`. Msgspec reaches that field during direct + or nested encoding and raises `TypeError`; replacing it with an + 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 marker's default sentinel. ''' _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. + + '''