From 1c945d3ea88fd959d921b2560a99a120e83b50cb Mon Sep 17 00:00:00 2001 From: Chris Lee Date: Sat, 20 Jun 2026 23:39:35 +1000 Subject: [PATCH 1/3] feat(observability): emit structured idempotency events on all 4 claimDelivery outcomes claimDelivery had four terminal outcomes but only one emitted a queryable event, and the claim-won happy path was unlogged, so dedup-hit-rate and fail-open-rate were not queryable. Add src/webhook/idempotency-log-fields.ts (strict z.discriminatedUnion, mirrors retry-log-fields) and emit a uniform event on all four outcomes: idempotency.claimed (new), duplicate_skipped (replaces the dedup-skip literal), and failed_open with reason unavailable/error. Logging-only: returns, SET command, TTL, and fail-open semantics unchanged. The emit-site test now parses each captured emit through the schema to prove emit/schema agreement. Closes #232 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015v2bspPF1ZTTG9ZZrbBgA1 --- docs/operate/observability.md | 10 +++ src/webhook/idempotency-log-fields.ts | 60 +++++++++++++ src/webhook/idempotency.ts | 23 ++++- test/webhook/idempotency-log-fields.test.ts | 95 +++++++++++++++++++++ test/webhook/idempotency.test.ts | 27 +++++- 5 files changed, 209 insertions(+), 6 deletions(-) create mode 100644 src/webhook/idempotency-log-fields.ts create mode 100644 test/webhook/idempotency-log-fields.test.ts diff --git a/docs/operate/observability.md b/docs/operate/observability.md index c680fce..f23434b 100644 --- a/docs/operate/observability.md +++ b/docs/operate/observability.md @@ -111,6 +111,16 @@ The load-bearing event is `retry.succeeded_after_retry`: it is the only signal i | `retry.exhausted` | error | All `max_attempts` attempts failed. Carries the full retry-window `elapsed_ms`; rethrows the last error. Neither `delay_ms` nor `status` are emitted on this event. | | `retry.succeeded_after_retry` | info | The call succeeded on `attempt > 1`. Weak-flake leading indicator: gated on `attempt > 1` so first-try successes stay silent. Alert on `count(...) by op` over 5-minute windows. Neither `delay_ms` nor `status` are emitted on this event. | +## Idempotency log fields + +`claimDelivery` (`src/webhook/idempotency.ts`) is the webhook dedup chokepoint: a Valkey `SET key 1 NX EX` claim that returns `true` exactly once per `deliveryId` within GitHub's 3-day redelivery window. Its three-event family is pinned by a `z.discriminatedUnion` of strict objects in `src/webhook/idempotency-log-fields.ts` so an emitter that mistypes an event name or drops `reason` from a fail-open line trips the co-located test. Every event carries `deliveryId` (camelCase, the established child-logger delivery identifier binding). Behaviour is fail-open: `idempotency.claimed` and `idempotency.failed_open` both proceed with processing; only `idempotency.duplicate_skipped` skips. + +| `event` | Level | Fields | +| ------------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `idempotency.claimed` | info | `deliveryId`. The SET-NX won the claim (first time this delivery is seen); the caller proceeds. | +| `idempotency.duplicate_skipped` | info | `deliveryId`. The SET-NX found an existing key (a redelivery); the caller skips. | +| `idempotency.failed_open` | warn | `deliveryId`, `reason` (`unavailable` when Valkey is unconfigured/disconnected, `error` when the SET threw), and `err` (the error message, on the `error` branch only). The caller proceeds (at-least-once degradation). | + ## Output secret-guard log events `safePostToGitHub` (`src/utils/github-output-guard.ts`) is the output-side chokepoint for every byte sent to GitHub. It emits structured `warn`/`error` events when the regex pass or the optional LLM scanner acts on a body. Per the logging contract, none of these carry the matched bytes, surrounding context, or a hash, only `kinds`, counts, lengths, `callsite`, and `deliveryId`. diff --git a/src/webhook/idempotency-log-fields.ts b/src/webhook/idempotency-log-fields.ts new file mode 100644 index 0000000..d6c6126 --- /dev/null +++ b/src/webhook/idempotency-log-fields.ts @@ -0,0 +1,60 @@ +/** + * Canonical pino log-field schema for `claimDelivery` observability (issue #232). + * + * Mirrors `src/utils/retry-log-fields.ts`: a strict Zod shape pins the + * structured `idempotency.*` event family so the emit sites in `claimDelivery` + * cannot drift on a field name (e.g. `reason` value or `failed_open` vs + * `failed-open`) without the co-located test catching it. Emitters log plain + * objects via `log.info` / `log.warn`; the schema is the drift-prevention + * contract, not a runtime validator on the hot path. + * + * The schema is a `z.discriminatedUnion` on `event` so per-event field presence + * is pinned: `idempotency.failed_open` requires a `reason` enum and may carry + * `err`; the `claimed` / `duplicate_skipped` branches carry neither. Future + * emitter changes that drop `reason` or attach it to the wrong event trip the + * co-located test. + */ +import { z } from "zod"; + +export const IDEMPOTENCY_LOG_EVENTS = { + claimed: "idempotency.claimed", + duplicateSkipped: "idempotency.duplicate_skipped", + failedOpen: "idempotency.failed_open", +} as const; + +// `deliveryId` stays camelCase because it is the established repo-wide +// child-logger delivery identifier binding; new metric-style fields use snake_case. +const deliveryId = z.string().min(1); + +export const IdempotencyLogFieldsSchema = z.discriminatedUnion("event", [ + /** + * Info-level emit when the SET-NX won the claim (first delivery seen). The + * caller proceeds. + */ + z.strictObject({ + event: z.literal(IDEMPOTENCY_LOG_EVENTS.claimed), + deliveryId, + }), + /** + * Info-level emit when the SET-NX found an existing key (a redelivery). The + * caller skips. + */ + z.strictObject({ + event: z.literal(IDEMPOTENCY_LOG_EVENTS.duplicateSkipped), + deliveryId, + }), + /** + * Warn-level emit on the fail-open path: `reason` is `unavailable` when + * Valkey is unconfigured/disconnected (no SET issued) or `error` when the SET + * threw. `err` carries the error message on the `error` branch only. Either + * way the caller proceeds (at-least-once degradation). + */ + z.strictObject({ + event: z.literal(IDEMPOTENCY_LOG_EVENTS.failedOpen), + deliveryId, + reason: z.enum(["unavailable", "error"]), + err: z.string().optional(), + }), +]); + +export type IdempotencyLogFields = z.infer; diff --git a/src/webhook/idempotency.ts b/src/webhook/idempotency.ts index 383a307..2a29f40 100644 --- a/src/webhook/idempotency.ts +++ b/src/webhook/idempotency.ts @@ -1,6 +1,7 @@ import type { Logger } from "pino"; import { getValkeyClient, isValkeyHealthy } from "../orchestrator/valkey"; +import { IDEMPOTENCY_LOG_EVENTS } from "./idempotency-log-fields"; /** * Webhook delivery idempotency (issue #202). @@ -44,7 +45,10 @@ export async function claimDelivery(deliveryId: string, log: Logger): Promise { + it("pins the three canonical event strings", () => { + expect(IDEMPOTENCY_LOG_EVENTS.claimed).toBe("idempotency.claimed"); + expect(IDEMPOTENCY_LOG_EVENTS.duplicateSkipped).toBe("idempotency.duplicate_skipped"); + expect(IDEMPOTENCY_LOG_EVENTS.failedOpen).toBe("idempotency.failed_open"); + }); +}); + +describe("IdempotencyLogFieldsSchema: accepts well-formed events", () => { + it("accepts a valid idempotency.claimed record", () => { + const result = IdempotencyLogFieldsSchema.safeParse({ + event: IDEMPOTENCY_LOG_EVENTS.claimed, + deliveryId: "d1", + }); + expect(result.success).toBe(true); + }); + + it("accepts a valid idempotency.duplicate_skipped record", () => { + const result = IdempotencyLogFieldsSchema.safeParse({ + event: IDEMPOTENCY_LOG_EVENTS.duplicateSkipped, + deliveryId: "d1", + }); + expect(result.success).toBe(true); + }); + + it("accepts a valid idempotency.failed_open record with reason unavailable", () => { + const result = IdempotencyLogFieldsSchema.safeParse({ + event: IDEMPOTENCY_LOG_EVENTS.failedOpen, + deliveryId: "d1", + reason: "unavailable", + }); + expect(result.success).toBe(true); + }); + + it("accepts a valid idempotency.failed_open record with reason error and err", () => { + const result = IdempotencyLogFieldsSchema.safeParse({ + event: IDEMPOTENCY_LOG_EVENTS.failedOpen, + deliveryId: "d1", + reason: "error", + err: "ECONNREFUSED", + }); + expect(result.success).toBe(true); + }); +}); + +describe("IdempotencyLogFieldsSchema: rejects drift and bad input", () => { + it("rejects an unknown extra field (strict)", () => { + const result = IdempotencyLogFieldsSchema.safeParse({ + event: IDEMPOTENCY_LOG_EVENTS.claimed, + deliveryId: "d1", + surprise: "boo", + }); + expect(result.success).toBe(false); + }); + + it("rejects an unknown event literal", () => { + const result = IdempotencyLogFieldsSchema.safeParse({ + event: "idempotency.bogus", + deliveryId: "d1", + }); + expect(result.success).toBe(false); + }); + + it("rejects an invalid reason on failed_open", () => { + const result = IdempotencyLogFieldsSchema.safeParse({ + event: IDEMPOTENCY_LOG_EVENTS.failedOpen, + deliveryId: "d1", + reason: "weird", + }); + expect(result.success).toBe(false); + }); + + it("rejects a failed_open record missing reason", () => { + const result = IdempotencyLogFieldsSchema.safeParse({ + event: IDEMPOTENCY_LOG_EVENTS.failedOpen, + deliveryId: "d1", + }); + expect(result.success).toBe(false); + }); + + it("rejects an empty deliveryId", () => { + const result = IdempotencyLogFieldsSchema.safeParse({ + event: IDEMPOTENCY_LOG_EVENTS.claimed, + deliveryId: "", + }); + expect(result.success).toBe(false); + }); +}); diff --git a/test/webhook/idempotency.test.ts b/test/webhook/idempotency.test.ts index 65f9ae0..4896ef3 100644 --- a/test/webhook/idempotency.test.ts +++ b/test/webhook/idempotency.test.ts @@ -9,6 +9,8 @@ import { beforeEach, describe, expect, it, mock } from "bun:test"; import type { Logger } from "pino"; +import { IdempotencyLogFieldsSchema } from "../../src/webhook/idempotency-log-fields"; + // Configurable Valkey stub: the mock is wired once at module load (below); // each test swaps `clientImpl` (and `healthy`) before invoking claimDelivery. // getValkeyClient returns whatever clientImpl holds at call time. @@ -26,11 +28,25 @@ void mock.module("../../src/orchestrator/valkey", () => ({ const { claimDelivery } = await import("../../src/webhook/idempotency"); +// Recording logger: captures the structured field object of each emit so the +// tests can assert the canonical `idempotency.*` event names and fail-open reasons. +let logged: { level: "info" | "warn"; fields: Record }[] = []; const log = { - warn: () => {}, - info: () => {}, + info: (fields: Record) => logged.push({ level: "info", fields }), + warn: (fields: Record) => logged.push({ level: "warn", fields }), } as unknown as Logger; +// Assert an emit with `event` was logged AND that its exact field object +// validates against the canonical schema. Parsing the real emitted object +// closes the drift hole a loose string check leaves open: a stray/misnamed +// field or a `reason` on the wrong event trips the strict schema here. +function expectEmittedEvent(event: string): Record { + const rec = logged.find((r) => r.fields.event === event); + expect(rec).toBeDefined(); + expect(() => IdempotencyLogFieldsSchema.parse(rec?.fields)).not.toThrow(); + return rec?.fields ?? {}; +} + // SET-NX-EX semantics: first SET of a key returns "OK", a second SET of the // same key returns null (the key already exists). Mirrors real Valkey NX. function nxClient(): { send: SendFn; store: Set } { @@ -52,6 +68,7 @@ describe("claimDelivery (issue #202)", () => { beforeEach(() => { clientImpl = null; healthy = true; + logged = []; }); it("claims a new delivery once, then rejects the redelivery", async () => { @@ -60,6 +77,8 @@ describe("claimDelivery (issue #202)", () => { const second = await claimDelivery("delivery-abc", log); expect(first).toBe(true); expect(second).toBe(false); + expectEmittedEvent("idempotency.claimed"); + expectEmittedEvent("idempotency.duplicate_skipped"); }); it("treats distinct deliveryIds independently", async () => { @@ -83,6 +102,7 @@ describe("claimDelivery (issue #202)", () => { it("fails OPEN (true) when Valkey is unconfigured (null client)", async () => { clientImpl = null; expect(await claimDelivery("delivery-no-valkey", log)).toBe(true); + expect(expectEmittedEvent("idempotency.failed_open").reason).toBe("unavailable"); }); it("fails OPEN (true) when the Valkey SET throws", async () => { @@ -90,6 +110,9 @@ describe("claimDelivery (issue #202)", () => { send: () => Promise.reject(new Error("ECONNREFUSED")), }; expect(await claimDelivery("delivery-error", log)).toBe(true); + const failed = expectEmittedEvent("idempotency.failed_open"); + expect(failed.reason).toBe("error"); + expect(failed.err).toBe("ECONNREFUSED"); }); it("fails OPEN (true) without issuing SET when configured-but-disconnected", async () => { From cad2db614e0cf8433e6f6b00735af638859408cf Mon Sep 17 00:00:00 2001 From: Chris Lee Date: Sat, 20 Jun 2026 23:49:29 +1000 Subject: [PATCH 2/3] fix(observability): tighten idempotency schema and doc per review Address review: enforce err-only-on-error in the failed_open schema via separate reason branches (rejecting err on the unavailable path) and clarify that claimDelivery's exactly-once guarantee holds only on the healthy Valkey path, degrading to at-least-once when it fails open. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015v2bspPF1ZTTG9ZZrbBgA1 --- docs/operate/observability.md | 2 +- src/webhook/idempotency-log-fields.ts | 42 ++++++++++----------- test/webhook/idempotency-log-fields.test.ts | 19 ++++++++++ 3 files changed, 40 insertions(+), 23 deletions(-) diff --git a/docs/operate/observability.md b/docs/operate/observability.md index f23434b..1020899 100644 --- a/docs/operate/observability.md +++ b/docs/operate/observability.md @@ -113,7 +113,7 @@ The load-bearing event is `retry.succeeded_after_retry`: it is the only signal i ## Idempotency log fields -`claimDelivery` (`src/webhook/idempotency.ts`) is the webhook dedup chokepoint: a Valkey `SET key 1 NX EX` claim that returns `true` exactly once per `deliveryId` within GitHub's 3-day redelivery window. Its three-event family is pinned by a `z.discriminatedUnion` of strict objects in `src/webhook/idempotency-log-fields.ts` so an emitter that mistypes an event name or drops `reason` from a fail-open line trips the co-located test. Every event carries `deliveryId` (camelCase, the established child-logger delivery identifier binding). Behaviour is fail-open: `idempotency.claimed` and `idempotency.failed_open` both proceed with processing; only `idempotency.duplicate_skipped` skips. +`claimDelivery` (`src/webhook/idempotency.ts`) is the webhook dedup chokepoint: a Valkey `SET key 1 NX EX` claim that returns `true` exactly once per `deliveryId` **only on the healthy Valkey path** within GitHub's 3-day redelivery window. When Valkey is unavailable or errors it fails **open**, returning `true` for every delivery (including redeliveries), so the exactly-once guarantee degrades to at-least-once. Its three-event family is pinned by a union of strict objects in `src/webhook/idempotency-log-fields.ts` so an emitter that mistypes an event name, drops `reason` from a fail-open line, or attaches `err` to the `unavailable` path trips the co-located test. Every event carries `deliveryId` (camelCase, the established child-logger delivery identifier binding). Behaviour is fail-open: `idempotency.claimed` and `idempotency.failed_open` both proceed with processing; only `idempotency.duplicate_skipped` skips. | `event` | Level | Fields | | ------------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | diff --git a/src/webhook/idempotency-log-fields.ts b/src/webhook/idempotency-log-fields.ts index d6c6126..e6460f4 100644 --- a/src/webhook/idempotency-log-fields.ts +++ b/src/webhook/idempotency-log-fields.ts @@ -8,11 +8,14 @@ * objects via `log.info` / `log.warn`; the schema is the drift-prevention * contract, not a runtime validator on the hot path. * - * The schema is a `z.discriminatedUnion` on `event` so per-event field presence - * is pinned: `idempotency.failed_open` requires a `reason` enum and may carry - * `err`; the `claimed` / `duplicate_skipped` branches carry neither. Future - * emitter changes that drop `reason` or attach it to the wrong event trip the - * co-located test. + * The schema is a union of strict per-outcome objects so per-event field + * presence is pinned exactly: `claimed` / `duplicate_skipped` carry only + * `deliveryId`; `failed_open` splits into a `reason: "unavailable"` branch + * (no `err`) and a `reason: "error"` branch (`err` required). The fail-open + * split is two branches rather than a `reason` enum + optional `err` so the + * contract "`err` only on the `error` path" is enforced by the schema itself, + * not just by emit-site discipline: a future emit attaching `err` to an + * `unavailable` line is rejected here and trips the co-located test. */ import { z } from "zod"; @@ -26,34 +29,29 @@ export const IDEMPOTENCY_LOG_EVENTS = { // child-logger delivery identifier binding; new metric-style fields use snake_case. const deliveryId = z.string().min(1); -export const IdempotencyLogFieldsSchema = z.discriminatedUnion("event", [ - /** - * Info-level emit when the SET-NX won the claim (first delivery seen). The - * caller proceeds. - */ +export const IdempotencyLogFieldsSchema = z.union([ + /** Info: the SET-NX won the claim (first delivery seen); the caller proceeds. */ z.strictObject({ event: z.literal(IDEMPOTENCY_LOG_EVENTS.claimed), deliveryId, }), - /** - * Info-level emit when the SET-NX found an existing key (a redelivery). The - * caller skips. - */ + /** Info: the SET-NX found an existing key (a redelivery); the caller skips. */ z.strictObject({ event: z.literal(IDEMPOTENCY_LOG_EVENTS.duplicateSkipped), deliveryId, }), - /** - * Warn-level emit on the fail-open path: `reason` is `unavailable` when - * Valkey is unconfigured/disconnected (no SET issued) or `error` when the SET - * threw. `err` carries the error message on the `error` branch only. Either - * way the caller proceeds (at-least-once degradation). - */ + /** Warn: Valkey unconfigured/disconnected, no SET issued. The caller proceeds. */ z.strictObject({ event: z.literal(IDEMPOTENCY_LOG_EVENTS.failedOpen), deliveryId, - reason: z.enum(["unavailable", "error"]), - err: z.string().optional(), + reason: z.literal("unavailable"), + }), + /** Warn: the SET threw; `err` carries the message. The caller proceeds. */ + z.strictObject({ + event: z.literal(IDEMPOTENCY_LOG_EVENTS.failedOpen), + deliveryId, + reason: z.literal("error"), + err: z.string(), }), ]); diff --git a/test/webhook/idempotency-log-fields.test.ts b/test/webhook/idempotency-log-fields.test.ts index 606f79b..7519303 100644 --- a/test/webhook/idempotency-log-fields.test.ts +++ b/test/webhook/idempotency-log-fields.test.ts @@ -85,6 +85,25 @@ describe("IdempotencyLogFieldsSchema: rejects drift and bad input", () => { expect(result.success).toBe(false); }); + it("rejects err on the unavailable branch (err only valid with reason error)", () => { + const result = IdempotencyLogFieldsSchema.safeParse({ + event: IDEMPOTENCY_LOG_EVENTS.failedOpen, + deliveryId: "d1", + reason: "unavailable", + err: "boom", + }); + expect(result.success).toBe(false); + }); + + it("rejects a failed_open record with reason error but no err", () => { + const result = IdempotencyLogFieldsSchema.safeParse({ + event: IDEMPOTENCY_LOG_EVENTS.failedOpen, + deliveryId: "d1", + reason: "error", + }); + expect(result.success).toBe(false); + }); + it("rejects an empty deliveryId", () => { const result = IdempotencyLogFieldsSchema.safeParse({ event: IDEMPOTENCY_LOG_EVENTS.claimed, From 63b2ee7a8c0bfa6da6d50a5a2023fdb42eefb995 Mon Sep 17 00:00:00 2001 From: Chris Lee Date: Sat, 20 Jun 2026 23:57:01 +1000 Subject: [PATCH 3/3] fix(observability): claimed at debug per #232 volume policy; validate every emit Address review: idempotency.claimed fires once per non-duplicate delivery, so emit it at debug (issue #232 volume policy, mirroring github.api.request) rather than info. The recording-logger test now parses every captured emit through IdempotencyLogFieldsSchema at capture time, so any emit/schema drift surfaces immediately, not only on lines a test asserts on. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015v2bspPF1ZTTG9ZZrbBgA1 --- docs/operate/observability.md | 2 +- src/webhook/idempotency.ts | 5 ++++- test/webhook/idempotency.test.ts | 28 ++++++++++++++++++---------- 3 files changed, 23 insertions(+), 12 deletions(-) diff --git a/docs/operate/observability.md b/docs/operate/observability.md index 1020899..08bae5e 100644 --- a/docs/operate/observability.md +++ b/docs/operate/observability.md @@ -117,7 +117,7 @@ The load-bearing event is `retry.succeeded_after_retry`: it is the only signal i | `event` | Level | Fields | | ------------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `idempotency.claimed` | info | `deliveryId`. The SET-NX won the claim (first time this delivery is seen); the caller proceeds. | +| `idempotency.claimed` | debug | `deliveryId`. The SET-NX won the claim (first time this delivery is seen); the caller proceeds. At `debug` because it fires once per non-duplicate delivery, too loud at `info` for a busy installation. | | `idempotency.duplicate_skipped` | info | `deliveryId`. The SET-NX found an existing key (a redelivery); the caller skips. | | `idempotency.failed_open` | warn | `deliveryId`, `reason` (`unavailable` when Valkey is unconfigured/disconnected, `error` when the SET threw), and `err` (the error message, on the `error` branch only). The caller proceeds (at-least-once degradation). | diff --git a/src/webhook/idempotency.ts b/src/webhook/idempotency.ts index 2a29f40..2c71738 100644 --- a/src/webhook/idempotency.ts +++ b/src/webhook/idempotency.ts @@ -63,7 +63,10 @@ export async function claimDelivery(deliveryId: string, log: Logger): Promise ({ const { claimDelivery } = await import("../../src/webhook/idempotency"); -// Recording logger: captures the structured field object of each emit so the -// tests can assert the canonical `idempotency.*` event names and fail-open reasons. -let logged: { level: "info" | "warn"; fields: Record }[] = []; +// Recording logger: captures the structured field object of each emit and +// parses it through the canonical schema AT CAPTURE TIME, so every emitted line +// (not only those a test later asserts on) is held to the strict contract. A +// stray/misnamed field, a `reason` on the wrong event, or `err` on the +// `unavailable` path throws here, surfacing the offending test directly. +type Level = "debug" | "info" | "warn"; +let logged: { level: Level; fields: Record }[] = []; +function record(level: Level) { + return (fields: Record) => { + IdempotencyLogFieldsSchema.parse(fields); + logged.push({ level, fields }); + }; +} const log = { - info: (fields: Record) => logged.push({ level: "info", fields }), - warn: (fields: Record) => logged.push({ level: "warn", fields }), + debug: record("debug"), + info: record("info"), + warn: record("warn"), } as unknown as Logger; -// Assert an emit with `event` was logged AND that its exact field object -// validates against the canonical schema. Parsing the real emitted object -// closes the drift hole a loose string check leaves open: a stray/misnamed -// field or a `reason` on the wrong event trips the strict schema here. +// Assert an emit with `event` was captured (its schema validity is already +// guaranteed by the capture-time parse above) and return its fields. function expectEmittedEvent(event: string): Record { const rec = logged.find((r) => r.fields.event === event); expect(rec).toBeDefined(); - expect(() => IdempotencyLogFieldsSchema.parse(rec?.fields)).not.toThrow(); return rec?.fields ?? {}; }