Skip to content

[Config] Round 6.2: the field declarations move to their namespaces, and the record is assembled from them - #38047

Merged
ch-wan merged 4 commits into
mainfrom
cheng/gc-r6-2-fields
Sep 7, 2026
Merged

ch-wan merged 4 commits into
mainfrom
cheng/gc-r6-2-fields

Conversation

@ch-wan

@ch-wan ch-wan commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Second of four; stacked on #38046. Mechanical relocation plus one design change
that the relocation makes possible. Review by checking the identity proofs at
the bottom
-- nothing here is meant to change behaviour.

The declarations move

ServerArgs carried all 487 declarations in one 4,462-line file, each tagged
with an NS("...") marker naming the namespace it belongs to -- structure
supplied by annotation, in a file a namespace away from the
arg_groups/*_hook.py that resolves it.

They move to arg_groups/fields/: one module per top-level namespace, one class
per leaf namespace (21 of them, exec.moe becomes exec_.py::ExecMoe). The
class carries the _NS_PATH it stands for, so the module a field is declared in
is its namespace and the marker is redundant -- namespace_of reads the
declaring class instead. NS stays for the one case a class cannot express: a
single ad-hoc dataclass whose fields span namespaces, which is what the
config-bag tests build.

Two things travel with the declarations. The *_CHOICES lists and the
add_*_choices adders that extend them move to arg_groups/choices.py, since
the fields naming them can no longer import from server_args without a cycle;
server_args re-exports all of them, because out-of-tree plugins have always
reached them there. And five fields whose only annotation element was the
namespace marker become plain annotations -- A is Annotated, which needs two
arguments, so stripping the marker would have left them invalid.

server_args.py goes from 4,458 lines to about 1,000.

The record is assembled, not inherited

Inheriting the namespace classes would make the record's contents a property of
which classes happen to appear in a base list. That is correct only while every
namespace declares nothing but operator input, and it stops being correct the
moment a derived field is declared: attn_tp_size belongs in parallel.py
next to the leaves it is derived from, and inheriting Parallel would put it on
the record -- where it is neither input nor safe, since the record is what
crosses a process boundary and a derived width pickled to a subprocess is a
stamp that elastic scale-up will not refresh.

collect_input_fields takes the classes that declare input and returns their
annotations, defaults and namespaces. Each source's annotations are resolved in
its own module and handed on as type objects; carried across as text they would
be re-evaluated where they land, and the composing module deliberately imports
none of the names the declarations use. A namespace can now declare both halves
side by side, and which half reaches the record is one readable call rather than
an invariant spread across a base-class list. Nothing is registered on the
derived side yet -- this is what makes it possible.

ServerArgs is still one flat dataclass with 494 attributes, so
server_args.tp_size, ServerArgs(model_path=..., tp_size=8), pickling to a
subprocess and every existing call site are untouched.

Field order is a contract, so it is written down

A dataclass turns field order into a positional constructor signature, and
collecting whole namespaces groups fields that used to be interleaved. Keeping
model_path first is not enough: ServerArgs("dummy", "/tmp/tokenizer") would
set load_format="/tmp/tokenizer" and leave tokenizer_path=None, which then
selects an invalid model loader -- silently, at a call site that did not change.

So arg_groups/field_order.py records the order the record had before the
split, and collect_input_fields orders what it collects by it. A field the
record declares that the frozen order does not name goes after it, in
declaration order -- the only backward-compatible place for a new field anyway,
so a new declaration needs no edit there. The list is a compatibility record and
nothing else reads it; the namespace a field belongs to is still the module it
is declared in.

Verification

Four ways, all against the base commit:

check result
namespace_of map, field by field 494 / 494, 0 differences
CLI surface (options, defaults, choices, actions) 507 / 507, 0 differences
field order, name by name 494 / 494, identical to the base
resolution result, 24 launch shapes x 489 fields 0 differences
names importable from sglang.srt.server_args nothing lost

Plus a full registered-unit sweep (648 files) against the stack's merge-base:
19 failures on both sides, the same 19, none of them config.


CI States

Latest PR Test (Base): ❌ Run #34022667423
Latest PR Test (Extra): ❌ Run #34022667349
Latest PR Test (AMD ROCm 7.2): ❌ Run #34022667436


CI States

Latest PR Test (Base): 🚫 Run #34083970723
Latest PR Test (Extra): 🚫 Run #34083970596
Latest PR Test (AMD ROCm 7.2): 🚫 Run #34083970711

@github-actions github-actions Bot added the lora label Sep 4, 2026
@ch-wan
ch-wan force-pushed the cheng/gc-r6-1-pilots branch from 736e6c1 to 604ff16 Compare September 5, 2026 08:10
@ch-wan
ch-wan force-pushed the cheng/gc-r6-2-fields branch from 2607c62 to 3efd951 Compare September 5, 2026 08:10
@ch-wan
ch-wan force-pushed the cheng/gc-r6-1-pilots branch from 604ff16 to ca635af Compare September 6, 2026 03:52
@ch-wan
ch-wan force-pushed the cheng/gc-r6-2-fields branch from 3efd951 to 473d21c Compare September 6, 2026 03:52
ch-wan added a commit that referenced this pull request Sep 6, 2026
Not for merging. The work is #38046, #38047, #38048, #38049 and #38113, each
stacked on the one before it, and each reviewable on its own terms. GitHub
shows a stacked PR only against its parent, so there is nowhere to read the
whole thing at once -- this branch is that view, and this empty commit is what
lets it be a separate pull request from the same content.
ch-wan added a commit that referenced this pull request Sep 6, 2026
Not for merging. The work is #38046, #38047, #38048, #38049 and #38113, each
stacked on the one before it, and each reviewable on its own terms. GitHub
shows a stacked PR only against its parent, so there is nowhere to read the
whole thing at once -- this branch is that view, and this empty commit is what
lets it be a separate pull request from the same content.
ch-wan added a commit that referenced this pull request Sep 6, 2026
Not for merging. The work is #38046, #38047, #38048, #38049 and #38113, each
stacked on the one before it, and each reviewable on its own terms. GitHub
shows a stacked PR only against its parent, so there is nowhere to read the
whole thing at once -- this branch is that view, and this empty commit is what
lets it be a separate pull request from the same content.
ch-wan added a commit that referenced this pull request Sep 6, 2026
Not for merging. The work is #38046, #38047, #38048, #38049 and #38113, each
stacked on the one before it, and each reviewable on its own terms. GitHub
shows a stacked PR only against its parent, so there is nowhere to read the
whole thing at once -- this branch is that view, and this empty commit is what
lets it be a separate pull request from the same content.
ch-wan added a commit that referenced this pull request Sep 6, 2026
Not for merging. The work is #38046, #38047, #38048, #38049 and #38113, each
stacked on the one before it, and each reviewable on its own terms. GitHub
shows a stacked PR only against its parent, so there is nowhere to read the
whole thing at once -- this branch is that view, and this empty commit is what
lets it be a separate pull request from the same content.
@ch-wan
ch-wan force-pushed the cheng/gc-r6-1-pilots branch from ca635af to 188a027 Compare September 6, 2026 07:12
@ch-wan
ch-wan force-pushed the cheng/gc-r6-2-fields branch from 473d21c to 4f45882 Compare September 6, 2026 07:12
ch-wan added a commit that referenced this pull request Sep 6, 2026
Not for merging. The work is #38046, #38047, #38048, #38049 and #38113, each
stacked on the one before it, and each reviewable on its own terms. GitHub
shows a stacked PR only against its parent, so there is nowhere to read the
whole thing at once -- this branch is that view, and this empty commit is what
lets it be a separate pull request from the same content.
ch-wan added a commit that referenced this pull request Sep 6, 2026
Not for merging. The work is #38046, #38047, #38048, #38049, #38113 and #38194,
each stacked on the one before it, and each reviewable on its own terms. GitHub
shows a stacked PR only against its parent, so there is nowhere to read the
whole thing at once -- this branch is that view, and this empty commit is what
lets it be a separate pull request from the same content.
@ch-wan
ch-wan force-pushed the cheng/gc-r6-1-pilots branch from 188a027 to 54b5114 Compare September 6, 2026 08:43
@ch-wan
ch-wan force-pushed the cheng/gc-r6-2-fields branch from 4f45882 to c37098c Compare September 6, 2026 08:43
ch-wan added a commit that referenced this pull request Sep 6, 2026
Not for merging. The work is #38046, #38047, #38048, #38049 and #38113, each
stacked on the one before it, and each reviewable on its own terms. GitHub
shows a stacked PR only against its parent, so there is nowhere to read the
whole thing at once -- this branch is that view, and this empty commit is what
lets it be a separate pull request from the same content.
ch-wan added a commit that referenced this pull request Sep 6, 2026
Not for merging. The work is #38046, #38047, #38048, #38049 and #38113, each
stacked on the one before it, and each reviewable on its own terms. GitHub
shows a stacked PR only against its parent, so there is nowhere to read the
whole thing at once -- this branch is that view, and this empty commit is what
lets it be a separate pull request from the same content.
@ch-wan
ch-wan force-pushed the cheng/gc-r6-2-fields branch from c37098c to 3bfa93e Compare September 6, 2026 09:00
ch-wan added a commit that referenced this pull request Sep 6, 2026
Not for merging. The work is #38046, #38047, #38048, #38049 and #38113, each
stacked on the one before it, and each reviewable on its own terms. GitHub
shows a stacked PR only against its parent, so there is nowhere to read the
whole thing at once -- this branch is that view, and this empty commit is what
lets it be a separate pull request from the same content.
ch-wan added a commit that referenced this pull request Sep 6, 2026
Not for merging. The work is #38046, #38047, #38048, #38049 and #38113, each
stacked on the one before it, and each reviewable on its own terms. GitHub
shows a stacked PR only against its parent, so there is nowhere to read the
whole thing at once -- this branch is that view, and this empty commit is what
lets it be a separate pull request from the same content.
@ch-wan
ch-wan force-pushed the cheng/gc-r6-1-pilots branch from 54b5114 to 6206a5e Compare September 6, 2026 10:18
@ch-wan
ch-wan force-pushed the cheng/gc-r6-2-fields branch from 3bfa93e to 2f8f653 Compare September 6, 2026 10:18
ch-wan added a commit that referenced this pull request Sep 6, 2026
Not for merging. The work is #38046, #38047, #38048, #38049 and #38113, each
stacked on the one before it, and each reviewable on its own terms. GitHub
shows a stacked PR only against its parent, so there is nowhere to read the
whole thing at once -- this branch is that view, and this empty commit is what
lets it be a separate pull request from the same content.
ch-wan added a commit that referenced this pull request Sep 6, 2026
Not for merging. The work is #38046, #38047, #38048, #38049 and #38113, each
stacked on the one before it, and each reviewable on its own terms. GitHub
shows a stacked PR only against its parent, so there is nowhere to read the
whole thing at once -- this branch is that view, and this empty commit is what
lets it be a separate pull request from the same content.
ch-wan added a commit that referenced this pull request Sep 6, 2026
Not for merging. The work is #38046, #38047, #38048, #38049 and #38113, each
stacked on the one before it, and each reviewable on its own terms. GitHub
shows a stacked PR only against its parent, so there is nowhere to read the
whole thing at once -- this branch is that view, and this empty commit is what
lets it be a separate pull request from the same content.
ch-wan added a commit that referenced this pull request Sep 6, 2026
Not for merging. The work is #38046, #38047, #38048, #38049 and #38113, each
stacked on the one before it, and each reviewable on its own terms. GitHub
shows a stacked PR only against its parent, so there is nowhere to read the
whole thing at once -- this branch is that view, and this empty commit is what
lets it be a separate pull request from the same content.
ch-wan added a commit that referenced this pull request Sep 7, 2026
Not for merging. The work is #38046, #38047, #38048, #38049 and #38113, each
stacked on the one before it, and each reviewable on its own terms. GitHub
shows a stacked PR only against its parent, so there is nowhere to read the
whole thing at once -- this branch is that view, and this empty commit is what
lets it be a separate pull request from the same content.
@ch-wan
ch-wan force-pushed the cheng/gc-r6-1-pilots branch from 6206a5e to 0bf74d6 Compare September 7, 2026 04:25
@ch-wan
ch-wan force-pushed the cheng/gc-r6-2-fields branch from 2f8f653 to b51a1c9 Compare September 7, 2026 04:25
@ch-wan
ch-wan force-pushed the cheng/gc-r6-1-pilots branch from 0bf74d6 to 320e3ae Compare September 7, 2026 04:36
@ch-wan
ch-wan force-pushed the cheng/gc-r6-2-fields branch from b51a1c9 to df385fc Compare September 7, 2026 04:36
Base automatically changed from cheng/gc-r6-1-pilots to main September 7, 2026 04:38
`ServerArgs` carried all 487 declarations in one 4,462-line file, each tagged
with an `NS("...")` marker naming the namespace it belongs to -- structure
supplied by annotation, in a file a namespace away from the
`arg_groups/*_hook.py` that resolves it.

The declarations move to `arg_groups/fields/`, one module per top-level
namespace and one class per leaf namespace (21 of them, `exec.moe` becomes
`exec_.py::ExecMoe`). The class carries the `_NS_PATH` it stands for, so the
module a field is declared in *is* its namespace and the marker is redundant:
`namespace_of` reads the declaring class off the MRO instead. `NS` stays for
the one case a class cannot express -- a single ad-hoc dataclass whose fields
span namespaces, which is what the config-bag tests build.

`ServerArgs` composes the 21. It is still one flat dataclass with 487
attributes, so `server_args.tp_size`, `ServerArgs(model_path=..., tp_size=8)`,
pickling to a subprocess and all 408 parameter sites are untouched. `Model` is
the last base because reverse-MRO order puts the last base's fields first, and
`model_path` -- the one field with no default -- has to stay the first
positional argument.

Two things travel with the declarations. The twelve `*_CHOICES` lists and the
`add_*_choices` adders that extend them move to `arg_groups/choices.py`, since
the fields naming them can no longer import from `server_args` without a
cycle; `server_args` re-exports them for the plugins that have always reached
them there. And five fields whose only annotation element was the namespace
marker become plain annotations -- `A` is `Annotated`, which needs two
arguments, so stripping the marker would have left them invalid.

Verified three ways: the 487-entry `namespace_of` map is identical field for
field, the resolution result is identical across 24 launch shapes x 485
fields, and the CLI registers the same 503 options with the same defaults,
choices and actions.
Ten places still told the reader that a field's namespace comes from an
`NS(...)` marker on the field. Two of them are errors a developer reads when
something is misconfigured -- "no NS namespace" would send them looking for a
marker that no longer exists on any ServerArgs field.

The namespace now comes from the class that declares the field, which is what
`namespace_of` reads, so the messages name the namespace rather than the
mechanism that used to supply it.
The split re-exported the names the extension-point block defines. That misses
a list the block never mentions: `SAMPLING_BACKEND_CHOICES` is read only by the
CLI section, so `add_cli_args` raised `NameError` on the first call -- which no
import-time check catches.

Re-export everything `choices.py` defines. All 33 used to be importable from
`server_args`, and a rule narrower than that has no way to know which ones a
plugin reaches for. The typing names the moved declarations were the last
users of go the other way and are dropped.
Inheriting the namespace classes made the record's contents a property of
which classes happen to appear in a base list. That is correct only while
every namespace declares nothing but operator input, and it stops being
correct the moment a derived field is declared -- `attn_tp_size` belongs in
`parallel.py` next to the leaves it is derived from, and inheriting `Parallel`
would put it on the record, where it is neither input nor safe: the record is
the wire format, so a derived width pickled to a subprocess is a stamp, and a
width has to stay a live read for elastic scale-up to see the group it has.

`collect_input_fields` takes the classes that declare input and returns their
annotations, defaults and namespaces. Each source's annotations are resolved
in its own module and handed on as type objects -- carried across as text they
would be re-evaluated where they land, and the composing module deliberately
imports none of the names the declarations use.

So a namespace can now declare both halves side by side, and which half
reaches the record is one readable call instead of an invariant spread across
a base-class list. Nothing is registered on the derived side yet; this is what
makes it possible.

The order is the old reverse-MRO order, so the fields, the CLI's 504 options
and `model_path` as the first positional argument are all unchanged. Verified
the four ways this refactor can be: the 491-entry namespace map and the CLI
surface are identical to origin/main, the resolution result is identical
across 24 launch shapes x 489 fields, and nothing that was importable from
`server_args` stopped being.
@ch-wan
ch-wan force-pushed the cheng/gc-r6-2-fields branch from df385fc to 945d9ad Compare September 7, 2026 04:40
@ch-wan
ch-wan merged commit ed82def into main Sep 7, 2026
8 of 17 checks passed
@ch-wan
ch-wan deleted the cheng/gc-r6-2-fields branch September 7, 2026 04:40
ch-wan added a commit that referenced this pull request Sep 7, 2026
… sealed while resolution runs (#38048)

Third of four; stacked on #38047. Two small changes, both about the same thing:
the record holds the operator's input, and nothing else should be true of it.

## `/server_info` can answer what was actually typed

It reports `resolved_dict()` -- what resolution decided. There was no way to ask
the other question, and the two are not derivable from each other: a field
nobody set reads the same as one set to the value resolution would have picked
anyway.

The launcher stores the arguments it parsed and the in-process `Engine` stores
the call that built the record. All three readbacks report it beside the
resolved values, so both surfaces come back in one request: HTTP `/server_info`,
`Engine.get_server_info`, and the gRPC bridge's -- the last one builds from
`resolved_dict()` and would otherwise have been the one surface of the three
that answers only "what resolution decided".
It rides on the record rather than in a field -- it describes how the
configuration was asked for, so it is not part of the configuration: no CLI
flag, no namespace, not in the bags. Being on the record is what lets a
subprocess copy answer the same question the launcher can, and
`replace_resolved` carries it because a copy was launched by whatever launched
its parent.

The crash dump already collected all four surfaces (`server_args`,
`config_updates`, `resolved_config`, `launch_command`); this is the one that
`/server_info` was missing.

## The record is sealed for the length of resolution

The read-only guard armed on `_resolution_finished`, so for the whole run of the
pipeline nothing stopped a resolver from assigning a field. Nothing in `srt/`
does -- 0 assignments statically, and 0 writes observed across the launch-shape
matrix with a watching `__setattr__` -- but that was a convention, and the
defect it permits is invisible: a value a resolver wrote onto the record is
indistinguishable from a value the operator typed, which is the one distinction
the record exists to preserve.

It now arms when resolution starts. A resolver that assigns a field fails at
boot with a message naming `declare_resolution`, which is where the decision
belongs: the stash carries a source and leaves the input intact.

`declare_direct_writes` asks for the seal by name through `record_writable`. It
hands the record to an out-of-tree platform plugin that sets fields on it; those
implementations cannot be converted by editing a resolver here, so the write
stays and the diff is captured into the stash afterwards. Naming the exception
is the point -- an in-tree resolver reaching for it is doing something it should
be declaring.

## Verification

Costs nothing: the 211 test-side assignments all happen before `resolve_once`,
which a post-resolution write already refused. A full registered-unit sweep
(648 files) against the stack's merge-base: 19 failures on both sides, the same
19, none of them config. Driving a
deliberate write into a real handler produces the new error, so the seal is
tested by more than its own unit test.
SovietPower added a commit to SovietPower/sglang that referenced this pull request Sep 7, 2026
…t/add_hrrn_scheduler_policy

Resolve server_args.py conflict from PR sgl-project#38047 refactor: --schedule-policy hrrn choice moved to arg_groups/fields/schedule.py.
mqhc2020 pushed a commit to mqhc2020/sglang that referenced this pull request Sep 15, 2026
…and the record is assembled from them (sgl-project#38047)

Second of four; stacked on sgl-project#38046. Mechanical relocation plus one design change
that the relocation makes possible. **Review by checking the identity proofs at
the bottom** -- nothing here is meant to change behaviour.

## The declarations move

`ServerArgs` carried all 487 declarations in one 4,462-line file, each tagged
with an `NS("...")` marker naming the namespace it belongs to -- structure
supplied by annotation, in a file a namespace away from the
`arg_groups/*_hook.py` that resolves it.

They move to `arg_groups/fields/`: one module per top-level namespace, one class
per leaf namespace (21 of them, `exec.moe` becomes `exec_.py::ExecMoe`). The
class carries the `_NS_PATH` it stands for, so the module a field is declared in
*is* its namespace and the marker is redundant -- `namespace_of` reads the
declaring class instead. `NS` stays for the one case a class cannot express: a
single ad-hoc dataclass whose fields span namespaces, which is what the
config-bag tests build.

Two things travel with the declarations. The `*_CHOICES` lists and the
`add_*_choices` adders that extend them move to `arg_groups/choices.py`, since
the fields naming them can no longer import from `server_args` without a cycle;
`server_args` re-exports all of them, because out-of-tree plugins have always
reached them there. And five fields whose only annotation element was the
namespace marker become plain annotations -- `A` is `Annotated`, which needs two
arguments, so stripping the marker would have left them invalid.

`server_args.py` goes from 4,458 lines to about 1,000.

## The record is assembled, not inherited

Inheriting the namespace classes would make the record's contents a property of
which classes happen to appear in a base list. That is correct only while every
namespace declares nothing but operator input, and it stops being correct the
moment a derived field is declared: `attn_tp_size` belongs in `parallel.py`
next to the leaves it is derived from, and inheriting `Parallel` would put it on
the record -- where it is neither input nor safe, since the record is what
crosses a process boundary and a derived width pickled to a subprocess is a
stamp that elastic scale-up will not refresh.

`collect_input_fields` takes the classes that declare input and returns their
annotations, defaults and namespaces. Each source's annotations are resolved in
its own module and handed on as type objects; carried across as text they would
be re-evaluated where they land, and the composing module deliberately imports
none of the names the declarations use. A namespace can now declare both halves
side by side, and which half reaches the record is one readable call rather than
an invariant spread across a base-class list. Nothing is registered on the
derived side yet -- this is what makes it possible.

`ServerArgs` is still one flat dataclass with 494 attributes, so
`server_args.tp_size`, `ServerArgs(model_path=..., tp_size=8)`, pickling to a
subprocess and every existing call site are untouched.

### Field order is a contract, so it is written down

A dataclass turns field order into a positional constructor signature, and
collecting whole namespaces groups fields that used to be interleaved. Keeping
`model_path` first is not enough: `ServerArgs("dummy", "/tmp/tokenizer")` would
set `load_format="/tmp/tokenizer"` and leave `tokenizer_path=None`, which then
selects an invalid model loader -- silently, at a call site that did not change.

So `arg_groups/field_order.py` records the order the record had before the
split, and `collect_input_fields` orders what it collects by it. A field the
record declares that the frozen order does not name goes after it, in
declaration order -- the only backward-compatible place for a new field anyway,
so a new declaration needs no edit there. The list is a compatibility record and
nothing else reads it; the namespace a field belongs to is still the module it
is declared in.

## Verification

Four ways, all against the base commit:

| check | result |
|---|---|
| `namespace_of` map, field by field | 494 / 494, **0 differences** |
| CLI surface (options, defaults, choices, actions) | 507 / 507, **0 differences** |
| field order, name by name | 494 / 494, **identical to the base** |
| resolution result, 24 launch shapes x 489 fields | **0 differences** |
| names importable from `sglang.srt.server_args` | nothing lost |

Plus a full registered-unit sweep (648 files) against the stack's merge-base:
19 failures on both sides, the same 19, none of them config.
mqhc2020 pushed a commit to mqhc2020/sglang that referenced this pull request Sep 15, 2026
… sealed while resolution runs (sgl-project#38048)

Third of four; stacked on sgl-project#38047. Two small changes, both about the same thing:
the record holds the operator's input, and nothing else should be true of it.

## `/server_info` can answer what was actually typed

It reports `resolved_dict()` -- what resolution decided. There was no way to ask
the other question, and the two are not derivable from each other: a field
nobody set reads the same as one set to the value resolution would have picked
anyway.

The launcher stores the arguments it parsed and the in-process `Engine` stores
the call that built the record. All three readbacks report it beside the
resolved values, so both surfaces come back in one request: HTTP `/server_info`,
`Engine.get_server_info`, and the gRPC bridge's -- the last one builds from
`resolved_dict()` and would otherwise have been the one surface of the three
that answers only "what resolution decided".
It rides on the record rather than in a field -- it describes how the
configuration was asked for, so it is not part of the configuration: no CLI
flag, no namespace, not in the bags. Being on the record is what lets a
subprocess copy answer the same question the launcher can, and
`replace_resolved` carries it because a copy was launched by whatever launched
its parent.

The crash dump already collected all four surfaces (`server_args`,
`config_updates`, `resolved_config`, `launch_command`); this is the one that
`/server_info` was missing.

## The record is sealed for the length of resolution

The read-only guard armed on `_resolution_finished`, so for the whole run of the
pipeline nothing stopped a resolver from assigning a field. Nothing in `srt/`
does -- 0 assignments statically, and 0 writes observed across the launch-shape
matrix with a watching `__setattr__` -- but that was a convention, and the
defect it permits is invisible: a value a resolver wrote onto the record is
indistinguishable from a value the operator typed, which is the one distinction
the record exists to preserve.

It now arms when resolution starts. A resolver that assigns a field fails at
boot with a message naming `declare_resolution`, which is where the decision
belongs: the stash carries a source and leaves the input intact.

`declare_direct_writes` asks for the seal by name through `record_writable`. It
hands the record to an out-of-tree platform plugin that sets fields on it; those
implementations cannot be converted by editing a resolver here, so the write
stays and the diff is captured into the stash afterwards. Naming the exception
is the point -- an in-tree resolver reaching for it is doing something it should
be declaring.

## Verification

Costs nothing: the 211 test-side assignments all happen before `resolve_once`,
which a post-resolution write already refused. A full registered-unit sweep
(648 files) against the stack's merge-base: 19 failures on both sides, the same
19, none of them config. Driving a
deliberate write into a real handler produces the new error, so the seal is
tested by more than its own unit test.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant