Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
56cfe9f
Add nominal ID values and preserve spelling through codec declarations
trissim Oct 23, 2026
5f5bb40
Pin nominal ID wire behavior and declare per-kind migration evidence
trissim Oct 23, 2026
04a8358
Merge remote-tracking branch 'origin/main' into refactor/id128-nomina…
trissim Oct 23, 2026
4dc23bb
Keep expanded ID phase patterns readable
trissim Oct 23, 2026
6406ba3
Let nominal spelling own its decoder and inherit the value-side link
trissim Oct 23, 2026
e782e38
Separate lookup and assignment identities and unblock independent kin…
trissim Oct 23, 2026
7226c60
Merge remote-tracking branch 'origin/main' into refactor/id128-nomina…
trissim Oct 23, 2026
ce0aede
Rename the turn registration record and type its stored identity
trissim Oct 23, 2026
6cef37d
Use nominal turn identities in fixtures and the prompt default factory
trissim Oct 23, 2026
b8674f8
Include declared column values and codecs in producer digests
trissim Oct 23, 2026
42eb3f4
Prove column spelling changes invalidate only their declared table
trissim Oct 23, 2026
df17df7
Preserve nominal TEXT storage and codec mixins in the owner walk
trissim Oct 23, 2026
1c679cb
Derive nominal TEXT storage from its declared text representation
trissim Oct 23, 2026
7c35a8c
Track the real message ID kind and expose codec representation ownership
trissim Oct 23, 2026
63031a4
Keep stored-input fixtures on valid turn identities
trissim Oct 23, 2026
4d21f63
Carry nominal turn values through the registration writers
trissim Oct 23, 2026
c0b1fed
Merge main and preserve comparison behavior in moved decoder classes
trissim Oct 23, 2026
c766da5
Resolve the codec merge and retain nominal comparison content
trissim Oct 23, 2026
34e3ab9
Merge remote-tracking branch 'origin/main' into refactor/id128-nomina…
trissim Oct 23, 2026
c37f7e8
Merge remote-tracking branch 'origin/main' into refactor/id128-nomina…
trissim Oct 23, 2026
f711e1c
Fix nominal input steering and turn completion through their declared…
trissim Oct 23, 2026
b807fa5
Merge current main fixture corrections into nominal ID types
trissim Oct 23, 2026
b0b65e2
Fix nominal ID consumers and carry validated wake claims to version two
trissim Oct 24, 2026
61b803e
Merge current installer snapshot fixes into nominal IDs
trissim Oct 24, 2026
0cf0835
Carry nominal input and message types from their producers through re…
trissim Oct 24, 2026
e8f01c4
Merge remote-tracking branch 'origin/refactor/id128-nominal-type' int…
trissim Oct 24, 2026
d09e1b5
Wrap the two digest assertions within the line limit
trissim Oct 24, 2026
d1bb4bc
Mint valid message IDs in reference and missing-lookup fixtures
trissim Oct 24, 2026
70d24a4
Require the minted input ID and declare the absence of an observed turn
trissim Oct 24, 2026
3fbe446
Fold producer-owned nominal storage and message references into the I…
trissim Oct 24, 2026
25f60e5
Give accepted turns their queue behavior and pass native start listen…
trissim Oct 24, 2026
86aef8b
Separate native write permission from committed-input acknowledgement
trissim Oct 24, 2026
7f04146
Keep representation queries total and carry nominal message IDs to th…
trissim Oct 24, 2026
bfa24f6
Give structural field representation its own behavior and decode whol…
trissim Oct 24, 2026
386d625
Type the selected representation before dispatching its projection
trissim Oct 24, 2026
8d2a9ad
Resolve represented decoder candidates without swallowed lookup errors
trissim Oct 24, 2026
5c41d99
Keep structural schema generation on the structural representation
trissim Oct 24, 2026
d0a24e2
Mint the selected-tool owner fixture input ID at its declared kind
trissim Oct 24, 2026
2ea3c2f
Merge paired nominal storage and carry corrections from PR 2966
trissim Oct 24, 2026
5229d59
Keep input and message kinds at SQL and fixture entry points
trissim Oct 24, 2026
b9c22bd
Give the wake carry fixture a canonical absolute resource path
trissim Oct 24, 2026
7b4d7f0
Keep publication mismatch fixture a valid message identity
trissim Oct 24, 2026
a1f4ffb
Merge pull request #2967 from OpenHCSDev/fix/producer-digest-value-types
trissim Oct 24, 2026
2818c2d
Decode assignment and historical IDs once and share test turn identities
trissim Oct 24, 2026
40781f4
Apply stored-record rewrites only to selected records under declared …
trissim Oct 24, 2026
07e83c6
Merge pull request #2968 from OpenHCSDev/fix/selected-record-carry
trissim Oct 24, 2026
a953c92
Declare nullable response-column decoding at its SQL boundary
trissim Oct 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 18 additions & 16 deletions plans/ID128-NOMINAL-PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Audited head: `dfc7d8976` (main, 2026-10-23). Every count below is `git grep -cE

## The thesis

An id is a nominal value type, decoded once by FieldCodec where it enters: a store row, a file, the wire, a CLI argument. Every field and parameter carries that type instead of `str`. Then a repeated decode has nothing left to check, so it cannot be written, and the constructor checks that exist only because the field is `str` delete themselves.
An id is a nominal value type, decoded once by FieldCodec where it enters: a store row, a file, the wire, a CLI argument. Each kind has its own subclass (`ClaimId`, `AssignmentGeneration`, `TurnId`, `GoalId`, `RootId`, `ThreadLookup`, `InputId`), and every field and parameter carries its kind instead of `str` or bare `Id128`. A turn id cannot encode as an input id. Then a repeated decode has nothing left to check, so it cannot be written, and the constructor checks that exist only because the field is `str` delete themselves.

## Why this is slop today

Expand All @@ -26,55 +26,57 @@ These facts decide the plan's order:
## How to execute without churn

- **No stored data changes** (fact 1). Each phase is one PR through the normal batch.
- **Kinds go in order, one PR each.** An id kind is one set of field names that carry one kind of value. Its fields, parameters, mints and re-decodes change together, so the value never crosses into an unconverted field of its own kind. The kinds run smallest first, by the counts below. They share modules (`acp_extension.py` holds both input and root ids), so they run in series, not in parallel.
- **Kinds go in order, one PR each.** An id kind is one set of field names that carry one kind of value. Its fields, parameters, mints and re-decodes change together, so the value never crosses into an unconverted field of its own kind. All six kind phases depend only on phase 1 and can run in parallel. They share modules (`acp_extension.py` holds both input and root ids), so shared files need one editor at a time.
- **Mechanical rewrites go through one codemod per kind.** The annotation edits are all one shape. Re-decode deletions are by hand, each citing the boundary line where the value entered.
- **Delete, never wrap.** No helper, no alias, no `cast`, no `try`. A site whose caller passes raw text is a boundary: it decodes once with the kind's type and stays.
- **Kinds stay one type.** Every 128-bit kind uses `Id128`. Distinct per-kind types (`InputId`, `TurnId`) would also make passing a turn id as an input id a type error. That is a new distinction, not the deletion of a restated fact, so this plan leaves it out; Tristan decides it separately.
- **Kinds have nominal identity.** Tristan decided that a kind is a required fact: `ClaimId`, `AssignmentGeneration`, `TurnId`, `GoalId`, `RootId`, `ThreadLookup` and `InputId` inherit the shared `Id128` mint and spelling. Each kind is a bare class, with no own mint or spelling declaration. `MessageId` remains a separate 12-hex nominal type. A field decodes to its declared kind and refuses a different kind on encode.

## Phases

Each phase, its predecessors and its evidence on `origin/main` are declared in `src/agent_comms/plan_phases.py` (`Id128NominalPlan`). Counts are at `dfc7d8976`; each check is `git grep -cE PATTERN -- src`, and done is the count shown.

| Phase | Done when, on main | Now | Done |
|---|---|---|---|
| 1 `NominalIdTypePhase` | `NominalText`, `NominalSpelling`, `Id128`, `MessageId` declared in `field_codec.py`; `def mint\(cls(, text: str)?\) -> str:` absent there | 2 | 0 |
| 1 `NominalIdTypePhase` | `NominalText`, `NominalSpelling`, `Id128`, `MessageId` and all seven kind classes declared in `field_codec.py`; `def mint\(cls(, text: str)?\) -> str:` absent there | 2 | 0 |
| 2 `PlainStrIdRatchetPhase` | `^class PlainStrIdAnnotation\(` in `tools/debt_ratchet.py` | 0 | 1 |
| 3 `ClaimIdsPhase` | `\b(nonce\|operation_id\|generation): (str\b\|Annotated\[str)` | 35 in 10 modules | 0 |
| 3 `ClaimIdsPhase` | `\b(nonce\|operation_id\|generation): (str\b\|Id128\b\|Annotated\[(str\|Id128)\b)` | 35 in 10 modules | 0 |
| | `Id128Text\.decode\(self\.(nonce\|operation_id)\)` | 2 | 0 |
| 4 `MessageIdsPhase` | `\b(message_id\|source_message_id): (str\b\|Annotated\[str)` | 37 in 21 | 0 |
| 4 `MessageIdsPhase` | `\b(message_id\|source_message_id): (str\b\|Id128\b\|Annotated\[(str\|Id128)\b)` | 37 in 21 | 0 |
| | `Annotated\[str, *MessageIdText\]` | 1 | 0 |
| 5 `TurnIdsPhase` | `\b(turn_id\|finished_turn_id\|report_turn_id\|current_turn_id): (str\b\|Annotated\[str)` | 71 in 24 | 0 |
| 5 `TurnIdsPhase` | `\b(turn_id\|finished_turn_id\|report_turn_id\|current_turn_id): (str\b\|Id128\b\|Annotated\[(str\|Id128)\b)` | 71 in 24 | 0 |
| | `Id128Text\.(decode\|from_text)\((self\.)?turn_id\)` | 2 | 0 |
| 6 `GoalIdsPhase` | `\b(goal_id\|original_goal_id): (str\b\|Annotated\[str)` | 85 in 24 | 0 |
| 7 `RootIdsPhase` | `\b(wire_root_id\|root_id\|lookup\|recipient_lookup): (str\b\|Annotated\[str)` | 127 in 45 | 0 |
| 6 `GoalIdsPhase` | `\b(goal_id\|original_goal_id): (str\b\|Id128\b\|Annotated\[(str\|Id128)\b)` | 85 in 24 | 0 |
| 7 `RootIdsPhase` | `\b(wire_root_id\|root_id\|lookup\|recipient_lookup): (str\b\|Id128\b\|Annotated\[(str\|Id128)\b)` | 127 in 45 | 0 |
| | `Id128Text\.decode\((self\.)?(owner\.)?(wire_root_id\|root_id\|lookup)\)` | 4 | 0 |
| | `FieldCodec\.decode\(Annotated\[str, *Id128Text\]` | 2 | 0 |
| 8 `InputIdsPhase` | `\b(input_id\|accepted_input_id\|native_id): (str\b\|Annotated\[str)` | 129 in 39 | 0 |
| 8 `InputIdsPhase` | `\b(input_id\|accepted_input_id\|native_id): (str\b\|Id128\b\|Annotated\[(str\|Id128)\b)` | 129 in 39 | 0 |
| | `Id128Text\.(decode\|from_text)\((self\.)?(input_id\|native_id)\)` | 6 | 0 |
| 9 `RedecodesDeletedPhase` | `Id128Text\.(decode\|from_text)\(` | 15 in 14 | 0 |
| | `Annotated\[str, *(Id128Text\|MessageIdText)\]` | 15 in 7 | 0 |
| | `uuid4\(\)\.hex([^\[]\|$)` outside `field_codec.py` | 23 in 19 | 0 |
| | `isinstance\(\w+, NominalText\)` in `field_codec.py` (the phase 1 allowance) | 0, 1 after phase 1 | 0 |

The kind patterns count fields and parameters together, so each kind phase covers both.
The kind patterns count fields and parameters together, so each kind phase covers both. Every kind check excludes `:!src/agent_comms/skills/`: pinned audit examples are historical input, not production id fields. Foreign-id exclusions stay with the integration owner.

### 1. The type (`field_codec.py` only)

- Declare `class NominalText(str)` with `__slots__ = ()`: text whose type is its identity.
- Declare `class NominalSpelling[V: NominalText](SpelledText)`. Its `__init_subclass__` reads `V` from the subscripted base with `types.get_original_bases` (as `cli_bridges/monitor.py:258` does) and records the pair once, value type to spelling. `from_text` returns `V(super().from_text(value))`. `encode` returns the validated text as exact `str` (fact 2).
- Declare `class Id128(NominalText)` with `mint() -> Id128`, holding the `uuid4().hex` now at `field_codec.py:1435-1437`. Declare `class MessageId(NominalText)` with `mint(text) -> MessageId`, holding the hash now at `:1448-1450`.
- `Id128Text` (`:1430`) and `MessageIdText` (`:1440`) become `NominalSpelling[Id128]` and `NominalSpelling[MessageId]`. Their `mint`s are deleted; callers use `Id128.mint()` / `MessageId.mint(text)`. That is 15 `Id128Text.mint(` sites in 11 modules and 1 at `messages.py:952`, edited in this phase.
- `FieldCodec._representation` (`:1534`) resolves a bare `NominalText` subclass to its recorded spelling, beside the `WireValue` branch at `:1556`. A field is then declared `input_id: Id128`.
- Declare `class Id128(NominalText)` with `mint() -> Id128`, holding the `uuid4().hex` now at `field_codec.py:1435-1437`. It returns `cls(uuid4().hex)`, so inherited mints preserve the kind. Declare the seven bare subclasses `ClaimId`, `AssignmentGeneration`, `TurnId`, `GoalId`, `RootId`, `ThreadLookup` and `InputId`. Declare `class MessageId(NominalText)` with `mint(text) -> MessageId`, holding the hash now at `:1448-1450`.
- `Id128Text` (`:1430`) and `MessageIdText` (`:1440`) become `NominalSpelling[Id128]` and `NominalSpelling[MessageId]`. Their `mint`s are deleted; callers use the named kind's inherited mint (`InputId.mint()`, `ClaimId.mint()`, `RootId.mint()`, `TurnId.mint()`) or `MessageId.mint(text)`. A temporary name with no semantic kind uses `Id128.mint()`. That is 15 `Id128Text.mint(` sites in 11 modules and 1 at `messages.py:952`, edited in this phase.
- `FieldCodec._representation` (`:1534`) walks a bare `NominalText` subclass's MRO to its nearest recorded spelling, beside the `WireValue` branch at `:1556`. Every `Id128` kind therefore uses `Id128Text` with no registration per kind. A field declared `input_id: InputId` decodes to `InputId`, not the spelling's generic `Id128`. The encoder checks the declared kind and raises `TypeError` for an `InputId` in a `TurnId` field.
- `_TextDecoder` (`:2203`) accepts a `NominalText` and encodes it as exact `str`. This is the migration allowance, and phase 9 deletes it.
- Wire, schema and stored rows stay byte-identical (fact 1). The test is a round trip of a record with an `Id128` field, an `Annotated[str, Id128Text]` field and a plain `str` field holding an `Id128`.
- Wire, schema and stored rows stay byte-identical (fact 1). The test is a round trip of a record with an `Id128` field, an `Annotated[str, Id128Text]` field and a plain `str` field holding an `Id128`, a decode of each kind and a wrong-kind encode refusal. Three fixed inputs pin the exact pre-move `MessageIdText.mint` output, including Unicode, so stored message ids remain identical.

### 2. The ratchet

`PlainStrIdAnnotation(PerFileOccurrenceMeasure)` in `tools/debt_ratchet.py`, beside `StringFieldMetadata` (`:406`). It counts `str` annotations, on fields and on parameters, whose name ends in `_id`, `nonce`, `lookup` or `generation`. It blocks growth per file, so no new id can be declared `str` while the kinds migrate. 283 such annotations in 107 modules today. The count includes external ids (`session_id`, `tool_call_id`) that this plan does not convert; the ratchet only stops the count from growing.

### 3-8. One id kind per phase, smallest first

For each kind: retype its fields and parameters to `Id128` (`MessageId` in phase 4) and its `Optional`s to `Id128 | None`. Delete its re-decodes and constructor checks, citing the boundary line each value already passed. Route its hand-written `uuid4().hex` mints through `Id128.mint()`. A parameter fed by raw text (a CLI argument, a file name, a foreign record) is a boundary: it decodes once there and nowhere downstream.
For each kind: retype its fields and parameters to that kind (`ClaimId`/`AssignmentGeneration`, `MessageId`, `TurnId`, `GoalId`, `RootId`/`ThreadLookup`, `InputId`, respectively) and its `Optional`s to the same kind unioned with `None`. Delete its re-decodes and constructor checks, citing the boundary line each value already passed. Route its hand-written `uuid4().hex` mints through that kind's inherited mint. Count both plain `str` and bare `Id128` annotations: neither states the kind, and both must reach zero in that phase. A parameter fed by raw text (a CLI argument, a file name, a foreign record) is a boundary: it decodes once there and nowhere downstream.

- `AssignmentGeneration` names a Todo assignment generation (goal_actions.py:364; todos.py:2571), while `ClaimId` names an envelope claim generation (publisher.py:176). `ThreadLookup` names a thread incarnation lookup (registry_provenance.py:79); `RootId` names the bus root. Phase 1 declares the two additional kinds; the kind phases retype their use sites.

- **3 Claims** (`maintenance_barrier.py`, `selected_write_plan.py`, `envelope_claim_transitions.py`, `todos.py` generations, `publisher.py`). These are Installer's two boundaries, where the constructor check is the only validation (`maintenance_barrier.py:305,316`; `selected_write_plan.py:71,89`).
- **4 Messages.** `commander/order.py:784`, `messages.py:952`, and the 37 `message_id` annotations.
Expand Down
13 changes: 7 additions & 6 deletions src/agent_comms/acp_extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
FailureSource,
UnrecordedFailureSource,
)
from .agent_backend import InputId, SendNow, TurnInbox
from .agent_backend import SendNow, TurnInbox
from .agent_events import CompactionEvent
from .child_process import ProcessIdentity
from .context_reading import ContextReading
Expand All @@ -29,6 +29,7 @@
ExternalJsonShape,
FieldCodec,
Id128Text,
InputId,
MalformedRecord,
WireValue,
WireName,
Expand Down Expand Up @@ -672,7 +673,7 @@ def draft_text(self) -> str | None:
class PromptRequest(CommsRequest):
user_text: str | None = None
defer_display: bool = False
input_id: Annotated[str, Id128Text] = field(default_factory=Id128Text.mint)
input_id: InputId = field(default_factory=InputId.mint)
origin: Annotated[InputOrigin, WireOmitDefault()] = field(
default_factory=UnattributedInputOrigin, kw_only=True
)
Expand All @@ -685,20 +686,20 @@ def draft_text(self) -> str | None:
return self.user_text

@abstractmethod
def enqueue_control(self, inbox: TurnInbox, input_id: str) -> None: ...
def enqueue_control(self, inbox: TurnInbox, input_id: InputId) -> None: ...

async def publish_acceptance(self, inputs: InputDrain, session_id: str) -> None:
await inputs.emit_queue_state(session_id)


class QueuePromptRequest(PromptRequest):
def enqueue_control(self, inbox: TurnInbox, input_id: str) -> None:
def enqueue_control(self, inbox: TurnInbox, input_id: InputId) -> None:
pass


class SteerPromptRequest(PromptRequest):
def enqueue_control(self, inbox: TurnInbox, input_id: str) -> None:
inbox.put_nowait(SendNow((InputId(input_id),)))
def enqueue_control(self, inbox: TurnInbox, input_id: InputId) -> None:
inbox.put_nowait(SendNow((input_id,)))


@dataclass(frozen=True)
Expand Down
3 changes: 2 additions & 1 deletion src/agent_comms/agent_activity.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
from .coordinator import Coordination
from .declared_family import DeclaredFamily
from .errors import RelationViolationError
from .field_codec import TurnId
from .routing import TurnRouting
from .runtime_info import RuntimeInfo, RuntimeInfoStore
from .thread_identity import OwnerIdentity, ThreadIncarnation
Expand Down Expand Up @@ -254,7 +255,7 @@ def recover_completed_turn(self, lease: TurnLeaseFence, backend: type[AgentBacke
return self._recover_drain(thread.name, owner, diagnostic)

def begin_turn(
self, name: str, turn_id: str, detail: str = "", routing: TurnRouting | None = None
self, name: str, turn_id: TurnId, detail: str = "", routing: TurnRouting | None = None
) -> RegistryOwner:
"""Return the exact owner installed by this atomic begin, including its lease."""
leased, _ = self.registry.lease_local_turn(name, turn_id, routing=routing)
Expand Down
44 changes: 29 additions & 15 deletions src/agent_comms/agent_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from typing import TYPE_CHECKING, ClassVar, Self

from .declared_family import DeclaredFamily
from .field_codec import InputId
from .pi_counts import PiCount
from .pi_vocabulary import CompactionReason, ThinkingLevelField
from .send_outcome import SendBoundary
Expand Down Expand Up @@ -110,17 +111,6 @@ def model_choice(self):
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class InputId:
"""Core's identity for one input; a durable backend uses it as its request ID."""

value: str

def __post_init__(self) -> None:
if type(self.value) is not str or not self.value:
raise ValueError("An input ID must be a nonempty string")


class BusyInputPolicy(DeclaredFamily, affix="InputPolicy"):
"""What the backend does with an input that arrives while a run is going."""

Expand Down Expand Up @@ -291,14 +281,39 @@ class StaleCompaction(CompactionOutcome):
# ---------------------------------------------------------------------------


class InputStartListener(ABC):
"""The listener owns acknowledgement and the corresponding start notification."""

started_events: tuple[AgentEvent, ...]

@abstractmethod
def original(self, native_id: str, text: str) -> bool: ...

@abstractmethod
def following(self, public_id: InputId, native_id: str, text: str) -> bool: ...


class NoInputStartListener(InputStartListener):
started_events = ()

def original(self, native_id: str, text: str) -> bool:
return True

def following(self, public_id: InputId, native_id: str, text: str) -> bool:
return True


NO_INPUT_START_LISTENER = NoInputStartListener()


@dataclass(frozen=True, kw_only=True)
class TurnDelivery:
"""Core's bindings for one turn, handed to the backend that runs it.

``inbox`` carries :class:`InputRequest` and :class:`SendNow` records that
arrive while the turn runs. Core checks input permission at these boundaries: the
backend enters ``send_boundary`` around writing each input and
``interrupt_boundary`` around a send-now; ``input_started`` reports that the
backend asks ``send_boundary`` before writing each input or interrupt;
``input_start`` reports that the
runtime began an input; ``request_progress`` and ``ui_request`` observe
provider progress and answer runtime dialogs.
"""
Expand All @@ -308,8 +323,7 @@ class TurnDelivery:
inbox: TurnInbox
finish_event: asyncio.Event
send_boundary: SendBoundary
interrupt_boundary: SendBoundary
input_started: Callable[..., bool]
input_start: InputStartListener
request_progress: Callable[..., None]
ui_request: Callable[[DialogUiRequest], Awaitable[ExtensionUiChoice]]
tool_mode: NativeToolMode
Expand Down
Loading
Loading