Skip to content

feat(sdk): M2 first breadth batch — session list/get/create and message list - #26

Merged
Blind-Striker merged 47 commits into
masterfrom
feature/m2-first-breadth-batch
Aug 15, 2026
Merged

Blind-Striker merged 47 commits into
masterfrom
feature/m2-first-breadth-batch

Conversation

@Blind-Striker

@Blind-Striker Blind-Striker commented Aug 14, 2026 •

Copy link
Copy Markdown
Collaborator

What this is

M2's first breadth batch: v2.session.list, v2.session.get, v2.session.create, and v2.message.list become callable through their final generated surface, deliberately opening the three machines M1 walled off — query-parameter binding, request bodies (first non-GET verb), and cursor-paged list envelopes. Architecture unchanged: SpecIR + curation → plans → name-blind emitters → committed source delegating once into the hand-written Pipeline, which grew exactly one capability (JSON bodies through the source-generated context).

Executed per docs/superpowers/plans/2026-08-14-m2-first-breadth-batch.md; decisions in research log Sessions 19–22 (Q76–Q90). Every commit is a red-test-first increment behind all six gates (830 tests at head, alignment batch and the construction/options/DI reshape included).

Surface

client.Sessions.ListSessionsAsync(new SessionListRequest { Limit = 5, Order = ListOrder.Descending, ParentId = SessionParentFilter.RootOnly });
client.Sessions.CreateSessionAsync(new SessionCreateRequest { Title = "…" });   // no-arg sends {}
client.Sessions.GetSessionClient(id).GetSessionAsync();
client.Sessions.GetSessionClient(id).ListMessagesAsync(new MessageListRequest { Limit = 5 });  // : ListRequest
  • Query request records over the fail-closed profile wall (MessageListRequest : ListRequest on exact trio match; SessionListRequest flat with 9 typed properties); sealed int? limit refuses non-positives at the route boundary.
  • Cursor-list envelopes: guarded IReadOnlyList<T> payload + shared ListCursor; single-pass deserialization through internal envelope DTOs (the P2 fold — ReadDataPayload's double parse retired).
  • Session.Info model closure (fork-boundary marked union with unknown carrier, revert, file diff, time/fork nests), InvalidCursorError, UnknownError, and the first 5xx status-map arm.
  • Upstream's byte-identical InvalidRequestError1 duplicate collapses through the new schemaAliases curation (deep-identity wall; every upstream drift direction stays loud).
  • List*/Create* verb rules with the C18 structural fix; C17/C19/C20 naming walls; the M2: generator fail-closed walls batch (F08 F09 F10 F11 F13 F14 F15 + P2 fold) #21 fail-closed walls batch (F08–F15); concrete Unknown* carrier converters (M2: Unknown* concrete-type serialization does not reproduce the raw document (F07) #19).
  • Parameters are uniformly request (operation input) and requestOptions (per-call) — the Q83 rename landed in the alignment batch below.

Alignment batch (Q83–Q85)

The three sealed alignment decisions (research log Session 20) landed on this branch after the breadth batch, one commit each:

  • Uniform *Request rename (Q83, c26653a). Every operation input is {Group}{Subject}{Verb}Request and the Options family retires: the hand-written ListOptions seam becomes ListRequest (same Limit/Order/Cursor trio — the M3 paginator seam keeps its shape), generated query records derive through RequestTypeName, and the method/route-builder parameter renames options → request. An operation declaring both a body and query parameters now derives the same type name twice and fails the existing collision wall — the intended fail-closed state until a merged-Request design is sealed, pinned by a refusal test. The PublicApi diff of this commit is the rename only.
  • Feature-slice layout (Q84, 251b483). Sessions/, Health/, and Pagination/ folders with flat public namespaces — folders are placement, a namespace is API surface, so the PublicApi baseline did not move at all. Emission is name-blind (per-operation RouteContainerName; the binder-set ClientPlan.ContainerName for family clients); the writer's allowlist learns family folders fail-closed from the plan's container names through the new GenerationWriteRequest; IDE0130 is arbitrated for the SDK's public folders and their test mirrors only.
  • Extensions bring-up (Q85, 1680d7b), reshaped by Q90 below. New mirrored OpenCode.Sdk.Extensions.Tests project; the first-cut constructor-mirror shape was grilled and rebuilt the same day.
  • Construction/options/DI reshape (Q90, a0c83a9) — sealed after a maintainer grill plus upstream re-research at the pin (research log Session 22, Q87–Q90). Construction is options-only (OpenCodeClient(OpenCodeClientOptions) + BYO-HttpClient; the Uri ctors retire — one home for the endpoint). Options stay convention-mutable but consumption is immutable: the pipeline reads the new IOpenCodeClientOptions view and snapshots at construction (pinned by test). Username joins (upstream --username/OPENCODE_SERVER_USERNAME, default opencode); Password null = anonymous (v2 auth is optional); the OPENCODE_SERVER_PASSWORD fallback leaves the SDK entirely — consumers resolve env, per upstream's own layering (revises the env half of M2 decision: OpenCodeClientOptions.Password semantics — unset vs empty vs whitespace (C16) #20). AddOpenCode rebuilds on IHttpClientFactory: Action<> + AOT-annotated IConfiguration overloads, transient typed client, explicit sub-client registrations, returns IHttpClientBuilder for handler/resilience/telemetry chaining. Owned transport gains SocketsHttpHandler + PooledConnectionLifetime on modern TFMs. The sandbox is now the Generic Host showcase of all of it.

Numbers

P2 single-pass envelopes: GetMessageAsync 67.4 → 56.2 μs, allocations flat (27.4 KB). New baseline: ListMessagesAsync 58.1 μs / 28.24 KB. GetHealthAsync unchanged (1.6 μs / 2.08 KB). The alignment batch is perf-neutral (names and file placement only; no emitted-shape change).

Live demo (opencode2 serve v0.0.0-next-17403, 2026-08-14)

health:   200 healthy=True version=0.0.0-next-17403
create:   200 id=ses_0025941d0ffe3kE39EQmwAJPYz title=sdk breadth demo
list:     200 sessions=3 cursor.next=eyJvcmRlciI6ImRlc2MiLCJhbmNob3Ii…
get:      200 id=ses_0025941d0ffe3kE39EQmwAJPYz
messages: 200 count=0 cursor.next=<none>

Review guidance

  • tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt is the primary review surface: the hand-written spine types (ListRequest, ListOrder, ListCursor, SessionParentFilter, IOpenCodeClientOptions), the options-only constructor pair, the uniform request/requestOptions parameters, the four operations with their request/response types, the Session.Info model graph, and [JsonConverter] attributes on the eight Unknown* carriers. The Extensions package has no baseline lock yet — review OpenCodeServiceCollectionExtensions directly (two public methods returning IHttpClientBuilder).
  • tools/curation.json gained the message group row (merged into the Sessions client family) and the InvalidRequestError1 alias — curation changes are API review per ADR-0008.
  • Route-member nuance: client-placed members mirror method names (Sessions.ListSessions, Sessions.GetSession) — the mechanical resolution that keeps merged families collision-free; root members keep the bare-verb shape (Health.Get).
  • The contract matrix caught a real defect pre-merge: the error-path constructor pushed its forgiven null through the list payload's defensive-copy init accessor (NRE on every cursor-list error); fixed in the emitter, pinned by test.

Verified-review fix batch (2026-08-15)

The adversarially verified external review (36 findings: 30 confirmed / 4 plausible / 2 refuted) closed out on this branch — every merge blocker plus the small confirmed fold-ins landed red-test-first, one green commit per fix, all six gates before each. Tests: 830 → 904.

Second review pass (same day)

A second full-diff review produced 15 verified findings. The six small runtime/emitter fixes landed here (03c0aa1, b628c0f): timeouts wrap into OpenCodeTransportException instead of masquerading as caller cancellation, a conflicting injected BaseAddress refuses loudly like the Authorization guard, handle construction applies the same route-value guard as the route builders, unknown-carrier constructors refuse unparsed payloads, the non-positive-limit refusal carries the caller-visible parameter identity, and the family-folder wall owns Pagination. Every surviving finding is milestone-anchored with an explicit trigger: #31 (M2, DI lifetime shape, decision-first), #32 (M3, EscapeDataString TFM policy), #33 (M3, carrier hand-construction semantics), #34 (M2, contract-test consolidation), plus enrichments on #27 (five new wall gaps) and #23 (the list re-copy mechanism).

Closes #19
Closes #21
Closes #22

… plans

The binder opens the M1 query wall: optional nullable query parameters bind
into per-operation options plans carrying wire names, PascalCase property
names, and shape-derived value kinds (text, positive count, asc/desc order,
parent-session filter). A fail-closed profile wall derives an operation from
the ListOptions base only when its wire parameters are exactly the
cursor-pagination trio; anything unrecognized (required, non-nullable,
deep-object, foreign enums, object shapes) refuses generation. Query schemas
stay out of the model closure, and options type names join the generated-type
collision checks.
POST joins the admitted methods, paired fail-closed with bodies: GET must not
carry one, POST must. A required JSON body referencing an object schema binds
into a request-body plan; the operation parameter is optional exactly when
every body property is optional, so an empty call sends an empty JSON object.
Inline body schemas are named from operation identity through the mechanical
{Subject}{Verb}Request rule instead of their op-rooted graph keys, which
would otherwise surface the protocol prefix ADR-0005 strips.
The envelope plan carries an explicit kind (bare, data, cursor list) instead
of a data-envelope flag. Cursor-list responses bind fail-closed: the wrapper
must require exactly 'data' and 'cursor', the cursor must be the
optional-nullable previous/next object, and items must reference named
component schemas. Wrapper roots — component-named ones included — and the
cursor object stay out of the model closure: they are envelope structure the
response spine renders, never generated models.
A schemaAliases curation section declares one component as a spelling of
another — the resolution sealed for upstream's InvalidRequestError1
duplicate, whose identical _tag would otherwise trip the per-status
duplicate-tag wall and poison tag dispatch. The collapse is a pure document
transform ahead of name resolution, so downstream machinery never sees the
duplicate; union branches that fold into the same reference deduplicate
mechanically. Every drift direction stays loud: a deleted source or target
orphans the row, a dereferenced source goes dormant, and any structural
divergence — the tag included — fails the deep-identity wall. The binder's
own duplicate-tag refusal stays intact for anomalies without an alias.
v2.message.list belongs to the 'message' wire group but its sealed home is
the Sessions client family. Client assembly now clusters by declared client
name instead of wire group, and the curation validator demands identical
handle declarations across a merged family so a divergent row cannot fork
it. A pinned-shape test also documents that 5xx arms ride the existing
status-map machinery unchanged.
ExecuteAsync gains a body-carrying overload serializing through
source-generated JsonTypeInfo metadata so the AOT commitment holds; both
overloads share one core, keeping decoration, buffering, and error-channel
policy identical. The body rides ByteArrayContent under a bare
application/json content type (JSON is UTF-8 by RFC 8259), the body-less
path stays body-free, and the recording test seam now captures request
content and content type.
Verb detection is structural: only the final identifier segment can be a
verb (create/get/list), so mid-position segments spelling a verb stay in
the subject — the C18 fix. Empty subjects fall back to the group,
pluralized for list operations under a naive fail-closed rule (words
needing -es or -ies refuse until the rule set grows). Response types fold
non-Get verbs so get/create envelopes cannot collide; client-placed route
members mirror their method names so merged families stay unique, while
root members keep their bare-verb shape. The naming walls batch rides
along: reserved keywords stop passing identifier validation (C20), the
reserved payload set covers object/record-synthesized members (C19), and
mechanical payload names pass the same identifier wall as curated ones
(C17). The committed two-operation surface is byte-identical under the
new rules.
ListOptions carries the sealed cursor-pagination trio as the seam the M3
paginator will consume; ListOrder spells asc/desc without magic strings;
ListCursor surfaces the wire cursor on list envelopes; SessionParentFilter
makes the root-only sentinel unrepresentable as ordinary data and refuses
the literal 'null' spelling. The internal QueryStringBuilder owns the
route-boundary conversions: invariant positive counts (non-positive
refused, the sealed int? limit contract), wire order spellings, escaped
values, omitted unset optionals. PublicApi baseline grows by exactly these
four types, identically across TFMs.
…t envelopes

The emitters open every M1 wall the binder now crosses. New emitters render
public options records (flat, or deriving from the ListOptions base) and the
internal envelope DTOs behind P2's single-pass read: every wrapped success
body deserializes once through source-generated metadata, retiring the
JsonDocument double-parse and the hand-written ReadDataPayload primitive.
Route builders compose escaped query suffixes through QueryStringBuilder;
cursor-list envelopes carry a guarded IReadOnlyList payload plus the wire
cursor; adapters project DTOs directly; operation methods thread body models
(an omitted optional body still sends the empty JSON object) and query
options, and the per-call OpenCodeRequestOptions parameter is uniformly
named requestOptions. Committed two-operation surface regenerated under the
new emitters; snapshots and the PublicApi baseline reviewed alongside.
The profile selects v2.session.list/get/create and v2.message.list; the
message wire group joins the Sessions client family and the
InvalidRequestError1 duplicate collapses through its curated alias. The
committed surface gains ListSessionsAsync, GetSessionAsync,
CreateSessionAsync, and ListMessagesAsync with their options records,
request model, cursor-list envelopes, single-pass DTOs, query-composing
routes, and the Session.Info model closure (fork boundary union with its
unknown carrier, revert, file diff, time/fork nests) plus the
InvalidCursorError and UnknownError types — the first 5xx arm included.
Pinned binder expectations and the PublicApi baseline reviewed alongside;
packing stays blocked behind the 114 pending operations.
Contract tests cover the four new operations end to end: typed pages with
cursor round-trips, escaped query composition, the sealed non-positive
limit refusal at the route boundary, both declared 400 tags on
session.list, the empty-body and typed-body create paths, every remaining
declared error status including the first 5xx arm on both error channels,
undeclared-2xx and missing-envelope protocol failures. The matrix caught a
real defect: the error-path constructor pushed its forgiven null through
the list payload's defensive-copy init accessor, crashing every cursor-list
error before the guard could hold — the copy now passes the forgiven null
through uncopied, and the envelope snapshot records the shape.
…carriers (#19)

Every union now emits a concrete-type converter for its Unknown* carrier:
writing replays the preserved document instead of the record shape
source-generated metadata would emit, and reading reproduces the base
converter's fallback arm, so consumer serialization of a concrete carrier
can no longer leak a {marker, Payload} record. Round-trip tests cover
representative carriers through both the union base and the concrete type;
the benchmark suite gains the ListMessagesAsync cursor-page baseline
(58.1 us / 28.24 KB) and records the P2 single-pass win on GetMessageAsync
(67.4 -> 56.2 us, allocations flat).
Seven latent walls close ahead of the surfaces that would trip them:
numeric literals refuse at binding instead of mapping to string (F08);
nested marked unions may spell their branches with oneOf (F09); a tag
owned by two closure schemas refuses before it can poison the converter
dispatch map (F10); wire parameters landing on emitted signature names
(options, requestOptions, request, pipeline, cancellationToken) fail at
bind, never as emitted compile errors (F11); an out-of-range curation
placement is a loud curation error instead of a silently dropped group
(F13); repeated path-template tokens refuse at ingestion since builders
substitute each token exactly once (F14); and generated client, response,
and options names collide against the model namespace and the hand-written
spine, not just each other (F15). The P2 envelope fold landed with the
emitter batch. Sandbox now demos create -> list -> get -> messages.
Research log Session 20 records the decisions sealed during execution
(options/order/cursor type names, the schemaAliases resolution of the
InvalidRequestError1 duplicate with its drift guarantees, the
options/requestOptions parameter rule) and the execution notes (concrete
verb rules, the P2 numbers, the defect the contract matrix caught). The
ROADMAP moves the batch to complete, the plan's checkboxes close, and the
consumed 2026-08-14 handover is deleted.
Null stays the unset spelling that reaches the OPENCODE_SERVER_PASSWORD
fallback; an explicitly empty or whitespace password fails loudly with
ArgumentException at construction instead of silently bypassing the
fallback and traveling as a real Basic credential. The server always
generates a password, so an empty-credential mode has no upstream meaning
to model. Property documentation states the contract.
Research log Session 20 gains the review's three sealed decisions: operation
inputs unify on {Group}{Subject}{Verb}Request across query and body (Q83,
revising Q76's naming half on the ecosystem survey — no surveyed SDK splits
the suffix by verb), the shipped SDK slices by client family with flat
public namespaces and mirrored test layout (Q84, canonical wording now in
coding-style §4 with the IDE0130 arbitration), and the Extensions package
rises in parallel with M2 breadth instead of waiting for M6 (Q85). The #20
and #25 closures are recorded, the ROADMAP carries the alignment-batch
queue, and HANDOFF-2026-08-14-2 hands the execution to the next session.
The next session's handover now carries the whole sequence instead of the
alignment batch alone: comprehensive onboarding order, the PR #26 state,
the alignment batch landing on the open PR, PR finalization, the external
multi-agent review cycle with its triage protocol, merge, and the M3
pickup (plan-first convention, scope pointers, and the sanctioned spec-pin
refresh at the boundary).
Every operation input is now {Group}{Subject}{Verb}Request and the Options
family retires: the hand-written ListOptions seam becomes ListRequest (same
Limit/Order/Cursor trio), generated query records derive through
RequestTypeName, and the method and route-builder parameter renames
options -> request (requestOptions unchanged). Internally the plan vocabulary
follows: QueryRequestPlan, QueryPropertyPlan, QueryRequestEmitter,
OperationPlan.QueryRequest.

ReservedParameterNames shrinks to emitted reality (request, requestOptions,
cancellationToken, pipeline); a path parameter named 'options' is admitted
again and pinned by test. An operation declaring both a body and query
parameters now derives the same type name twice and fails the existing
collision wall - the intended fail-closed state until a merged-Request
design is sealed, pinned by a refusal test.

The PublicApi baseline diff is the rename only, reviewed as such.
Perf-neutral: no emitted-shape change beyond names.
The shipped SDK now slices by client family with flat public namespaces:
Sessions/ and Health/ hold each family's clients, envelopes, and query
requests; Pagination/ holds the ListRequest/ListOrder/ListCursor spine;
Models/, Internal/, and the root client with the response/exception spine
stay put. Namespaces remain OpenCode.Sdk and OpenCode.Sdk.Models - folders
are placement, a namespace is API surface - so the PublicApi baseline is
untouched by this change.

Emission is name-blind: envelope and query-request files derive their
folder from the operation's RouteContainerName, family clients from the
new ClientPlan.ContainerName the binder sets from the curated client name;
the root client, OpenCodeRoutes, and the registry keep their homes. The
writer's allowlist learns the family folders fail-closed through the new
GenerationWriteRequest (admitted folders come from the plan's container
names, one level deep, never shadowing a static directory); manifest
entries validate shape only so a retired family's files stay deletable
behind the provenance wall. WriteAsync's six parameters fold into the
request record per the signature-hygiene rule.

IDE0130 is arbitrated for the SDK's public folders and their test mirrors
per the standing per-rule pattern; contract tests and the parent-filter
spine test move under Sessions/ with flat test namespaces. Manifest paths
churned once; stale root-level copies deleted in the same regen.
OpenCode.Sdk.Extensions gains its first surface: three AddOpenCode
overloads mirroring the client constructors - (Uri), (Uri,
Action<OpenCodeClientOptions>?), and a caller-owned HttpClient with a
required configure action (the endpoint is required on that path). Each
registers the root OpenCodeClient as a singleton the container disposes,
plus a SessionsClient registration resolved from the root so consumers
inject sub-clients directly. Extension methods live in the
Microsoft.Extensions.DependencyInjection namespace per the recorded
companion-package convention, with the IDE0130 arbitration scoped to that
one file.

The only new dependency is Microsoft.Extensions.DependencyInjection
.Abstractions per the research doc 06 policy; IHttpClientFactory
integration stays out of this first cut deliberately. The new
OpenCode.Sdk.Extensions.Tests project mirrors the layout and covers each
overload's composition, sub-client resolution, the configure action, the
caller-owned transport path against a canned health fixture, and the
input guards. Registrations stay hand-written; the research log records
the generator-emission trigger for when client families multiply.
The external/opencode gitlink still pointed at the 1.x line (v1.18.15)
while spec/ has pinned v2-branch commit a6a712a since the M1 retarget -
every fresh clone checked out a tree that no longer matches the surface
the SDK is built against. The gitlink now records the exact spec-pin
commit, so 'git submodule update --init' lands there mechanically;
.gitmodules documents the v2 tracking branch, and the SNAPSHOT.md
refresh procedure gains the lockstep step so the pointer and the
snapshot never diverge again.
.gitignore carried an external/ pattern that never affected the tracked
gitlinks but made every 'git add external/opencode' refuse with an
ignored-path warning - the submodule pin update tripped over it. The
submodules are ordinary tracked entries; they need no ignore rule.
Research doc 15 gains the parameter-placement census at the pin (5a: zero
header params; the location deepObject on 61 of 120 ops; all 15
body+query mixes caused by location alone; session.list as the single
flat-field exception) and the re-derived auth and location mechanics
(optional Basic auth, configurable username via --username /
OPENCODE_SERVER_USERNAME defaulting to opencode, client-side password
resolution owned by the CLI consumer over OPENCODE_PASSWORD ->
OPENCODE_SERVER_PASSWORD, dual-channel location resolution with query >
header precedence). Research log Session 22 records the findings and the
sealed decision: the location + merged-Request input design runs as a
short session opening M3 planning instead of waiting for the collision
wall. The vision spec's obsolete header-only Directory story is corrected
in place; the ROADMAP M3 milestone carries the design session.
Sealed as the in-session 'Q86' batch (research log Q90). The client now
constructs from options alone - OpenCodeClient(OpenCodeClientOptions) and
the caller-owned-HttpClient overload; the Uri constructors retire and the
endpoint has exactly one home, killing the endpoint-must-stay-unset guard.
Options gain the read-only IOpenCodeClientOptions view the pipeline
consumes and snapshots at construction (mutating an options object after a
client is built never changes that client), plus a configurable Username
(upstream: --username / OPENCODE_SERVER_USERNAME, default opencode) beside
the optional Password whose null now plainly means anonymous requests -
auth is optional on v2 servers. The OPENCODE_SERVER_PASSWORD environment
fallback leaves the SDK entirely (upstream layering: the consumer resolves
the environment; revises the env half of #20 on Session 22 evidence), and
the environment-provider seam retires with it. The self-created transport
rides SocketsHttpHandler with a pooled connection lifetime on modern TFMs.

Extensions rebuilds on IHttpClientFactory: AddOpenCode(Action<...>) and a
config-binding AddOpenCode(IConfiguration) (AOT-annotated) register a
transient typed client over a named factory client and return the
IHttpClientBuilder, so consumer handlers, resilience, and telemetry chain
on without SDK middleware; SessionsClient resolves straight from the
container. The sandbox becomes the live showcase: Generic Host,
AddOpenCode, a consumer-owned ConsoleTimingHandler on the builder, and
direct sub-client resolution. The PublicApi baseline diff carries exactly
this reshape.
Research log Session 22 gains the Q90 entry (options-only construction,
read-only options view, Username, no SDK environment reads, factory-based
AddOpenCode over IHttpClientBuilder, pooled-lifetime owned transport);
the ROADMAP status paragraph carries the reshape.
…rge, M3

The PR #26 review cycle ran through adversarial verification (36 findings:
30 confirmed, 4 plausible, 2 refuted, 10 merge blockers). Survivors are
preserved durably: issues #27 (M5 naming-wall gaps), #28 (M2 curated
mutual-exclusion, decision-first), #29 (M3 success-path allocations riding
#23), #30 (M2 cursor PrintMembers), and seven hygiene items on #24.

HANDOFF-2026-08-14-3 supersedes and deletes its predecessor: it carries
the Q91 BYO-HttpClient question (recorded leaning: internalize behind the
Extensions IVT; not sealed - an industry survey runs first, and a
first-class-but-pragmatic default-header resolution keeps BYO acceptable),
the ten-blocker fix batch with scratchpad-independent summaries and
fold-ins, the merge steps, and the M3 pickup whose planning opens with the
location + merged-Request design session. The ROADMAP status paragraph
reflects the verification outcome and Q91 joins its open questions.
…ays public

Seven-agent primary-source survey (doc 16): no surveyed SDK gates transport
behind a DI companion, none guards the anonymous default-Authorization leak,
and the stock typed-client pattern requires the public constructor. Anonymous
mode will fail closed at construction and per send (blocker #1's fix shape);
the ROADMAP open question resolves and the stale env-fallback sentence (#25)
aligns with Q90.
… clients

Anonymous mode cannot be expressed by omission over a caller-owned HttpClient:
the BCL copies default headers onto every request that lacks the header, so a
foreign credential would ride requests documented as anonymous (review
blocker #1; Q91 seal). The pipeline now refuses the conflict fail-closed at
construction (ArgumentException) and before every send (the typed transport
spine, before any handler runs), by presence rather than parseability; with a
password set the per-request header keeps winning by the BCL request-wins
merge, now documented on the constructor and options. Direct-constructor and
IHttpClientFactory/ConfigureHttpClient regressions pin that no request
reaches the handler.
…ma forbids it

The binder overwrote the sole nullability signal when widening optional
non-collection properties for absence, so a wire null reached typed success
instead of the malformed-body wall (review blocker #2). The plan now carries
an AllowsWireNull bit capturing schema-admitted null (anyOf-null, plus
JsonElement whose unrestricted schema admits everything) separately from the
C# absence shape, and the emitter puts a shared AOT-safe rejecting converter
on every optional non-collection property whose schema does not admit null:
present-and-null throws JsonException, which the response adapters already
map to OpenCodeTransportException; absence and true anyOf-null stay legal.
Covered at the binder (synthetic + pinned parentID), the emitter snapshot,
and the concrete 200 session path.
Public surface first (constructor, factories, ExecuteAsync, Dispose), then
private members in first-use order — the repo-wide member-ordering rule.
A 200 page whose data array carried a null element deserialized into a typed
success that exploded later with NullReferenceException (review blocker #3).
Cursor-list DTOs now ride a shared AOT-safe converter that turns a null array
or a null element into a JsonException the adapters map to
OpenCodeTransportException, and the public envelope initializer's defensive
copy refuses null elements with ArgumentException while keeping the error
path's forgiven null behind the getter guard. Both generated cursor-list
shapes are covered by contract tests and emitter snapshots.
The parameterless JsonStringEnumConverter admits integers, so a numeric
status in a success body surfaced as an undefined enum member instead of a
malformed-body transport failure (review blocker #4). Every generated enum
now rides a shared strict converter chaining to
JsonStringEnumConverter<TEnum>(namingPolicy: null, allowIntegerValues:
false); the six enums regenerate, and context plus 200-session-path tests
pin numeric-status rejection alongside the known-string mapping. Union
carriers (ADR-0009) are untouched.
Alias identity compared primitive nodes by kind alone even though format
picks the emitted CLR type ('format: uri' emits Uri), so a format-drifted
duplicate could collapse silently instead of tripping Q81's fail-closed
drift wall (review blocker #5). Format now compares ordinally ahead of the
node-kind switch, riding every recursive comparison; a format-only alias
divergence refuses.
The collision wall bulk-unioned model names with ReservedSpineTypeNames,
silently swallowing duplicates such as a schema named ListCursor whose
unqualified emitted references would then resolve to the pagination spine
type (review blocker #6). Reserved names now seed the owner set and each
model name joins individually; a model/spine duplicate reports a
deterministic naming diagnostic and no emit plan escapes.
Registry composition deduplicated model and DTO names with a Distinct, so an
operation-derived DTO name matching a component model collapsed two
serializer identities and made the emitted DTO recursively typed instead of
refusing (review blocker #7). DTO names now validate against the schema
registry and against each other before composition, with deterministic
naming diagnostics; the Distinct is gone.
An override keyed to an alias source validated cleanly, then vanished after
collapse because the type binder looked it up under the target key (review
blocker #9). Override schema keys now map through the alias collapse before
type binding, and rows landing on the same logical (schema, property) after
resolution refuse with a curation diagnostic instead of one silently
winning. Regressions cover the reachable-source override and the
source-plus-target collision.
Operation-derived Request naming applied only to inline bodies, so a
selected operation with a component-referenced body would surface the raw
component spelling instead of the sealed {Subject}{Verb}Request name (review
blocker #12). The name resolver now takes the selected operations and claims
every selected nominal body root — inline or component — with the
operation-derived name; ownership is selection-scoped so pending operations
cannot rename shared components, two selected claims under different names
refuse, nested dependencies stay shared, and BindRequestBody asserts the
resolved name. Folds #8: operations mixing a request body and query
parameters refuse explicitly in the wire-shape check, independent of any
name collision, until the merged-Request design seals at M3.
Concrete unknown-carrier converters lacked HandleNull, so deserializing a
null token bypassed Read and returned null for a non-nullable carrier
(review blocker #14); neither converter validated a nested union's fixed
outer tag, so a contradicted discriminator rode into the carrier and
replayed on serialization (#13, folded). Carriers now emit HandleNull with
an explicit null-token rejection, and both the carrier and base converters
validate the fixed marker's presence, kind, and exact value while keeping
unknown-inner-marker tolerance and raw replay. All eight carriers
regenerate; regressions deserialize through the raw type info so the
wrapper's null coalesce cannot mask a bypass.
Four small confirmed findings from the PR #26 review land together: the
owned transport disposes when the pipeline constructor refuses credentials
after creation (#15); the family-folder shadow and duplicate checks compare
with the writer's case-insensitive owned-path comparer (#20);
QueryStringBuilder escapes parameter names exactly like values (#21); and
the User-Agent product token resolves once into a shared immutable instance
instead of re-reflecting per client (#30 — no performance claim made, the
reflection removal is the recorded obvious-waste rule). The Username
documentation drops its pinned-server claim: the pinned server accepts only
'opencode'; --username/OPENCODE_SERVER_USERNAME is upstream direction, and
the option is the gateway seam (#23; doc 15 and the Q89 finding corrected).
Comments in code artifacts explain the status quo on the spot — no doc or
ADR citations, no decision dates or attributions, no milestone scoping or
future promises, no review-finding ids. A repository-wide sweep rewords or
drops every such fragment across the SDK, the generator, the tests, and the
build infrastructure, and reunites a doc comment that had drifted away from
ReservedPayloadNames.
…ing BaseAddress

Second-review runtime findings: an HttpClient timeout escaped as
TaskCanceledException masquerading as caller cancellation — cancellation
whose token the caller never canceled now wraps into
OpenCodeTransportException on both the send and body-read paths, while
genuine token cancellation still passes through untouched. An injected
client's BaseAddress, which the pipeline never consults, now refuses at
construction and before every send instead of being silently ignored — the
same loud posture as the anonymous Authorization guard. The non-positive
Limit refusal becomes ArgumentOutOfRangeException carrying the route
builder's own 'request' parameter name instead of a helper-internal one.
The handle factory and handle constructor now emit the same route-value
guard as the route builders — non-whitespace and never a dot segment — so
an id the route would refuse can no longer construct a handle and detonate
later under a foreign parameter name; the guard pair lives once in
EmissionSyntax. Unknown-carrier constructors refuse an unparsed
default(JsonElement) payload with an argument exception instead of letting
Clone surface a bare InvalidOperationException. The family-folder shadow
wall now also owns Pagination, the hand-written spine folder its own doc
comment promised to protect.
@Blind-Striker
Blind-Striker merged commit de23d73 into master Aug 15, 2026
@Blind-Striker
Blind-Striker deleted the feature/m2-first-breadth-batch branch August 15, 2026 13:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant