Skip to content

Wayfinder: architecture review — deepening candidates (2026-08-21) #224

Description

@Skymly

Destination

Tracking map of deepening candidates from the 2026-08-21 architecture review (post-0.2.0 IO stub generator pipeline, Proxy domain catalog, Feature runtimes, IDE edits).

This map does not ship code. Each item is grilled later as its own child (/grilling), then filed as one GitHub issue + PR per solution module (AGENTS.md). Do not implement from this map.

Related prior map: #212 (bug hunt; items 1–2 already grilled).

Notes

Lens: shallow modules, leakage across a seam, and confirmed bugs. Vocabulary: module, interface, depth, seam, adapter, leverage, locality.

Hot spot: 0.2.0 extracted IoProxyGeneratorPipeline / ProxyRegistrationEmitter / ProxyDomainTable. Remaining friction is catalog wrappers, Parser walks, R3 vs Reactive subscribe clones, RestAPI ownership flags, IDE completion/code fixes, and eight identical *Service.For factories.

Do not re-open: ADR-001 (no third reactive backend); ADR-002 (no new Feature from this map).

Do not re-open without a new decision: #215 — no Shared subscribe-pump module. Per-Feature Reactive bugs are https://github.com/Skymly/Observables/issues/216–https://github.com/Skymly/Observables/issues/223.

Do not migrate RestAPI onto ForAttributeWithMetadataName (ROADMAP C2). RestAPI stays on CreateSyntaxProvider.

Suggested child order (grill, then file)

# Strength Module Working title Why first
1 Strong Shared Collapse Feature identity into the Proxy domain catalog Adding a Feature still edits four shallow modules; generators do not read the table
2 Strong Shared Deepen completion and package code fixes InsertText never applied; RestAPI verbs on every Feature; csproj writer bypasses workspace Solution
3 Strong per-Feature (not Shared pump) Deepen Feature subscribe/dispose behind each Feature protocol R3 vs Reactive still clone the pump; #215 forbids a Shared pump
4 Worth exploring RestAPI Deepen RestAPI response ownership after #214 isApiResponse / disposeResponse flags still leak; classifier matches IApiResponse by name
5 Worth exploring Shared Push the IO stub generator pipeline into the Parser walk Eight Parsers clone walk / HasAttribute / nullability / IInternalErrorProbe
6 Worth exploring Shared Collapse *Service.For factories Eight ~2.6KB clones; ROADMAP D1 deferred Core — reopen only if grilling says yes

Each grilling session on a child should run /grilling. If a deepened module is named after a concept not in CONTEXT.md, update the glossary there.


Item 1 — Collapse Feature identity into the Proxy domain catalog

Module: Shared
Strength: Strong · in-process

Files:

  • Observables.Shared/Observables.Roslyn.Shared/ProxyDomainTable.cs
  • Observables.Shared/Observables.Analyzers/ProxyDomainCatalog.cs
  • Observables.Shared/Observables.CodeFixes/ObservablesMemberDiagnosticIds.cs
  • Observables.Shared/Observables.CodeFixes/BoundaryAttributeDefaults.cs
  • Observables.Shared/Observables.CodeFixes/ObservablesPackageReferenceMappings.cs

Problem. ProxyDomainTable is the real table (markers, OBS*001/004, default boundary attributes). Four shallow modules wrap or re-list it. Runtime / Reactive package IDs live in ObservablesPackageReferenceMappings, not the table. Stub generators still hard-code "Observables.Redis.RedisAttribute" instead of reading the catalog. Adding Redis meant editing the catalog and a second package map.

Deletion test. ProxyDomainCatalog / ObservablesMemberDiagnosticIds / BoundaryAttributeDefaults are pass-through (just move). Folding empty-interface ids, package ids, and assembly names into one catalog concentrates.

Known invariant bugs. EmptyRestApiInterface (OBS3007) text says “HTTP method attributes”; EmptyProxyInterfaceAnalyzer only looks for [RestApi]. ProxyDomainTable.RestApi has empty method/property attributes.

Deepening shape (for grilling, not a spec). One Proxy domain catalog interface; analyzers and code fixes become adapters. Do not invent a second table.


Item 2 — Deepen completion and package code fixes

Module: Shared
Strength: Strong · local-substitutable

Files:

  • Observables.Shared/Observables.Analyzers/CompletionItemFactory.cs
  • Observables.Shared/Observables.Analyzers/BoundaryAttributeCompletionProvider.cs
  • Observables.Shared/Observables.Analyzers/RestApiMemberCompletionProvider.cs
  • Observables.Shared/Observables.CodeFixes/ProjectFileWriter.cs
  • Observables.Shared/Observables.CodeFixes/AddRuntimePackageReferenceCodeFixProvider.cs
  • Observables.Shared/Observables.CodeFixes/SwitchToReactivePackageCodeFixProvider.cs

Problem. Completion stores insert text in a property named InsertText and never implements GetChangeAsync; Roslyn inserts DisplayText. RestApiMemberCompletionProvider has no Feature marker gate, so HTTP verbs appear on SignalR/Mqtt interfaces (analyzers ship with every Feature package). ProjectFileWriter does File.WriteAllText on project.FilePath and returns the unchanged Solution (NoWarn=RS1035). OBS*005 offers “Add .Reactive” on an R3 generator, which walks the user into OBS0001.

Deletion test. CompletionItemFactory is just move. One IDE-edit module that owns GetChangeAsync, Feature gating, and workspace text concentrates.

Deepening shape (for grilling, not a spec). Catalog is the seam; completion and csproj edits are adapters. Tests hit that interface, not the filesystem.

Latent note from #212 residue. WebSocketConnect / WebSocketClose have no string ctor; catalog FormatAttribute would emit WebSocketConnect("Name")] if InsertText were wired.


Item 3 — Deepen Feature subscribe/dispose (per Feature, not Shared)

Module: each IO Feature (SignalR, Mqtt, WebSocket, Grpc, Sse, Nats, Postgres, Redis) — not Shared
Strength: Strong · local-substitutable
Blocked by decision: #215 (no Shared pump module)
Open children already filed: #216#223

Files (examples):

  • Observables.SignalR/Observables.SignalR/SignalRObservable.cs
  • Observables.SignalR/Observables.SignalR.Reactive/SystemReactiveSignalRAdapter.cs
  • Observables.Redis/Observables.Redis/RedisObservable.cs
  • Observables.Redis/Observables.Redis.Reactive/SystemReactiveRedisAdapter.cs
  • Observables.Mqtt/Observables.Mqtt.Reactive/SystemReactiveMqttAdapter.cs
  • Observables.Nats/Observables.Nats.Reactive/SystemReactiveNatsAdapter.cs

Problem. Two adapters (R3 *Observable vs SystemReactive*Adapter) already justify a Feature seam, but protocol (subscribe, map, cancel, unsubscribe) is copied. Reactive still uses _ = Pump() / _ = RunAsync(), linked CTS never Disposed, cancel often becomes OnError, MQTT never UnsubscribeAsync and can SubscribeAsync after dispose.

Deletion test. Deleting one adapter just moves the pump. Concentrating protocol inside the Feature (R3 and Reactive as thin adapters) is the remaining depth. A cross-Feature Shared pump was already rejected.

Grill later. Whether each open bug ticket (#216#223) is enough, or a Feature-local protocol module (like SignalR HubConnectionArgs) should absorb R3+Reactive. Do not file a Shared pump ticket unless #215 is superseded.


Item 4 — Deepen RestAPI response ownership (leftovers after #214)

Module: RestAPI
Strength: Worth exploring · local-substitutable
Shipped: #213 / PR #214 (IApiResponse ownership + cancel + factory coerce)

Files:

  • Observables.RestAPI/Observables.RestAPI/RestApiBridge.cs
  • Observables.RestAPI/Observables.RestAPI/ApiResponse.cs
  • Observables.RestAPI/Observables.RestAPI.SourceGenerators.Shared/RestApiReturnTypeClassifier.cs
  • Observables.RestAPI/Observables.RestAPI.SourceGenerators.Shared/Emitter.cs
  • Observables.RestAPI/Observables.RestAPI.SourceGenerators.Shared/Parser.cs

Problem. Generated code still passes isApiResponse; SendAsync still keeps a mutable disposeResponse beside ShouldDisposeResponse(typeof(TBody)). Classifier treats any generic named ApiResponse / IApiResponse as the RestAPI wrapper (name, no namespace). ParseNonHttpMethod can emit ReturnTypeInfo.Return for Observable<T> members the classifier would reject. RestService.For still falls through to Type.GetType + Activator unlike other Features’ GeneratedFactories.

Out of this item (still #212 leftovers, grill separately if needed). QueryUriFormat default 0; silent GET for custom HTTP attributes; AuthenticatedHttpClientHandler token getter.

Deepening shape (for grilling, not a spec). RestApiBridge owns lifetime; generated code stops passing dispose knowledge. Keep Task / IApiResponse classification in RestAPI (ROADMAP C1).


Item 5 — Push the IO stub generator pipeline into the Parser walk

Module: Shared
Strength: Worth exploring · in-process

Files:

  • Observables.Shared/Observables.SourceGenerators.Shared/IoProxyGeneratorPipeline.cs
  • Observables.{SignalR,Mqtt,WebSocket,Grpc,Sse,Nats,Postgres,Redis}.SourceGenerators.Shared/Parser.cs

Problem. 0.2.0 deepened incremental wiring only. Eight Parsers still clone: GroupBy(SyntaxTree)GetSemanticModel → marker scan → nullability → public members → HasAttribute. SignalR production Parser throws on AssemblyName == "GeneratorTests" + IInternalErrorProbe. OBS7004 is defined and never reported. IoProxyGeneratorPipeline / ObservableReturnTypeParser have no Shared tests (coverage is Feature snapshots). Stub generators do not read the Proxy domain catalog (marker strings can drift from item 1).

Deletion test. Extracting HasAttribute alone is just move. Pipeline walking marked interfaces, with Feature adapters only classifying members, concentrates. Templating per-Feature DiagnosticDescriptors was already rejected (docs/design/shproj-dedup-plan.md).

Keep Events off this pipeline (call-site driven; real seam). Keep RestAPI on CreateSyntaxProvider (C2).


Item 6 — Collapse *Service.For factories

Module: Shared (implementation); public interface stays Observables.<Feature>.*Service.For<T>
Strength: Worth exploring · in-process

Files: HubService.cs, SseService.cs, RedisService.cs, MqttService.cs, GrpcService.cs, NatsService.cs, PostgresService.cs, WebSocketService.cs (~2.6KB each). RestAPI RestService is not the same shape (two-arg factory, reflection fallback).

Problem. Eight modules whose interface is the implementation (RegisterGeneratedFactory + ConcurrentDictionary + For<T> + AOT attributes).

Deletion test. Deleting one *Service reappears at user call sites — each Feature interface earns its keep. The clone of the implementation does not. Eight adapters would justify a Core factory seam.

Callout. ROADMAP D1 deferred filling Observables.Core. #212 listed Core as out of scope (“empty project is not a user-facing bug”). Grill before reopening D1; do not silently put types in Core.


Decisions so far

Not yet specified

Out of scope

  • New Features (RabbitMQ, AMQP 1.0, Diagnostics un-park) — ADR-002.
  • Third reactive backend — ADR-001.
  • RestAPI onto IoProxyGeneratorPipeline / ForAttributeWithMetadataName — C2.
  • Shared subscribe pumpDecide shared subscribe-pump module vs per-Feature Reactive fixes #215 unless superseded.
  • Docs/Samples companion repos — file there only if a child changes user-facing diagnostics or samples.
  • Events onto the IO stub generator pipeline — call-site seam is intentional.

Acceptance (map)

  • Each Strong item has a child issue in one module (or an explicit wontfix / ADR).
  • Worth exploring items are children, parked with a reason, or declined.
  • This map’s Decisions so far lists those children (or ADRs).
  • No child PR mixes Shared with a Feature, or two Features, in one change.

Metadata

Metadata

Assignees

No one assigned

    Labels

    wayfinder:mapWayfinder map (canonical artifact)

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions