Skip to content

Telemetry: service skeleton + slice 1 (TelemetryPolicyConfigured config-as-events) - #42

Merged
erikshafer merged 5 commits into
mainfrom
telemetry/skeleton-and-slice-1-config
Jul 10, 2026
Merged

Telemetry: service skeleton + slice 1 (TelemetryPolicyConfigured config-as-events)#42
erikshafer merged 5 commits into
mainfrom
telemetry/skeleton-and-slice-1-config

Conversation

@erikshafer

Copy link
Copy Markdown
Owner

Opening PR of the W006 Telemetry transport chain. Stands up CritterCab.Telemetry as the repo's second service and realizes W006 slice 1 (TelemetryPolicyConfigured config-as-events) in one PR, per the named skeleton-plus-first-slice cadence exception (mirrors the Dispatch skeleton + slice-5.1 precedent).

Config-as-events is the dependency-correct first slice: slice 2's gRPC ingest reads this slice's TelemetryPolicy view (throttlePolicyVersion, h3Resolution, intervals). So this opening PR of the "first real transport build" deliberately lands no transport — that's the correct dependency order, not an omission.

What landed

Skeleton — new Microsoft.NET.Sdk.Web service mirroring Dispatch (own crittercab_telemetry DB, ports 5315/5316), paired Alba test project, apphost + slnx wiring. Kafka deliberately not wired into apphost.cs — transport lands with the slice that needs it (slice 3), the same deferral the Dispatch skeleton made.

Slice 1 (TelemetryPolicy/ feature folder)

  • TelemetryPolicyConfigured singleton event stream (full-replacement, ADR-011)
  • ConfigureTelemetryPolicy command + nested AbstractValidator (boundary validation: intervals positive, heartbeat ≥ minPublish, h3Resolution in 0–15)
  • TelemetryPolicy self-aggregating live-stream view exposing throttlePolicyVersion as a long (the Marten stream version, matching the proto int64)
  • IInitialData migration-time idempotent bootstrap seed (defaults 9/30/5, operatorId=system-bootstrap) per ADR-011 Option A
  • 3 Alba GWTs (bootstrap / reconfigure / reject) + a DB-less smoke test

Two firsts in code

  • First config-as-events instance — ADR-011's third instance overall; Dispatch and Onboarding were design-only. Triggers ADR-011's deferred migration-template skill → registered as a DEBT.md row (not authored here).
  • First FluentValidation boundary validation — introduces WolverineFx.Http.FluentValidation.

Verification

  • Four jasperfx-source-verifier gates ran before wiring: IInitialData seed vehicle, long stream-version property, self-aggregating LiveStreamAggregation registration + Marten-9 partial scope, Wolverine.HTTP FluentValidation middleware. All confirmed at build.
  • The gRPC client-streaming re-verification found the local JasperFx checkout stale at V5.37.2 (no 6.1x/6.17 source) — hand-wire verdict holds through 5.37.2, 6.17 unverified. This is a slice-2 (PR B) concern and gated nothing here.
  • dotnet build green; smoke test green locally. Local Docker's container-start path was wedged all session (the handoff-flagged condition), so the 3 slice-1 Testcontainers tests are gated on CI here rather than run locally.

No ADR fires — W006 §11's three candidates are all later-arc (Kafka topic-naming → slice 3; stream-processing-4th-shape → slices 2–4; windowed client-streaming → slice 2). No narrative anchor (PR #40: the narrative layer does not apply to Telemetry).

Prompt + retro: docs/{prompts,retrospectives}/implementations/006-telemetry-skeleton-and-slice-1-config.md.

Follow-on PRs

  • B — slice 4 (LastKnownPosition + eviction) + slice 2 (gRPC ingest, hand-wired against IMessageBus)
  • C — slice 3 (DriverLocationUpdated → Kafka; wires Kafka into apphost)
  • D — slice 5 (Dispatch consumer, Kafka half — replaces NearbyAvailableDriversStub)

…ig-as-events)

Stands up CritterCab.Telemetry as the repo's second service and realizes W006
slice 1 in one PR (skeleton + first-slice cadence exception; mirrors the Dispatch
skeleton + slice-5.1 precedent). Opening PR of the W006 transport chain — config-
as-events is the dependency-correct first slice (slice 2's gRPC ingest reads this
slice's TelemetryPolicy view), and it lands no transport.

- TelemetryPolicyConfigured singleton event stream (full-replacement, ADR-011)
- ConfigureTelemetryPolicy command + nested AbstractValidator (boundary validation)
- TelemetryPolicy self-aggregating live-stream view; throttlePolicyVersion as long
  (the Marten stream version, matching the proto int64)
- IInitialData migration-time idempotent bootstrap seed (defaults 9/30/5,
  operatorId=system-bootstrap) per ADR-011 Option A
- 3 Alba GWTs (bootstrap / reconfigure / reject) + DB-less smoke test
- apphost: Telemetry + crittercab_telemetry DB (5315/5316); Kafka deliberately
  deferred to slice 3

First config-as-events instance in code (ADR-011's third instance; Dispatch and
Onboarding were design-only) and first FluentValidation use. Four jasperfx-source-
verifier gates ran before wiring. No ADR fired (W006 §11 candidates are later-arc).

Prompt + retro under docs/{prompts,retrospectives}/implementations/006-*. DEBT row
registered for the config-as-events bootstrap-seed skill (ADR-011's deferred follow-up).
…liation

The critter-skill-auditor Phase 2 pass caught a design-vs-implementation gap:
W006 §6.1 locks the seed as "ADR-011 Option A (migration-time seed)", but the
Marten IInitialData realization runs at host startup — structurally Option B, the
option ADR-011 rejected over a multi-instance seed race. IInitialData does serve
Option A's intent under the JasperFx `resources setup` deploy step, but also self-
seeds at host start; the race is benign here (idempotent guard + full-replacement)
and irrelevant at single-instance MVP scale.

Corrects artifacts to stop bare-claiming "Option A" and registers the reconciliation
(plus the write-path optimistic-concurrency question — Append with no expected version,
because [WriteAggregate] has no binding to a constant singleton id) as questions the
future canonical-pattern skill must resolve. The ADR-011 amendment itself is a
user-owned decision, not made here.

- TelemetryPolicyBootstrap.cs / Program.cs: accurate seed comments + reconciliation note
- W006 Document History: open-reconciliation note
- DEBT.md: two design questions the reference impl must resolve before enshrining
- retro: "design meets code" section capturing the finding
…est)

CI caught it: the reject GWT got 200 instead of 400 — the boundary validator
never ran. UseFluentValidationProblemDetailMiddleware resolves IValidator<T> from
DI, but nothing registered the validators. Add opts.UseFluentValidation()
(WolverineFx.FluentValidation) which discovers and registers all IValidator<>
in the app assembly — the idiomatic pairing the Wolverine HTTP sample uses, and it
auto-discovers future validators rather than needing a per-validator registration.

Local build green; the reject test's 400 is verified on CI (local Docker wedged).
…wiring DEBT

Records the confirmed CI outcome (run 29119259592: Telemetry 4/4 + Dispatch 11/11)
and the CI-caught validator-registration bug as a methodology lesson (a Docker-wedged
session's DB-less smoke test gives false confidence about paths it never touches).
Registers the Wolverine.HTTP FluentValidation two-call wiring as a wolverine-http-handlers
skill gap.
@erikshafer erikshafer self-assigned this Jul 10, 2026
… LWW

Resolves the Option-A/B-for-Marten gap the slice-1 Phase-2 audit surfaced (user-
directed, in-PR). Amends ADR-011 with a 2026-07-10 section recording that IInitialData
(via .InitializeWith<T>()) is the canonical Marten realization of Option A — it seeds at
the deploy-time apply step and idempotently at host start as a self-healing safety net;
the multi-instance race is mitigated by the idempotent guard + full-replacement and
avoided by the deploy step / single-instance MVP. Also records last-writer-wins as the
accepted config-singleton write semantic (full-replacement has no invariant to defend;
optimistic concurrency not required; manual constant-id append since [WriteAggregate]
has no id field to bind).

Cascades the "resolved" status through DEBT (skill now unblocked), W006 history, the
retro (design-meets-code + spec-delta + outstanding items, documenting the mid-flight
user-directed expansion), both README indexes, and the two seed code comments. Prompt
left as authored (historical record), not retroactively re-scoped.
@erikshafer
erikshafer merged commit 4d288bd into main Jul 10, 2026
1 check passed
@erikshafer
erikshafer deleted the telemetry/skeleton-and-slice-1-config branch July 10, 2026 20:32
erikshafer added a commit that referenced this pull request Jul 10, 2026
…nsport build

Introduces three handoff notes tracking Telemetry chain progression from PR #39 (protos) through PR #42 (skeleton + slice 1). Post-PR-42 handoff orients next session on PR B: slices 4+2 paired (LastKnownPosition store + ReportLocations gRPC ingest), CritterCab's first gRPC surface in code and first Kafka testcontainer. Documents verified WolverineFx.Grpc client-streaming gap (must hand-wire against IMessageBus through 5.37.2; 6.17 unconfirmed), local Docker wedged state (lean on CI), and ADR-011 amendment locked (IInitialData bootstrap + last-writer-wins singletons). Supersedes three earlier untracked handoffs after PR #40 (narrative decision—does not apply, recorded) and PR #41 (package refresh to 6.17.0) both merged. Carries forward design-locked guardrails, three W006 §11 ADR candidates, and open skill-debt items. Includes narrative-decision and post-PR-41 intermediate handoffs for session lineage; all disposable once next session orients.
erikshafer added a commit that referenced this pull request Jul 24, 2026
…unk C)

W006 §6.2. CritterCab's first gRPC surface that serves traffic, and the
client-streaming shape that was blocked for three months until WolverineFx.Grpc
6.21.0 shipped auto-codegen for it. Nothing here is hand-wired.

TelemetryGrpcService is an empty abstract [WolverineGrpcService] deriving the
generated TelemetryServiceBase. Two codegen layers stack: protoc emits the
virtual ReportLocations over a raw IAsyncStreamReader, then Wolverine emits the
override that adapts it to IAsyncEnumerable and forwards to
IMessageBus.StreamAsync. ReportLocationsHandler therefore never sees a gRPC
type — the whole stream is simply the message.

The per-ping pipeline lives inside the handler, which is forced rather than
stylistic: Wolverine cannot weave Before/Validate frames for client-streaming
because a before-frame needs a concrete request at method entry and a stream
cannot supply one. So unlike slice 1, which validates at the HTTP boundary with
FluentValidation, validation and the publish trigger are handler concerns here.

Trigger, per §6.2: shouldPublish = heartbeatDue OR (cellChanged AND
throttleFloorElapsed), evaluated against slice 4's document. Policy and baseline
are read once at window open and the baseline kept current in memory — safe
because within a window the driver's own pings are the sole writer of their own
document. An absent baseline publishes immediately, which is what closes §6.4's
Return case. On publish it goes publish-first, then store: that ordering fails
benignly (a duplicate the consumer's dedup absorbs) where the reverse fails as a
missed cell change. Invalid pings are silently dropped, never errored.

Seams, both ready-to-swap rather than final:
- IDriverPrincipalAccessor resolves driverId from the principal, never the
  payload (R5) — which is why the proto has no driver_id field. The dev stub
  reads x-driver-id; gRPC metadata travels as HTTP/2 headers so it arrives
  there. Documented swap site for a real Entra claim.
- IDriverLocationPublisher carries the generated DriverLocationUpdated — the
  real published language, so PR C swaps only the implementation, never the
  contract. driver_location_updated.proto joins codegen for this
  (GrpcServices="None", message-only). No Kafka is wired.

H3CellIndexer wraps one line because that line has a footgun on both axes: H3's
LatLng is (lat, lon) in radians while NTS Coordinate is (lon, lat) in degrees.
The degrees-native Coordinate path removes the unit conversion entirely. The
pinning test computes the same point down both paths — opposite on order AND
units, so they can only agree if production has both right — and separately
proves a swapped Coordinate yields a different cell, since "is a valid cell"
passes happily for the wrong place on Earth.

apphost.cs did not compile, and had not since PR #42 added the Telemetry service
block without its #:project directive; CI never builds the apphost so nothing
caught it. Fixed here because deliverable 3 cannot land on a file that does not
build. gRPC rides the existing HTTPS endpoint over HTTP/2, matching the
convention the Dispatch block already documents, so it needs no port of its own.

Tests: 31/31 green. Five drive a real gRPC client stream against the Alba host —
gate 11 resolves cleanly, since Alba wraps WebApplicationFactory over TestServer,
so GetTestServer().CreateHandler() reaches the same in-memory transport and
CritterCab keeps its Alba-first default with no parallel host recipe. Covers
Happy publish, Throttled, Heartbeat, Window close and unauthenticated, plus the
§6.4 No-write GWT that slice 4 could not reach alone because deciding not to
write is this slice's trigger.
erikshafer added a commit that referenced this pull request Jul 24, 2026
…portLocations ingest (#45)

* docs: prompt 007 — telemetry slices 4+2 (LastKnownPosition + gRPC ingest)

Session prompt for PR B of the W006 Telemetry chain: slice 4
(LastKnownPosition store + eviction) and slice 2 (gRPC ReportLocations
client-streaming ingest).

Includes the 2026-07-21 Verify-before-wiring source-verification pass and
the 2026-07-24 consistency alignment:

- fork 3 and gate 8a now name the NTS Coordinate-degrees H3 recipe; the
  H3Index.FromLatLng tuple path is marked as the radians trap it is
- gate 8b closed: package id is pocketken.H3 4.5.0.1, ships a native
  lib/net10.0, pulls NetTopologySuite 2.6.0 transitively
- deliverables 3 and 6 carry gate 5's BackgroundService +
  EvictStalePositions handler split (Wolverine has no first-class
  recurring primitive) and gate 6's HardDeleteWhere refinement
- deliverable 1 pins the bump to 6.21.0 (the source-verified V6.21.0-12
  baseline, not the newer 6.22.0) and makes it lockstep across all 11
  WolverineFx entries

The previous subject on this commit claimed to implement slices 4 and 2;
it only ever contained this prompt document.

* Telemetry: WolverineFx 6.21.0 lockstep + proto-first gRPC codegen (chunk A)

First codegen-behind-a-proto in the repo. Chunk A of prompt 007 — the
version bump and codegen wiring that slices 4 and 2 build on.

Directory.Packages.props
- all 11 WolverineFx* entries 6.19.0 -> 6.21.0 in lockstep. Only WolverineFx
  and WolverineFx.Grpc strictly need it (6.21.0 is the release that added
  client-streaming auto-codegen), but bumping the pair alone would leave the
  other nine as a version island.
- 6.21.0 rather than the newer 6.22.0: 6.21.0 is what prompt 007's
  Verify-before-wiring citations were source-verified against
  (local checkout V6.21.0-12), so the gate line numbers keep matching the
  binaries we build on. 6.22.0 is a later tidy: packages bump.
- new Grpc.AspNetCore / Grpc.Tools / Google.Protobuf entries, pinned to
  exactly what WolverineFx.Grpc 6.21.0 declares (2.76.0 / 2.76.0 / 3.31.1)
  so the codegen toolchain matches Wolverine's own tested pairing.

CritterCab.Telemetry.csproj
- adds WolverineFx.Grpc plus the three gRPC packages.
- adds the <Protobuf> item compiling report_locations.proto in from the
  repo-root protos/ tree. The proto stays there because the contract is the
  artifact (ADR-009) and is shared across services; ProtoRoot points at that
  tree so the proto's package path resolves.

Verified: dotnet build emits TelemetryService.TelemetryServiceBase with

  Task<LocationIngestAck> ReportLocations(
      IAsyncStreamReader<LocationPing>, ServerCallContext)

and MethodType.ClientStreaming — the shape gate 1 predicted. Full solution
builds with 0 warnings; 15/15 tests pass locally against the new dependency
line (Marten 9.16.1, JasperFx 2.30.1), resolving gate 7.

* Telemetry: slice 4 — LastKnownPosition store + heartbeat-absence eviction (chunk B)

W006 §6.4 and §3.3. CritterCab's first non-event-sourced document write path
and first recurring/scheduled work in the repo.

LastKnownPosition — a plain Marten document, overwrite-in-place, defending no
invariant: LWW on ServerReceivedAt is the whole concurrency story. Ships the
minimal single-timestamp shape: ServerReceivedAt serves BOTH §6.2's
lastPublishedAt trigger baseline AND §6.4's eviction key, because §6.4 locks
upsert-on-publish-only, which makes them the same instant by construction. A
separate LastPublishedAt would have held a duplicate value and forced eviction
and the trigger to read different names for one moment. §3.3's DeviceTimestamp
/ Speed / Heading are omitted deliberately — slice 3 passes speed and heading
to Kafka straight from the ping, so the document never reads them. Field is
Lon, not §3.3's Lng: the proto is the contract (ADR-009).

Eviction is split, per the prompt's gate 5 — Wolverine has no first-class
recurring-message primitive, so the timer is a plain BackgroundService and only
the work is Wolverine's:

- LastKnownPositionEvictionService holds no logic and stays untested: it loops
  on Task.Delay, opens a scope, and calls InvokeAsync (not PublishAsync, so a
  slow sweep back-pressures its own timer rather than overlapping the next
  tick). It takes IServiceScopeFactory rather than IMessageBus because
  Wolverine registers IMessageBus scoped (HostBuilderExtensions.cs:232) and a
  BackgroundService is a singleton — injecting the bus directly fails DI
  scope validation at host build.
- EvictStalePositionsHandler holds everything testable: reads
  HeartbeatIntervalSeconds from the TelemetryPolicy view, derives the
  3-missed-heartbeats threshold (a documented constant, not a v1 policy
  param), and HardDeleteWhere's the stale rows. HardDeleteWhere rather than
  DeleteWhere so "the row is gone" survives a future soft-delete config —
  DeleteWhere would silently degrade to a flag and break the Return GWT. No
  staleness event is published (v1, R3/R8).

Registered inside Program.cs's Marten guard: without a store there is nothing
to sweep and the timer would only log failures.

Tests (6 new, 21/21 green): Upsert, Evict, threshold-respects-fresh-documents,
Return, and a proof the threshold is read from policy rather than hardcoded
(the same 10-second-old document survives under the seeded 30s heartbeat and is
swept under a reconfigured 1s one). The §6.4 "No-write" GWT is not here —
deciding not to write is slice 2's trigger, which lands in chunk C.

Also covers the production host wiring, which nothing else reaches: the fixture
host strips the timer for determinism and the smoke test runs without a
connection string, so the registration would otherwise be untested. Verified by
mutation that the guard actually fails when the bus is injected directly.

* Telemetry: slice 2 — gRPC ReportLocations client-streaming ingest (chunk C)

W006 §6.2. CritterCab's first gRPC surface that serves traffic, and the
client-streaming shape that was blocked for three months until WolverineFx.Grpc
6.21.0 shipped auto-codegen for it. Nothing here is hand-wired.

TelemetryGrpcService is an empty abstract [WolverineGrpcService] deriving the
generated TelemetryServiceBase. Two codegen layers stack: protoc emits the
virtual ReportLocations over a raw IAsyncStreamReader, then Wolverine emits the
override that adapts it to IAsyncEnumerable and forwards to
IMessageBus.StreamAsync. ReportLocationsHandler therefore never sees a gRPC
type — the whole stream is simply the message.

The per-ping pipeline lives inside the handler, which is forced rather than
stylistic: Wolverine cannot weave Before/Validate frames for client-streaming
because a before-frame needs a concrete request at method entry and a stream
cannot supply one. So unlike slice 1, which validates at the HTTP boundary with
FluentValidation, validation and the publish trigger are handler concerns here.

Trigger, per §6.2: shouldPublish = heartbeatDue OR (cellChanged AND
throttleFloorElapsed), evaluated against slice 4's document. Policy and baseline
are read once at window open and the baseline kept current in memory — safe
because within a window the driver's own pings are the sole writer of their own
document. An absent baseline publishes immediately, which is what closes §6.4's
Return case. On publish it goes publish-first, then store: that ordering fails
benignly (a duplicate the consumer's dedup absorbs) where the reverse fails as a
missed cell change. Invalid pings are silently dropped, never errored.

Seams, both ready-to-swap rather than final:
- IDriverPrincipalAccessor resolves driverId from the principal, never the
  payload (R5) — which is why the proto has no driver_id field. The dev stub
  reads x-driver-id; gRPC metadata travels as HTTP/2 headers so it arrives
  there. Documented swap site for a real Entra claim.
- IDriverLocationPublisher carries the generated DriverLocationUpdated — the
  real published language, so PR C swaps only the implementation, never the
  contract. driver_location_updated.proto joins codegen for this
  (GrpcServices="None", message-only). No Kafka is wired.

H3CellIndexer wraps one line because that line has a footgun on both axes: H3's
LatLng is (lat, lon) in radians while NTS Coordinate is (lon, lat) in degrees.
The degrees-native Coordinate path removes the unit conversion entirely. The
pinning test computes the same point down both paths — opposite on order AND
units, so they can only agree if production has both right — and separately
proves a swapped Coordinate yields a different cell, since "is a valid cell"
passes happily for the wrong place on Earth.

apphost.cs did not compile, and had not since PR #42 added the Telemetry service
block without its #:project directive; CI never builds the apphost so nothing
caught it. Fixed here because deliverable 3 cannot land on a file that does not
build. gRPC rides the existing HTTPS endpoint over HTTP/2, matching the
convention the Dispatch block already documents, so it needs no port of its own.

Tests: 31/31 green. Five drive a real gRPC client stream against the Alba host —
gate 11 resolves cleanly, since Alba wraps WebApplicationFactory over TestServer,
so GetTestServer().CreateHandler() reaches the same in-memory transport and
CritterCab keeps its Alba-first default with no parallel host recipe. Covers
Happy publish, Throttled, Heartbeat, Window close and unauthenticated, plus the
§6.4 No-write GWT that slice 4 could not reach alone because deciding not to
write is this slice's trigger.

* Telemetry: docs + ledger for slices 4 and 2 (chunk D)

Closes the session's documentation deliverables and retires the
client-streaming forward-constraint everywhere it was recorded.

report_locations.proto — the stale comment directing a hand-wire against
IMessageBus is corrected. This one mattered beyond tidiness: the proto's
leading comments become doc-comments in the generated ReportLocationsGrpc.cs,
so the obsolete instruction was being compiled into the build output. The
replacement also records the middleware caveat at contract level, since it is
a property of the RPC shape rather than of our implementation.

wolverine-grpc-handlers — corrected under the session-runner-blocking
exception: a session cannot follow a skill that tells it to hand-wire a shape
the library now generates. Seven stale claims fixed (frontmatter, intro,
why-not-code-first, the abstract-stub note, two pitfalls, see-also), and a new
Client-streaming handlers section added mirroring the server-streaming one.
It documents the empty stub, the two stacked codegen layers, the
IAsyncEnumerable handler shape, and three things the session had to discover
from source rather than docs: middleware does NOT weave and fails silently
rather than loudly; identity must come from an IHttpContextAccessor seam
because there is no [WolverineBefore] to read ServerCallContext from; and the
handler slot is keyed on the request type, so two client-streaming RPCs
sharing a request type collide.

wolverine-grpc-bidirectional-handlers — banner plus mental-model corrections
only, as scoped. The ~145-line hand-written-workaround body is left intact
under an explicit obsolete banner rather than half-rewritten; its removal is a
DEBT row. The mental-model table and the prose around it were corrected too,
because leaving "Rejected at startup" in the most-read section would have kept
the file contradicting shipped code regardless of the banner.

DEBT.md — six rows: the bidirectional rewrite; test-class naming across all
three Telemetry suites (a decision, not a cleanup, and explicitly not to be
fixed slice-locally); no skill for plain-document write paths; no skill for
recurring work; identity-acl's gRPC auth pattern not applying to streaming
shapes; and the feature-folder/type-name collision now on its third occurrence.

W006 Document History — slices 2 and 4 designed -> realized, the forward
constraint closed, and the §11 windowed-client-streaming candidate recorded as
firing into a skill rather than an ADR. It also records that MaxAccuracyMeters
= 100 was invented at implementation time, since §6.2 names the threshold but
fixes no value.

Retro — records what the session learned rather than what it did: that the
verification pass earned its cost by disconfirming its own prompt twice, that
both load-bearing tests were built to fail a naive implementation, that the
DI guard was mutation-verified, and that the apphost break survived two weeks
because CI's existing completeness guard cannot see a file-based app.

31/31 still green.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant