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. + + '''