chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:18 +08:00
commit 6d5d58c1a9
18293 changed files with 3502153 additions and 0 deletions
@@ -0,0 +1,483 @@
// CvdiagEmitter.cs — the shared .NET CVDIAG emitter (plan unit L0-D; spec §6/§7).
//
// Mirrors the canonical TS `emit.ts` contract for the .NET integrations:
// • Tier resolution from the deployment environment, with the .NET-specific
// precedence SHOWCASE_ENV → RAILWAY_ENVIRONMENT_NAME → ASPNETCORE_ENVIRONMENT
// (the last is the .NET analogue of TS's NODE_ENV).
// • Fail-closed DEBUG: the constructor throws (startup assertion — the ONE
// place the emitter is permitted to throw) when DEBUG is requested in a
// production env, when no env resolves (unknown == production), or when no
// CVDIAG_DEBUG_ALLOW_LIST is provided.
// • §6 tier matrix: a boundary is emitted only if included at the current
// tier; `cvdiag.*` accounting boundaries are always emitted.
// • DEBUG auto-off: after 10 minutes OR 10k events, DEBUG disarms and falls
// back to default-tier inclusion.
// • Closed-world metadata filter: unknown metadata keys for the boundary are
// dropped and `_metadata_dropped` is stamped.
// • Edge-header allow/deny filter (9 allow / 12 deny, exact-match, deny wins,
// case-insensitive, no cf-ip* wildcard).
// • Per-event byte cap by tier; over-budget envelopes are trimmed +
// `_truncated` stamped.
// • EmitEvent → single-line JSON to stdout, PLUS a background fire-and-forget
// PocketBase write (HttpClient, ≤1s timeout) that never blocks or throws
// into the observed boundary.
//
// Pure instrumentation: outside the constructor's startup guard, a CVDIAG
// failure must NEVER throw into the caller (spec §7). All hot-path errors
// degrade to a stderr CVDIAG-tagged warning.
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Diagnostics;
using System.Security.Cryptography;
namespace Copilotkit.Showcase.Cvdiag;
/// <summary>Resolved verbosity tier (cumulative; spec §6).</summary>
public enum CvdiagTier { Default, Verbose, Debug }
/// <summary>Construction options for <see cref="CvdiagEmitter"/>.</summary>
public sealed class CvdiagEmitterOptions
{
/// <summary>Force DEBUG tier (subject to the fail-closed prod guard).</summary>
public bool Debug { get; init; }
/// <summary>Force VERBOSE tier (DEBUG wins if both set).</summary>
public bool Verbose { get; init; }
/// <summary>Environment bag; defaults to the process environment.</summary>
public IReadOnlyDictionary<string, string?>? Env { get; init; }
/// <summary>Owning layer for default envelope fields.</summary>
public CvdiagLayer Layer { get; init; } = CvdiagLayer.Backend;
/// <summary>PocketBase ingest URL; when null, no background write is attempted.</summary>
public string? PbWriteUrl { get; init; }
/// <summary>Injected HttpClient (tests); defaults to a shared 1s-timeout client.</summary>
public HttpClient? HttpClient { get; init; }
/// <summary>Emit the single-line JSON to stdout (default true).</summary>
public bool WriteToStdout { get; init; } = true;
}
/// <summary>Arguments for a single <see cref="CvdiagEmitter.Emit"/> call.</summary>
public sealed class CvdiagEmitArgs
{
public required CvdiagLayer Layer { get; init; }
public required CvdiagBoundary Boundary { get; init; }
public required string Slug { get; init; }
public required string Demo { get; init; }
public required CvdiagOutcome Outcome { get; init; }
public EdgeHeaders? EdgeHeaders { get; init; }
public Dictionary<string, object?>? Metadata { get; init; }
public long? DurationMs { get; init; }
public string? ParentSpanId { get; init; }
/// <summary>Override test_id (e.g. probe.start mints one and threads it).</summary>
public string? TestId { get; init; }
}
public sealed class CvdiagEmitter
{
// §6 hard bounds + §7 byte caps (mirror emit.ts constants).
private const long DebugMaxWallclockMs = 10 * 60 * 1000;
private const int DebugMaxEvents = 10_000;
private static readonly IReadOnlyDictionary<CvdiagTier, int> ByteCapByTier =
new Dictionary<CvdiagTier, int>
{
[CvdiagTier.Default] = 2 * 1024,
[CvdiagTier.Verbose] = 4 * 1024,
[CvdiagTier.Debug] = 16 * 1024,
};
private const string AccountingPrefix = "cvdiag.";
// The 12-name DENY list (spec §5). Exact-match, lowercase, deny wins; the
// cf-ip* family is blocked by these explicit entries, NOT a wildcard.
private static readonly HashSet<string> DenyList = new(StringComparer.Ordinal)
{
"cf-ipcountry", "cf-connecting-ip", "cf-ipcity", "cf-iplatitude",
"cf-iplongitude", "cf-iptimezone", "cf-visitor", "cf-worker",
"true-client-ip", "x-forwarded-for", "x-real-ip", "forwarded",
};
private static readonly HashSet<string> AllowList =
new(CvdiagSchema.EdgeHeaderKeys, StringComparer.Ordinal);
// §6 tier matrix: per data-plane boundary, included-at-tier flags.
private static readonly IReadOnlyDictionary<string, (bool Default, bool Verbose, bool Debug)> TierMatrix =
new Dictionary<string, (bool, bool, bool)>
{
["probe.start"] = (false, true, true),
["probe.navigate.complete"] = (false, true, true),
["probe.message.send"] = (true, true, true),
["probe.dom.container.mount"] = (true, true, true),
["probe.dom.firsttoken"] = (true, true, true),
["probe.dom.alternate_content"] = (true, true, true),
["probe.sse.event"] = (false, true, true),
["probe.sse.aborted"] = (true, true, true),
["probe.network.error"] = (true, true, true),
["probe.network.response"] = (true, true, true),
["probe.console.error"] = (true, true, true),
["probe.exit"] = (true, true, true),
["backend.request.ingress"] = (false, true, true),
["backend.agent.enter"] = (true, true, true),
["backend.llm.call.start"] = (false, true, true),
["backend.llm.call.heartbeat"] = (false, true, true),
["backend.llm.call.response"] = (false, true, true),
["backend.sse.first_byte"] = (false, true, true),
["backend.sse.event"] = (false, false, true),
["backend.sse.aborted"] = (true, true, true),
["backend.agent.exit"] = (true, true, true),
["backend.response.complete"] = (true, true, true),
["backend.error.caught"] = (true, true, true),
["aimock.request.ingress"] = (false, true, true),
["aimock.match.decision"] = (false, true, true),
["aimock.response.start"] = (false, true, true),
["aimock.sse.chunk"] = (false, false, true),
["aimock.response.aborted"] = (true, true, true),
["aimock.response.complete"] = (true, true, true),
};
private static readonly HttpClient SharedHttpClient =
new() { Timeout = TimeSpan.FromSeconds(1) };
private readonly IReadOnlyDictionary<string, string?> _env;
private readonly CvdiagLayer _defaultLayer;
private readonly string? _pbWriteUrl;
private readonly HttpClient _httpClient;
private readonly bool _writeToStdout;
private readonly long _debugDeadlineMs;
private readonly Stopwatch _mono = Stopwatch.StartNew();
private long _debugEventCount;
private bool _debugDisarmed;
public CvdiagTier Tier { get; }
public CvdiagEmitter(CvdiagEmitterOptions? options = null)
{
options ??= new CvdiagEmitterOptions();
_env = options.Env ?? ReadProcessEnv();
_defaultLayer = options.Layer;
_pbWriteUrl = options.PbWriteUrl;
_httpClient = options.HttpClient ?? SharedHttpClient;
_writeToStdout = options.WriteToStdout;
var wantsDebug = options.Debug || _env.GetValueOrDefault("CVDIAG_DEBUG") == "1";
var wantsVerbose = options.Verbose || _env.GetValueOrDefault("CVDIAG_VERBOSE") == "1";
if (wantsDebug)
{
AssertDebugAllowed();
Tier = CvdiagTier.Debug;
_debugDeadlineMs = NowMs() + DebugMaxWallclockMs;
}
else
{
Tier = wantsVerbose ? CvdiagTier.Verbose : CvdiagTier.Default;
}
}
/// <summary>
/// Resolve the deployment-environment label (spec §6 production detection):
/// SHOWCASE_ENV → RAILWAY_ENVIRONMENT_NAME → ASPNETCORE_ENVIRONMENT.
/// Returns the lowercase label, or null if none resolves.
/// </summary>
public static string? ResolveEnvLabel(IReadOnlyDictionary<string, string?> env)
{
var raw = env.GetValueOrDefault("SHOWCASE_ENV")
?? env.GetValueOrDefault("RAILWAY_ENVIRONMENT_NAME")
?? env.GetValueOrDefault("ASPNETCORE_ENVIRONMENT");
return string.IsNullOrEmpty(raw) ? null : raw.ToLowerInvariant();
}
/// <summary>
/// DEBUG startup assertion (spec §6 hard bounds). Throws (fail-closed) when
/// the env is production, unresolved (unknown == production), or no
/// allow-list is provided. The ONE place the emitter may throw.
/// </summary>
private void AssertDebugAllowed()
{
var label = ResolveEnvLabel(_env);
if (label is null)
{
throw new InvalidOperationException(
"CVDIAG_DEBUG refused: deployment environment is unresolved " +
"(SHOWCASE_ENV → RAILWAY_ENVIRONMENT_NAME → ASPNETCORE_ENVIRONMENT all unset); " +
"fail-closed treats unknown env as production.");
}
if (label == "production")
{
throw new InvalidOperationException(
"CVDIAG_DEBUG refused: deployment environment is production.");
}
var allowList = _env.GetValueOrDefault("CVDIAG_DEBUG_ALLOW_LIST");
if (string.IsNullOrWhiteSpace(allowList))
{
throw new InvalidOperationException(
"CVDIAG_DEBUG refused: CVDIAG_DEBUG_ALLOW_LIST is required " +
"(comma-separated slug list) before DEBUG may start.");
}
}
/// <summary>True iff the boundary is included at the current tier.</summary>
public bool ShouldEmit(CvdiagBoundary boundary)
{
var wire = CvdiagSchema.WireValue(boundary);
if (wire.StartsWith(AccountingPrefix, StringComparison.Ordinal))
{
return true; // accounting events always emit
}
if (Tier == CvdiagTier.Debug && IsDebugExpired())
{
return TierMatrix.TryGetValue(wire, out var fb) && fb.Default;
}
if (!TierMatrix.TryGetValue(wire, out var row)) return false;
return Tier switch
{
CvdiagTier.Default => row.Default,
CvdiagTier.Verbose => row.Verbose,
CvdiagTier.Debug => row.Debug,
_ => false,
};
}
private bool IsDebugExpired()
{
if (_debugDisarmed) return true;
if (NowMs() >= _debugDeadlineMs || _debugEventCount >= DebugMaxEvents)
{
_debugDisarmed = true;
return true;
}
return false;
}
/// <summary>
/// Emit one event: tier-filter, mint ids, closed-world metadata filter,
/// byte-cap, then EmitEvent (stdout + background PB write). Returns the
/// built envelope, or null when filtered out / on failure. Never throws.
/// </summary>
public CvdiagEnvelope? Emit(CvdiagEmitArgs args)
{
try
{
if (!ShouldEmit(args.Boundary)) return null;
if (Tier == CvdiagTier.Debug) _debugEventCount++;
var wire = CvdiagSchema.WireValue(args.Boundary);
var isDataPlane = !wire.StartsWith(AccountingPrefix, StringComparison.Ordinal);
var metadata = new Dictionary<string, object?>();
var metadataDropped = false;
if (isDataPlane)
{
(metadata, metadataDropped) = FilterMetadata(wire, args.Metadata);
}
else if (args.Metadata is not null)
{
// Accounting events ride their payload verbatim (trusted internal).
metadata = new Dictionary<string, object?>(args.Metadata);
}
var testId = args.TestId ?? MintTestId();
var envelope = new CvdiagEnvelope
{
SchemaVersion = CvdiagSchema.SchemaVersion,
TestId = testId,
TraceId = testId,
SpanId = MintSpanId(),
ParentSpanId = args.ParentSpanId,
Layer = args.Layer,
Boundary = args.Boundary,
Slug = args.Slug,
Demo = args.Demo,
Ts = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ"),
MonoNs = MonoNs(),
DurationMs = args.DurationMs,
Outcome = args.Outcome,
EdgeHeaders = args.EdgeHeaders ?? new EdgeHeaders(),
Metadata = metadata,
MetadataDropped = metadataDropped,
};
ApplyByteCap(envelope);
EmitEvent(envelope);
return envelope;
}
catch (Exception ex)
{
Console.Error.WriteLine($"CVDIAG emit failed boundary={args.Boundary} error={ex.Message}");
return null;
}
}
/// <summary>
/// EmitEvent: write the single-line JSON to stdout AND kick off a
/// fire-and-forget background PB write (≤1s). Never blocks the caller;
/// background failures are swallowed to a stderr warning.
/// </summary>
public void EmitEvent(CvdiagEnvelope envelope)
{
string json;
try
{
json = JsonSerializer.Serialize(envelope, CvdiagJsonContext.Default.CvdiagEnvelope);
}
catch (Exception ex)
{
Console.Error.WriteLine($"CVDIAG serialize failed error={ex.Message}");
return;
}
if (_writeToStdout) Console.Out.WriteLine(json);
if (string.IsNullOrEmpty(_pbWriteUrl)) return;
// Fire-and-forget: do not await, do not throw into the boundary.
_ = BackgroundWriteAsync(json);
}
private async Task BackgroundWriteAsync(string json)
{
try
{
using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1));
await _httpClient.PostAsync(_pbWriteUrl, content, cts.Token).ConfigureAwait(false);
}
catch (Exception ex)
{
Console.Error.WriteLine($"CVDIAG pb-write failed error={ex.Message}");
}
}
/// <summary>
/// Closed-world metadata filter: keep only the boundary's schema keys; drop
/// the rest and flag <c>_metadata_dropped</c> (spec §6). If the boundary has
/// no key set (shouldn't happen for data-plane), keep nothing and flag drop.
/// </summary>
private static (Dictionary<string, object?> Metadata, bool Dropped) FilterMetadata(
string wireBoundary, Dictionary<string, object?>? raw)
{
var result = new Dictionary<string, object?>();
if (raw is null || raw.Count == 0) return (result, false);
if (!CvdiagSchema.BoundaryMetadataKeys.TryGetValue(wireBoundary, out var allowed))
{
return (result, true);
}
var allowedSet = new HashSet<string>(allowed, StringComparer.Ordinal);
var dropped = false;
foreach (var (key, value) in raw)
{
if (allowedSet.Contains(key)) result[key] = value;
else dropped = true;
}
return (result, dropped);
}
/// <summary>
/// Filter a raw header bag to the closed 9-key <see cref="EdgeHeaders"/>:
/// deny-list rejected first (deny wins, case-insensitive); non-allow keys
/// dropped; every result carries all 9 keys (absent → null). No cf-ip*
/// wildcard — only exact deny-list entries.
/// </summary>
public static EdgeHeaders FilterEdgeHeaders(IReadOnlyDictionary<string, string?> raw)
{
var kept = new Dictionary<string, string?>(StringComparer.Ordinal);
foreach (var (rawKey, rawValue) in raw)
{
var key = rawKey.ToLowerInvariant();
if (DenyList.Contains(key)) continue; // deny wins
if (!AllowList.Contains(key)) continue; // closed-world
kept[key] = rawValue;
}
string? Get(string k) => kept.TryGetValue(k, out var v) ? v : null;
return new EdgeHeaders
{
CfRay = Get("cf-ray"),
CfMitigated = Get("cf-mitigated"),
CfCacheStatus = Get("cf-cache-status"),
XRailwayEdge = Get("x-railway-edge"),
XRailwayRequestId = Get("x-railway-request-id"),
XHikariTrace = Get("x-hikari-trace"),
RetryAfter = Get("retry-after"),
Via = Get("via"),
Server = Get("server"),
};
}
/// <summary>
/// Trim over-budget envelopes to the tier byte cap and stamp
/// <c>_truncated</c> (spec §7). Metadata string values are trimmed first.
/// </summary>
private void ApplyByteCap(CvdiagEnvelope envelope)
{
var cap = ByteCapByTier[Tier];
if (SerializedSize(envelope) <= cap) return;
envelope.Truncated = true;
foreach (var key in new List<string>(envelope.Metadata.Keys))
{
if (SerializedSize(envelope) <= cap) break;
var value = envelope.Metadata[key];
if (value is string s && s.Length > 64)
{
envelope.Metadata[key] = s[..61] + "...";
}
else if (value is not null && value is not (int or long or double or bool))
{
envelope.Metadata[key] = "[truncated]";
}
}
}
private static int SerializedSize(CvdiagEnvelope envelope)
{
try
{
var json = JsonSerializer.Serialize(envelope, CvdiagJsonContext.Default.CvdiagEnvelope);
return Encoding.UTF8.GetByteCount(json);
}
catch
{
return int.MaxValue;
}
}
/// <summary>
/// Mint a UUIDv7 (RFC 9562): 48-bit Unix-ms timestamp, version nibble 7,
/// variant bits 10. Lowercase hyphenated. Matches the TS mintTestId().
/// </summary>
public static string MintTestId(long? nowMs = null)
{
var bytes = new byte[16];
RandomNumberGenerator.Fill(bytes);
var ts = (ulong)(nowMs ?? NowMs());
bytes[0] = (byte)((ts >> 40) & 0xff);
bytes[1] = (byte)((ts >> 32) & 0xff);
bytes[2] = (byte)((ts >> 24) & 0xff);
bytes[3] = (byte)((ts >> 16) & 0xff);
bytes[4] = (byte)((ts >> 8) & 0xff);
bytes[5] = (byte)(ts & 0xff);
bytes[6] = (byte)((bytes[6] & 0x0f) | 0x70); // version 7
bytes[8] = (byte)((bytes[8] & 0x3f) | 0x80); // variant 10
var hex = Convert.ToHexString(bytes).ToLowerInvariant();
return $"{hex[..8]}-{hex[8..12]}-{hex[12..16]}-{hex[16..20]}-{hex[20..32]}";
}
/// <summary>Mint a 16-hex span id (8 random bytes).</summary>
public static string MintSpanId()
{
var bytes = new byte[8];
RandomNumberGenerator.Fill(bytes);
return Convert.ToHexString(bytes).ToLowerInvariant();
}
private long MonoNs() => (long)(_mono.Elapsed.TotalMilliseconds * 1e6);
private static long NowMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
private static IReadOnlyDictionary<string, string?> ReadProcessEnv()
{
var dict = new Dictionary<string, string?>(StringComparer.Ordinal);
foreach (System.Collections.DictionaryEntry e in Environment.GetEnvironmentVariables())
{
if (e.Key is string k) dict[k] = e.Value as string;
}
return dict;
}
}
@@ -0,0 +1,324 @@
// <auto-generated>
// CvdiagSchema.cs — C# binding for the CVDIAG flap-observability envelope.
// CODEGEN'd from the single IR `showcase/harness/src/cvdiag/schema.json`
// ($id https://copilotkit.ai/schemas/cvdiag/v1.json, schema_version 1).
// Plan unit L0-D; spec §5. Mirrors the canonical TS `schema.ts` / Pydantic /
// Java bindings so all five languages share one wire contract.
//
// Source of truth: schema.json. To regenerate, re-run the .NET codegen step
// (see L1-F build-context note in the slot report) — DO NOT hand-edit the
// enum members / record fields below; edit schema.ts (→ schema.json) and
// re-emit. The 3 enums, the EdgeHeaders record, the CvdiagEnvelope record,
// the 29 per-boundary metadata records, and the closed key tables below are
// derived 1:1 from schema.json `$defs`.
//
// System.Text.Json source-generation: `CvdiagJsonContext` carries the
// [JsonSerializable] attributes so serialization is AOT/trim-safe and never
// uses reflection-based serializers at runtime.
// </auto-generated>
#nullable enable
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Copilotkit.Showcase.Cvdiag;
/// <summary>Emitting layer (schema.json `$defs.layers`).</summary>
[JsonConverter(typeof(JsonStringEnumConverter<CvdiagLayer>))]
public enum CvdiagLayer
{
[JsonStringEnumMemberName("probe")] Probe,
[JsonStringEnumMemberName("backend")] Backend,
[JsonStringEnumMemberName("aimock")] Aimock,
}
/// <summary>Event outcome (schema.json `$defs.outcomes`).</summary>
[JsonConverter(typeof(JsonStringEnumConverter<CvdiagOutcome>))]
public enum CvdiagOutcome
{
[JsonStringEnumMemberName("ok")] Ok,
[JsonStringEnumMemberName("err")] Err,
[JsonStringEnumMemberName("timeout")] Timeout,
[JsonStringEnumMemberName("info")] Info,
}
/// <summary>
/// Closed boundary enum (schema.json `$defs.boundaries`, 33 members). The
/// dotted wire values (e.g. "backend.response.complete") are carried by the
/// [JsonStringEnumMemberName] attributes; the C# member names are the
/// PascalCase'd, dot-stripped equivalents.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<CvdiagBoundary>))]
public enum CvdiagBoundary
{
[JsonStringEnumMemberName("probe.start")] ProbeStart,
[JsonStringEnumMemberName("probe.navigate.complete")] ProbeNavigateComplete,
[JsonStringEnumMemberName("probe.message.send")] ProbeMessageSend,
[JsonStringEnumMemberName("probe.dom.container.mount")] ProbeDomContainerMount,
[JsonStringEnumMemberName("probe.dom.firsttoken")] ProbeDomFirsttoken,
[JsonStringEnumMemberName("probe.dom.alternate_content")] ProbeDomAlternateContent,
[JsonStringEnumMemberName("probe.sse.event")] ProbeSseEvent,
[JsonStringEnumMemberName("probe.sse.aborted")] ProbeSseAborted,
[JsonStringEnumMemberName("probe.network.error")] ProbeNetworkError,
[JsonStringEnumMemberName("probe.network.response")] ProbeNetworkResponse,
[JsonStringEnumMemberName("probe.console.error")] ProbeConsoleError,
[JsonStringEnumMemberName("probe.exit")] ProbeExit,
[JsonStringEnumMemberName("backend.request.ingress")] BackendRequestIngress,
[JsonStringEnumMemberName("backend.agent.enter")] BackendAgentEnter,
[JsonStringEnumMemberName("backend.llm.call.start")] BackendLlmCallStart,
[JsonStringEnumMemberName("backend.llm.call.heartbeat")] BackendLlmCallHeartbeat,
[JsonStringEnumMemberName("backend.llm.call.response")] BackendLlmCallResponse,
[JsonStringEnumMemberName("backend.sse.first_byte")] BackendSseFirstByte,
[JsonStringEnumMemberName("backend.sse.event")] BackendSseEvent,
[JsonStringEnumMemberName("backend.sse.aborted")] BackendSseAborted,
[JsonStringEnumMemberName("backend.agent.exit")] BackendAgentExit,
[JsonStringEnumMemberName("backend.response.complete")] BackendResponseComplete,
[JsonStringEnumMemberName("backend.error.caught")] BackendErrorCaught,
[JsonStringEnumMemberName("aimock.request.ingress")] AimockRequestIngress,
[JsonStringEnumMemberName("aimock.match.decision")] AimockMatchDecision,
[JsonStringEnumMemberName("aimock.response.start")] AimockResponseStart,
[JsonStringEnumMemberName("aimock.sse.chunk")] AimockSseChunk,
[JsonStringEnumMemberName("aimock.response.aborted")] AimockResponseAborted,
[JsonStringEnumMemberName("aimock.response.complete")] AimockResponseComplete,
[JsonStringEnumMemberName("cvdiag.purge_audit")] CvdiagPurgeAudit,
[JsonStringEnumMemberName("cvdiag.collision_detected")] CvdiagCollisionDetected,
[JsonStringEnumMemberName("cvdiag.queue_dropped")] CvdiagQueueDropped,
[JsonStringEnumMemberName("cvdiag.metadata_dropped")] CvdiagMetadataDropped,
}
/// <summary>
/// The closed 9-key edge-header bag (schema.json `edge_headers`). Every field
/// is always present on the wire; an absent header serializes as null. The
/// hyphenated wire names are carried by [JsonPropertyName].
/// </summary>
public sealed record EdgeHeaders
{
[JsonPropertyName("cf-ray")] public string? CfRay { get; init; }
[JsonPropertyName("cf-mitigated")] public string? CfMitigated { get; init; }
[JsonPropertyName("cf-cache-status")] public string? CfCacheStatus { get; init; }
[JsonPropertyName("x-railway-edge")] public string? XRailwayEdge { get; init; }
[JsonPropertyName("x-railway-request-id")] public string? XRailwayRequestId { get; init; }
[JsonPropertyName("x-hikari-trace")] public string? XHikariTrace { get; init; }
[JsonPropertyName("retry-after")] public string? RetryAfter { get; init; }
[JsonPropertyName("via")] public string? Via { get; init; }
[JsonPropertyName("server")] public string? Server { get; init; }
/// <summary>Always 9 — the closed allow-list cardinality.</summary>
public int KeyCount() => 9;
/// <summary>All 9 field values (for deny-leakage assertions/inspection).</summary>
public IReadOnlyList<string?> AllValues() => new[]
{
CfRay, CfMitigated, CfCacheStatus, XRailwayEdge, XRailwayRequestId,
XHikariTrace, RetryAfter, Via, Server,
};
}
/// <summary>
/// The CVDIAG envelope (schema.json root). Field/property names map to the
/// snake_case wire keys via [JsonPropertyName]; the two optional `_`-prefixed
/// stamp flags are written only when true (DefaultIgnoreCondition handles the
/// false/null case via the source-gen context options).
/// </summary>
public sealed record CvdiagEnvelope
{
[JsonPropertyName("schema_version")] public int SchemaVersion { get; init; } = CvdiagSchema.SchemaVersion;
[JsonPropertyName("test_id")] public string TestId { get; init; } = "";
[JsonPropertyName("trace_id")] public string TraceId { get; init; } = "";
[JsonPropertyName("span_id")] public string SpanId { get; init; } = "";
[JsonPropertyName("parent_span_id")] public string? ParentSpanId { get; init; }
[JsonPropertyName("layer")] public CvdiagLayer Layer { get; init; }
[JsonPropertyName("boundary")] public CvdiagBoundary Boundary { get; init; }
[JsonPropertyName("slug")] public string Slug { get; init; } = "";
[JsonPropertyName("demo")] public string Demo { get; init; } = "";
[JsonPropertyName("ts")] public string Ts { get; init; } = "";
[JsonPropertyName("mono_ns")] public long MonoNs { get; init; }
[JsonPropertyName("duration_ms")] public long? DurationMs { get; init; }
[JsonPropertyName("outcome")] public CvdiagOutcome Outcome { get; init; }
[JsonPropertyName("edge_headers")] public EdgeHeaders EdgeHeaders { get; init; } = new();
[JsonPropertyName("metadata")] public Dictionary<string, object?> Metadata { get; init; } = new();
[JsonPropertyName("_metadata_dropped")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public bool MetadataDropped { get; set; }
[JsonPropertyName("_truncated")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public bool Truncated { get; set; }
}
// ── Per-boundary metadata records (29; schema.json `$defs.boundary_metadata_keys`) ──
//
// One record per metadata-bearing boundary. These are convenience/typed views
// over the boundary's closed key set; the on-wire `metadata` bag stays an open
// JSON object (Dictionary<string, object?>) so a single envelope type carries
// every boundary. Emitters MAY construct these and pass their property bag to
// CvdiagEmitArgs.Metadata. Field names mirror the schema.json metadata keys.
public sealed record ProbeStartMeta { public string? Url { get; init; } public string? Viewport { get; init; } }
public sealed record ProbeNavigateCompleteMeta { public string? Url { get; init; } public long? NavMs { get; init; } public int? HttpStatus { get; init; } }
public sealed record ProbeMessageSendMeta { public int? MessageIndex { get; init; } public int? CharCount { get; init; } public string? Demo { get; init; } }
public sealed record ProbeDomContainerMountMeta { public long? DeltaMsFromStart { get; init; } }
public sealed record ProbeDomFirsttokenMeta { public long? DeltaMsFromStart { get; init; } public int? TextLength { get; init; } }
public sealed record ProbeDomAlternateContentMeta { public Dictionary<string, int>? ChildTypeHistogram { get; init; } }
public sealed record ProbeSseEventMeta { public string? EventType { get; init; } public int? PayloadSizeBytes { get; init; } public int? SequenceNum { get; init; } }
public sealed record ProbeSseAbortedMeta { public string? TerminationKind { get; init; } public int? BytesBeforeAbort { get; init; } }
public sealed record ProbeNetworkErrorMeta { public string? Url { get; init; } public string? ErrorClass { get; init; } public int? ResponseStatus { get; init; } }
public sealed record ProbeNetworkResponseMeta { public string? Url { get; init; } public int? Status { get; init; } public int? ContentLength { get; init; } public long? DurationMs { get; init; } }
public sealed record ProbeConsoleErrorMeta { public string? Level { get; init; } public string? MessageScrubbed { get; init; } public string? SourceFile { get; init; } public string? LineCol { get; init; } }
public sealed record ProbeExitMeta { public string? TerminalOutcome { get; init; } public long? TotalDurationMs { get; init; } public int? SseEventCount { get; init; } public long? FirstTokenDeltaMs { get; init; } }
public sealed record BackendRequestIngressMeta { public string? Method { get; init; } public string? Path { get; init; } public int? ContentLength { get; init; } }
public sealed record BackendAgentEnterMeta { public string? AgentName { get; init; } public string? ModelId { get; init; } }
public sealed record BackendLlmCallStartMeta { public string? Provider { get; init; } public string? Model { get; init; } public int? PromptTokenCountEstimate { get; init; } }
public sealed record BackendLlmCallHeartbeatMeta { public long? ElapsedMsSinceStart { get; init; } }
public sealed record BackendLlmCallResponseMeta { public string? Provider { get; init; } public string? Model { get; init; } public int? ResponseTokenCount { get; init; } public long? LatencyMs { get; init; } public string? ErrorClass { get; init; } }
public sealed record BackendSseFirstByteMeta { public long? DeltaMsFromIngress { get; init; } }
public sealed record BackendSseEventMeta { public string? EventType { get; init; } public int? PayloadSizeBytes { get; init; } public int? SequenceNum { get; init; } }
public sealed record BackendSseAbortedMeta { public string? TerminationKind { get; init; } public int? BytesBeforeAbort { get; init; } }
public sealed record BackendAgentExitMeta { public string? TerminalOutcome { get; init; } public long? TotalDurationMs { get; init; } }
public sealed record BackendResponseCompleteMeta { public int? HttpStatus { get; init; } public int? ContentLength { get; init; } public long? TotalDurationMs { get; init; } public int? SseEventCount { get; init; } }
public sealed record BackendErrorCaughtMeta { public string? ExceptionType { get; init; } public string? MessageScrubbed { get; init; } public string? StackBrief { get; init; } public bool? Truncated { get; init; } }
public sealed record AimockRequestIngressMeta { public string? Path { get; init; } public int? ContentLength { get; init; } public string? MatchKeys { get; init; } }
public sealed record AimockMatchDecisionMeta { public string? FixtureId { get; init; } public double? MatchScore { get; init; } public IReadOnlyList<string>? RejectReasons { get; init; } }
public sealed record AimockResponseStartMeta { public long? DeltaMsFromIngress { get; init; } }
public sealed record AimockSseChunkMeta { public int? ChunkSizeBytes { get; init; } public int? SequenceNum { get; init; } }
public sealed record AimockResponseAbortedMeta { public string? TerminationKind { get; init; } public int? BytesBeforeAbort { get; init; } }
public sealed record AimockResponseCompleteMeta { public int? HttpStatus { get; init; } public int? TotalBytes { get; init; } public long? TotalDurationMs { get; init; } public int? ChunkCount { get; init; } }
/// <summary>
/// Static schema tables derived 1:1 from schema.json `$defs`. These back the
/// closed-world metadata filter in <see cref="CvdiagEmitter"/> and the
/// codegen-coverage assertions.
/// </summary>
public static class CvdiagSchema
{
/// <summary>schema.json `schema_version` (const 1).</summary>
public const int SchemaVersion = 1;
/// <summary>schema.json `$defs.edge_header_keys` (9 keys, wire order).</summary>
public static readonly IReadOnlyList<string> EdgeHeaderKeys = new[]
{
"cf-ray", "cf-mitigated", "cf-cache-status", "x-railway-edge",
"x-railway-request-id", "x-hikari-trace", "retry-after", "via", "server",
};
/// <summary>schema.json `$defs.boundaries` (all 33 wire values).</summary>
public static readonly IReadOnlyList<string> AllBoundaries = new[]
{
"probe.start", "probe.navigate.complete", "probe.message.send",
"probe.dom.container.mount", "probe.dom.firsttoken",
"probe.dom.alternate_content", "probe.sse.event", "probe.sse.aborted",
"probe.network.error", "probe.network.response", "probe.console.error",
"probe.exit", "backend.request.ingress", "backend.agent.enter",
"backend.llm.call.start", "backend.llm.call.heartbeat",
"backend.llm.call.response", "backend.sse.first_byte", "backend.sse.event",
"backend.sse.aborted", "backend.agent.exit", "backend.response.complete",
"backend.error.caught", "aimock.request.ingress", "aimock.match.decision",
"aimock.response.start", "aimock.sse.chunk", "aimock.response.aborted",
"aimock.response.complete", "cvdiag.purge_audit",
"cvdiag.collision_detected", "cvdiag.queue_dropped",
"cvdiag.metadata_dropped",
};
/// <summary>
/// schema.json `$defs.boundary_metadata_keys` — the closed metadata key
/// set per data-plane boundary (29 entries; the 4 `cvdiag.*` accounting
/// boundaries carry no closed key set and ride their payload verbatim).
/// Keyed by the dotted wire boundary value.
/// </summary>
public static readonly IReadOnlyDictionary<string, IReadOnlyList<string>> BoundaryMetadataKeys =
new Dictionary<string, IReadOnlyList<string>>
{
["probe.start"] = new[] { "url", "viewport" },
["probe.navigate.complete"] = new[] { "url", "nav_ms", "http_status" },
["probe.message.send"] = new[] { "message_index", "char_count", "demo" },
["probe.dom.container.mount"] = new[] { "delta_ms_from_start" },
["probe.dom.firsttoken"] = new[] { "delta_ms_from_start", "text_length" },
["probe.dom.alternate_content"] = new[] { "child_type_histogram" },
["probe.sse.event"] = new[] { "event_type", "payload_size_bytes", "sequence_num" },
["probe.sse.aborted"] = new[] { "termination_kind", "bytes_before_abort" },
["probe.network.error"] = new[] { "url", "error_class", "response_status" },
["probe.network.response"] = new[] { "url", "status", "content_length", "duration_ms" },
["probe.console.error"] = new[] { "level", "message_scrubbed", "source_file", "line_col" },
["probe.exit"] = new[] { "terminal_outcome", "total_duration_ms", "sse_event_count", "first_token_delta_ms" },
["backend.request.ingress"] = new[] { "method", "path", "content_length" },
["backend.agent.enter"] = new[] { "agent_name", "model_id" },
["backend.llm.call.start"] = new[] { "provider", "model", "prompt_token_count_estimate" },
["backend.llm.call.heartbeat"] = new[] { "elapsed_ms_since_start" },
["backend.llm.call.response"] = new[] { "provider", "model", "response_token_count", "latency_ms", "error_class" },
["backend.sse.first_byte"] = new[] { "delta_ms_from_ingress" },
["backend.sse.event"] = new[] { "event_type", "payload_size_bytes", "sequence_num" },
["backend.sse.aborted"] = new[] { "termination_kind", "bytes_before_abort" },
["backend.agent.exit"] = new[] { "terminal_outcome", "total_duration_ms" },
["backend.response.complete"] = new[] { "http_status", "content_length", "total_duration_ms", "sse_event_count" },
["backend.error.caught"] = new[] { "exception_type", "message_scrubbed", "stack_brief", "truncated" },
["aimock.request.ingress"] = new[] { "path", "content_length", "match_keys" },
["aimock.match.decision"] = new[] { "fixture_id", "match_score", "reject_reasons" },
["aimock.response.start"] = new[] { "delta_ms_from_ingress" },
["aimock.sse.chunk"] = new[] { "chunk_size_bytes", "sequence_num" },
["aimock.response.aborted"] = new[] { "termination_kind", "bytes_before_abort" },
["aimock.response.complete"] = new[] { "http_status", "total_bytes", "total_duration_ms", "chunk_count" },
};
/// <summary>The dotted wire value for a boundary enum member.</summary>
public static string WireValue(CvdiagBoundary boundary) => boundary switch
{
CvdiagBoundary.ProbeStart => "probe.start",
CvdiagBoundary.ProbeNavigateComplete => "probe.navigate.complete",
CvdiagBoundary.ProbeMessageSend => "probe.message.send",
CvdiagBoundary.ProbeDomContainerMount => "probe.dom.container.mount",
CvdiagBoundary.ProbeDomFirsttoken => "probe.dom.firsttoken",
CvdiagBoundary.ProbeDomAlternateContent => "probe.dom.alternate_content",
CvdiagBoundary.ProbeSseEvent => "probe.sse.event",
CvdiagBoundary.ProbeSseAborted => "probe.sse.aborted",
CvdiagBoundary.ProbeNetworkError => "probe.network.error",
CvdiagBoundary.ProbeNetworkResponse => "probe.network.response",
CvdiagBoundary.ProbeConsoleError => "probe.console.error",
CvdiagBoundary.ProbeExit => "probe.exit",
CvdiagBoundary.BackendRequestIngress => "backend.request.ingress",
CvdiagBoundary.BackendAgentEnter => "backend.agent.enter",
CvdiagBoundary.BackendLlmCallStart => "backend.llm.call.start",
CvdiagBoundary.BackendLlmCallHeartbeat => "backend.llm.call.heartbeat",
CvdiagBoundary.BackendLlmCallResponse => "backend.llm.call.response",
CvdiagBoundary.BackendSseFirstByte => "backend.sse.first_byte",
CvdiagBoundary.BackendSseEvent => "backend.sse.event",
CvdiagBoundary.BackendSseAborted => "backend.sse.aborted",
CvdiagBoundary.BackendAgentExit => "backend.agent.exit",
CvdiagBoundary.BackendResponseComplete => "backend.response.complete",
CvdiagBoundary.BackendErrorCaught => "backend.error.caught",
CvdiagBoundary.AimockRequestIngress => "aimock.request.ingress",
CvdiagBoundary.AimockMatchDecision => "aimock.match.decision",
CvdiagBoundary.AimockResponseStart => "aimock.response.start",
CvdiagBoundary.AimockSseChunk => "aimock.sse.chunk",
CvdiagBoundary.AimockResponseAborted => "aimock.response.aborted",
CvdiagBoundary.AimockResponseComplete => "aimock.response.complete",
CvdiagBoundary.CvdiagPurgeAudit => "cvdiag.purge_audit",
CvdiagBoundary.CvdiagCollisionDetected => "cvdiag.collision_detected",
CvdiagBoundary.CvdiagQueueDropped => "cvdiag.queue_dropped",
CvdiagBoundary.CvdiagMetadataDropped => "cvdiag.metadata_dropped",
_ => throw new ArgumentOutOfRangeException(nameof(boundary), boundary, null),
};
}
/// <summary>
/// System.Text.Json source-generation context. Carries the [JsonSerializable]
/// roots so serialization is reflection-free (AOT/trim-safe). The snake_case
/// policy is NOT applied globally — every field already declares its exact
/// wire name via [JsonPropertyName], so we keep the default naming and rely on
/// the explicit attributes (avoids double-transforming hyphenated edge keys).
///
/// IMPORTANT: we do NOT set a context-wide DefaultIgnoreCondition. The schema
/// REQUIRES the nullable fields (`parent_span_id`, `duration_ms`) to appear on
/// the wire as `null`, so a global WhenWritingDefault/WhenWritingNull would
/// drop them and break the closed-world `required` contract. The only fields
/// that should be omitted when default are the two `_`-prefixed stamp flags,
/// which carry their own per-property [JsonIgnore(WhenWritingDefault)].
/// </summary>
[JsonSourceGenerationOptions(WriteIndented = false)]
[JsonSerializable(typeof(CvdiagEnvelope))]
[JsonSerializable(typeof(EdgeHeaders))]
[JsonSerializable(typeof(Dictionary<string, object?>))]
public partial class CvdiagJsonContext : JsonSerializerContext
{
}
@@ -0,0 +1,37 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
Standalone xUnit test project for the shared .NET CVDIAG binding (plan unit
L0-D). The `_shared/dotnet/` directory is a SOURCE-INCLUDE shared library
(no production .csproj of its own — see the L1-F reference note in the slot
report), so this test project compiles the two binding sources directly via
<Compile Include> rather than a <ProjectReference>. This keeps the binding
buildable/testable in isolation without a host integration.
System.Text.Json source-generation requires C# 9+ partial-class contexts and
the JsonStringEnumMemberName attribute (net8.0+); we target net9.0 to match
the .NET integrations (ProverbsAgent.csproj is net9.0).
-->
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>Copilotkit.Showcase.Cvdiag.Tests</RootNamespace>
<AssemblyName>Cvdiag.Schema.Tests</AssemblyName>
</PropertyGroup>
<!-- Compile the shared binding sources directly (source-include model). -->
<ItemGroup>
<Compile Include="..\CvdiagSchema.cs" />
<Compile Include="..\CvdiagEmitter.cs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
</Project>
@@ -0,0 +1,262 @@
// CvdiagSchema.test.cs — xUnit red-green proof for the .NET CVDIAG codegen
// binding (plan unit L0-D, spec §5/§6). Mirrors the TS `schema.test.ts` /
// `emit.ts` contract:
//
// 1. schema round-trip — a fully-populated CvdiagEnvelope serializes to JSON
// and deserializes back identically (System.Text.Json source-gen).
// 2. forbidden-header rejection — CvdiagEmitter.FilterEdgeHeaders() drops a
// DENY-list header (cf-ipcountry) and the cf-ip* family even if it is
// injected alongside an allow-list header; every result carries all 9
// allow-list keys (absent → null).
// 3. _metadata_dropped stamp — an envelope carrying an unknown metadata key
// is closed-world filtered and the `_metadata_dropped` flag is set.
// 4. production-env DEBUG refusal — CvdiagEmitter fail-closes (throws) when
// DEBUG is requested in a production environment, when no env resolves,
// and when the allow-list is absent.
//
// RED: with no CvdiagSchema.cs / CvdiagEmitter.cs these types do not exist and
// the suite does not compile. GREEN once both are implemented.
//
// Run (from repo root, requires the .NET 9 SDK):
// dotnet test showcase/integrations/_shared/dotnet/tests/CvdiagSchema.Tests.csproj
using System.Text.Json;
using Copilotkit.Showcase.Cvdiag;
using Xunit;
namespace Copilotkit.Showcase.Cvdiag.Tests;
public class CvdiagSchemaTests
{
private static EdgeHeaders EmptyEdgeHeaders() => new();
private static CvdiagEnvelope SampleEnvelope() => new()
{
SchemaVersion = CvdiagSchema.SchemaVersion,
TestId = "017f22e2-79b0-7cc3-98c4-dc0c0c07398f",
TraceId = "017f22e2-79b0-7cc3-98c4-dc0c0c07398f",
SpanId = "00f067aa0ba902b7",
ParentSpanId = null,
Layer = CvdiagLayer.Backend,
Boundary = CvdiagBoundary.BackendResponseComplete,
Slug = "gen-ui-chat",
Demo = "default",
Ts = "2026-06-18T12:00:00.000Z",
MonoNs = 123456789,
DurationMs = 42,
Outcome = CvdiagOutcome.Ok,
EdgeHeaders = EmptyEdgeHeaders(),
Metadata = new Dictionary<string, object?> { ["http_status"] = 200 },
};
// (1) Round-trip: serialize → deserialize → equal field-by-field.
[Fact]
public void Envelope_RoundTrips_ThroughSystemTextJson()
{
var original = SampleEnvelope();
var json = JsonSerializer.Serialize(original, CvdiagJsonContext.Default.CvdiagEnvelope);
var back = JsonSerializer.Deserialize(json, CvdiagJsonContext.Default.CvdiagEnvelope)!;
Assert.Equal(original.SchemaVersion, back.SchemaVersion);
Assert.Equal(original.TestId, back.TestId);
Assert.Equal(original.SpanId, back.SpanId);
Assert.Equal(original.Layer, back.Layer);
Assert.Equal(original.Boundary, back.Boundary);
Assert.Equal(original.Slug, back.Slug);
Assert.Equal(original.Outcome, back.Outcome);
Assert.Equal(original.MonoNs, back.MonoNs);
Assert.Equal(original.DurationMs, back.DurationMs);
}
// (1b) Wire format: enum + key names match the schema.json contract exactly
// (snake_case keys, dotted/lowercase enum string values).
[Fact]
public void Envelope_SerializesWithSchemaWireNames()
{
var json = JsonSerializer.Serialize(SampleEnvelope(), CvdiagJsonContext.Default.CvdiagEnvelope);
Assert.Contains("\"schema_version\":1", json);
Assert.Contains("\"test_id\":", json);
Assert.Contains("\"parent_span_id\":null", json);
Assert.Contains("\"edge_headers\":", json);
Assert.Contains("\"layer\":\"backend\"", json);
Assert.Contains("\"boundary\":\"backend.response.complete\"", json);
Assert.Contains("\"outcome\":\"ok\"", json);
// cf-ray etc. serialize with their hyphenated wire names.
Assert.Contains("\"cf-ray\":null", json);
Assert.Contains("\"x-railway-request-id\":null", json);
}
// (2) Forbidden-header rejection: cf-ipcountry (DENY) is dropped even when
// injected next to an allow-list header; the cf-ip* family is never
// captured; all 9 allow-list keys are present (absent → null).
[Fact]
public void FilterEdgeHeaders_RejectsDenyListAndCfIpFamily()
{
var raw = new Dictionary<string, string?>
{
["cf-ray"] = "8abc-DFW", // allow → kept
["cf-ipcountry"] = "US", // deny → dropped
["cf-connecting-ip"] = "1.2.3.4", // deny → dropped
["true-client-ip"] = "1.2.3.4", // deny → dropped
["x-forwarded-for"] = "1.2.3.4", // deny → dropped
["CF-IPCity"] = "Dallas", // deny (case-insensitive)
["server"] = "railway-edge", // allow → kept
};
var filtered = CvdiagEmitter.FilterEdgeHeaders(raw);
Assert.Equal("8abc-DFW", filtered.CfRay);
Assert.Equal("railway-edge", filtered.Server);
// DENY-list values never surface on any of the 9 fields.
Assert.DoesNotContain("US", filtered.AllValues());
Assert.DoesNotContain("1.2.3.4", filtered.AllValues());
Assert.DoesNotContain("Dallas", filtered.AllValues());
// All 9 allow-list keys present; unset → null.
Assert.Null(filtered.CfMitigated);
Assert.Null(filtered.XRailwayRequestId);
Assert.Null(filtered.RetryAfter);
Assert.Equal(9, filtered.KeyCount());
}
// (2b) Deny wins even if a deny key somehow appears in the allow path.
[Fact]
public void FilterEdgeHeaders_DenyWinsOverAllow()
{
var raw = new Dictionary<string, string?> { ["forwarded"] = "for=1.2.3.4" };
var filtered = CvdiagEmitter.FilterEdgeHeaders(raw);
Assert.DoesNotContain("for=1.2.3.4", filtered.AllValues());
}
// (3) _metadata_dropped stamp: an unknown metadata key for the boundary is
// dropped (closed-world) and the flag is stamped.
[Fact]
public void Emit_StampsMetadataDropped_OnUnknownKey()
{
var emitter = CvdiagEmitterTestFactory.Verbose();
var env = emitter.Emit(new CvdiagEmitArgs
{
Layer = CvdiagLayer.Backend,
Boundary = CvdiagBoundary.BackendResponseComplete,
Slug = "gen-ui-chat",
Demo = "default",
Outcome = CvdiagOutcome.Ok,
Metadata = new Dictionary<string, object?>
{
["http_status"] = 200, // allowed for this boundary
["totally_unknown_key"] = 1, // not in the closed key set → dropped
},
});
Assert.NotNull(env);
Assert.True(env!.MetadataDropped);
Assert.True(env.Metadata.ContainsKey("http_status"));
Assert.False(env.Metadata.ContainsKey("totally_unknown_key"));
}
// (3b) Clean metadata leaves the flag unset.
[Fact]
public void Emit_DoesNotStampMetadataDropped_OnCleanMetadata()
{
var emitter = CvdiagEmitterTestFactory.Verbose();
var env = emitter.Emit(new CvdiagEmitArgs
{
Layer = CvdiagLayer.Backend,
Boundary = CvdiagBoundary.BackendAgentEnter,
Slug = "gen-ui-chat",
Demo = "default",
Outcome = CvdiagOutcome.Ok,
Metadata = new Dictionary<string, object?>
{
["agent_name"] = "proverbs",
["model_id"] = "gpt-4o-mini",
},
});
Assert.NotNull(env);
Assert.False(env!.MetadataDropped);
}
// (4) Production-env DEBUG refusal: SHOWCASE_ENV=production + DEBUG throws.
[Fact]
public void DebugInProduction_FailsClosed()
{
var env = new Dictionary<string, string?>
{
["SHOWCASE_ENV"] = "production",
["CVDIAG_DEBUG_ALLOW_LIST"] = "gen-ui-chat",
};
Assert.Throws<InvalidOperationException>(
() => new CvdiagEmitter(new CvdiagEmitterOptions { Debug = true, Env = env }));
}
// (4b) Unresolved env (no SHOWCASE_ENV/RAILWAY/ASPNETCORE) treated as prod.
[Fact]
public void DebugWithUnresolvedEnv_FailsClosed()
{
var env = new Dictionary<string, string?> { ["CVDIAG_DEBUG_ALLOW_LIST"] = "gen-ui-chat" };
Assert.Throws<InvalidOperationException>(
() => new CvdiagEmitter(new CvdiagEmitterOptions { Debug = true, Env = env }));
}
// (4c) DEBUG in a non-prod env without an allow-list still refuses.
[Fact]
public void DebugWithoutAllowList_FailsClosed()
{
var env = new Dictionary<string, string?> { ["SHOWCASE_ENV"] = "staging" };
Assert.Throws<InvalidOperationException>(
() => new CvdiagEmitter(new CvdiagEmitterOptions { Debug = true, Env = env }));
}
// (4d) DEBUG in a non-prod env WITH an allow-list is permitted (no throw).
[Fact]
public void DebugInStagingWithAllowList_IsAllowed()
{
var env = new Dictionary<string, string?>
{
["SHOWCASE_ENV"] = "staging",
["CVDIAG_DEBUG_ALLOW_LIST"] = "gen-ui-chat",
};
var emitter = new CvdiagEmitter(new CvdiagEmitterOptions { Debug = true, Env = env });
Assert.Equal(CvdiagTier.Debug, emitter.Tier);
}
// (4e) Tier env precedence: ASPNETCORE_ENVIRONMENT is the last fallback
// (the .NET analogue of NODE_ENV).
[Fact]
public void ResolveEnvLabel_FallsBackToAspNetCoreEnvironment()
{
var env = new Dictionary<string, string?> { ["ASPNETCORE_ENVIRONMENT"] = "Production" };
Assert.Equal("production", CvdiagEmitter.ResolveEnvLabel(env));
}
[Fact]
public void ResolveEnvLabel_ShowcaseEnvWins()
{
var env = new Dictionary<string, string?>
{
["SHOWCASE_ENV"] = "staging",
["RAILWAY_ENVIRONMENT_NAME"] = "production",
["ASPNETCORE_ENVIRONMENT"] = "Production",
};
Assert.Equal("staging", CvdiagEmitter.ResolveEnvLabel(env));
}
// Codegen coverage: all 29 metadata-bearing boundaries have a closed key set.
[Fact]
public void AllBoundaries_HaveClosedMetadataKeySets()
{
Assert.Equal(33, CvdiagSchema.AllBoundaries.Count);
Assert.Equal(29, CvdiagSchema.BoundaryMetadataKeys.Count);
Assert.Equal(9, CvdiagSchema.EdgeHeaderKeys.Count);
}
}
// Small factory: a VERBOSE-tier emitter with no PB writer for metadata tests.
internal static class CvdiagEmitterTestFactory
{
public static CvdiagEmitter Verbose() => new(new CvdiagEmitterOptions
{
Verbose = true,
Env = new Dictionary<string, string?> { ["SHOWCASE_ENV"] = "staging" },
});
}