From 1d0e7278b57e50f45e086c353394881e5adb63c1 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Tue, 8 Sep 2026 08:23:46 +0200 Subject: [PATCH 1/6] docs(capture): specify preservation of explicit null properties --- acceptance/public/capture-ai.feature | 13 +++++++ acceptance/public/capture-exception.feature | 15 ++++++++ acceptance/public/capture.feature | 37 +++++++++++++++++++ .../.openspec.yaml | 2 + .../design.md | 34 +++++++++++++++++ .../proposal.md | 27 ++++++++++++++ .../specs/capture-ai/spec.md | 13 +++++++ .../specs/capture-exception/spec.md | 15 ++++++++ .../specs/capture/spec.md | 33 +++++++++++++++++ .../preserve-null-capture-properties/tasks.md | 11 ++++++ 10 files changed, 200 insertions(+) create mode 100644 openspec/changes/preserve-null-capture-properties/.openspec.yaml create mode 100644 openspec/changes/preserve-null-capture-properties/design.md create mode 100644 openspec/changes/preserve-null-capture-properties/proposal.md create mode 100644 openspec/changes/preserve-null-capture-properties/specs/capture-ai/spec.md create mode 100644 openspec/changes/preserve-null-capture-properties/specs/capture-exception/spec.md create mode 100644 openspec/changes/preserve-null-capture-properties/specs/capture/spec.md create mode 100644 openspec/changes/preserve-null-capture-properties/tasks.md diff --git a/acceptance/public/capture-ai.feature b/acceptance/public/capture-ai.feature index 98d469d..95941c2 100644 --- a/acceptance/public/capture-ai.feature +++ b/acceptance/public/capture-ai.feature @@ -18,6 +18,19 @@ Feature: Capture AI And the call should return the enqueued event's uuid And the enqueued event should include a timestamp and uuid + @server + Scenario: AI capture preserves null properties on its delivery route + Given the SDK is initialized with token "test-token" + And the SDK supports AI capture with no property-changing hooks or filters + When capture_ai is called with distinct id "user-123", event "$ai_generation", and custom properties represented by JSON: + """json + {"optional":null,"nested":{"value":null},"items":["first",null,"last"]} + """ + And the SDK is flushed + Then the event received on the AI endpoint should contain every supplied custom property with the same JSON value + And "items" should contain three elements with JSON null at index 1 + And the analytics endpoint should receive no events + @server Scenario: capture and capture_ai ride separate routes Given the SDK is initialized with token "test-token" diff --git a/acceptance/public/capture-exception.feature b/acceptance/public/capture-exception.feature index d449183..ad11f93 100644 --- a/acceptance/public/capture-exception.feature +++ b/acceptance/public/capture-exception.feature @@ -33,6 +33,21 @@ Feature: Capture Exception | handled | true | | area | checkout | + @both + Scenario: Exception capture preserves null custom properties on the wire + Given the SDK is initialized with token "test-token" + And capture has a valid distinct id and no property-changing hooks or filters + And a valid handled exception + When capture exception is called for the exception with additional custom properties represented by JSON: + """json + {"optional":null,"nested":{"value":null},"items":["first",null,"last"]} + """ + And the SDK is flushed + Then one "$exception" event should be received + And the received event should contain every supplied custom property with the same JSON value + And "items" should contain three elements with JSON null at index 1 + And the event should still include its SDK-generated exception data + @both Scenario: Exception capture normalizes non-standard thrown values Given the SDK is initialized with token "test-token" diff --git a/acceptance/public/capture.feature b/acceptance/public/capture.feature index f65f605..55ca854 100644 --- a/acceptance/public/capture.feature +++ b/acceptance/public/capture.feature @@ -42,6 +42,43 @@ Feature: Capture | $lib | any | And the enqueued event should include an event uuid + @both + Scenario: Queued capture preserves nulls on the wire + Given the SDK is initialized with token "test-token" + And capture has a valid distinct id and no property-changing hooks or filters + When capture is called with event "Nullable Properties" and custom properties represented by JSON: + """json + {"optional":null,"nested":{"value":null},"items":["first",null,"last"],"empty":"","zero":0,"enabled":false} + """ + And the SDK is flushed + Then the received event should contain every supplied custom property with the same JSON value + And "optional" and "nested.value" should be present with JSON null values, not the string "null" + And "items" should contain three elements with JSON null at index 1 + And the absent custom property "missing" should remain absent + + @both + Scenario: Immediate capture preserves nulls on the wire + Given the SDK is initialized with token "test-token" + And the SDK supports immediate delivery + And capture has a valid distinct id and no property-changing hooks or filters + And capture is configured for immediate delivery + When capture is called with event "Nullable Properties" and custom properties represented by JSON: + """json + {"optional":null,"nested":{"value":null},"items":["first",null,"last"]} + """ + And the immediate send completes + Then the received event should contain every supplied custom property with the same JSON value + And "items" should contain three elements with JSON null at index 1 + + @both + Scenario: JavaScript object undefined remains distinct from null + Given a JavaScript SDK is initialized with token "test-token" + And capture has a valid distinct id and no property-changing hooks or filters + When capture is called with event "Absent Versus Null" and JavaScript properties "{ optional: null, missing: undefined }" + And the SDK is flushed + Then the received event property "optional" should be present with JSON null + And the received event properties should not contain "missing" + @both Scenario: Capture honors opt-out state Given the SDK is initialized with token "test-token" diff --git a/openspec/changes/preserve-null-capture-properties/.openspec.yaml b/openspec/changes/preserve-null-capture-properties/.openspec.yaml new file mode 100644 index 0000000..7a8e2be --- /dev/null +++ b/openspec/changes/preserve-null-capture-properties/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-08 diff --git a/openspec/changes/preserve-null-capture-properties/design.md b/openspec/changes/preserve-null-capture-properties/design.md new file mode 100644 index 0000000..e4c4b96 --- /dev/null +++ b/openspec/changes/preserve-null-capture-properties/design.md @@ -0,0 +1,34 @@ +## Context + +The capture spec does not define null-property serialization. Python's `clean()` explicitly preserves `None`; `json.dumps(..., cls=DatetimeSerializer)` emits JSON `null`, recursively. Node/React Native, Go, Ruby, and iOS explicit `NSNull()` also preserve it. Android's `GsonSafeMapSerializer` instead skips null map values and list elements. This contract selects preservation, not the lowest common denominator. + +Evidence from the inspected SDK revisions: +- [Python PR #926](https://github.com/PostHog/posthog-python/pull/926) proposes changing existing top-level preservation. +- [Node/core payload construction](https://github.com/PostHog/posthog-js/blob/f902b705ed07efb4c78695646da6d6708ae082b9/packages/core/src/posthog-core-stateless.ts#L477-L501) retains caller values before JSON serialization. +- [iOS sanitizer](https://github.com/PostHog/posthog-ios/blob/611db7567af31263da4b80317bfe7d2340b8df40/PostHog/Utils/DictUtils.swift#L44-L89) accepts JSON-compatible values, including `NSNull()`. +- [Android serializer](https://github.com/PostHog/posthog-android/blob/0d7f567006bf24b6913d7fd00e025597dcbd605e/posthog/src/main/java/com/posthog/internal/GsonSafeMapSerializer.kt) drops nulls recursively. + +## Goals / Non-Goals + +**Goals:** Preserve caller-supplied JSON nulls on the wire for ordinary, immediate, AI, and exception capture. Make absent keys and null array positions unambiguous in acceptance scenarios. + +**Non-Goals:** SDK implementation changes, backend query/person-update semantics, accepting null for required or typed reserved fields, changing privacy hooks, or changing OTLP attribute encoding. + +## Decisions + +- Define one shared requirement in `capture`; reference it from AI and exception capture rather than duplicating policy. Explicit null is valid JSON data, not a serialization error or implicit deletion instruction. +- Preserve nulls recursively. Top-level-only preservation leaves nested custom payloads lossy; removing null array elements also changes positional meaning. +- Allow platform-idiomatic null representations (such as Swift `NSNull()`). A nullable properties argument is not the same as nullable entries; languages with non-nullable entry types need a way to represent explicit JSON null to conform. +- Keep absence distinct: an omitted key stays absent. JavaScript object `undefined` is not JSON null and can continue to be omitted by normal JSON serialization. This does not redefine JavaScript array serialization. +- Existing property precedence, reserved-field validation, consent, and explicitly configured filters still apply. Null preservation must not bypass privacy controls or populate missing exception metadata. +- Assert decoded wire JSON, not only queued dictionaries, for both queued and immediate delivery where supported. Spec-only Gherkin scenarios describe the contract; SDK adapters implement them separately. + +## Risks / Trade-offs + +- Existing null-dropping SDKs change emitted data when converging → require per-SDK compatibility review and release notes; do not bundle SDK changes here. +- Slightly larger payloads than stripping nulls → preserve caller intent; applications can deliberately filter unwanted values. +- Confusion with OTLP null omission → explicitly exclude logs and traces from this analytics contract. + +## Migration Plan + +Add delta requirements and matching acceptance scenarios, validate the change, then use OpenSpec archive to sync canonical specs. No SDK release or conformance claim is made by this spec change. diff --git a/openspec/changes/preserve-null-capture-properties/proposal.md b/openspec/changes/preserve-null-capture-properties/proposal.md new file mode 100644 index 0000000..283c08d --- /dev/null +++ b/openspec/changes/preserve-null-capture-properties/proposal.md @@ -0,0 +1,27 @@ +## Why + +JSON `null` is valid event data, and an explicitly present null-valued property is not the same as an absent key. Python PR [#926](https://github.com/PostHog/posthog-python/pull/926) proposes dropping those properties, exposing a gap in the capture contract despite existing SDKs preserving them. + +## What Changes + +- Require capture methods to accept explicit null-valued custom properties and preserve them as JSON `null`, including nested objects and array positions, for queued and immediate delivery. +- Distinguish null values from absent properties and JavaScript object properties containing `undefined`. +- Apply the same contract to caller-supplied properties on exception and AI capture. +- Keep caller-configured filtering, reserved-field validation, and OTLP logs/traces rules unchanged. +- Add wire-level acceptance scenarios. **BREAKING** for implementations that currently strip explicit nulls: conformance changes their emitted payloads; SDK rollout needs compatibility review. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `capture`: Define explicit null acceptance and preservation through serialization. +- `capture-ai`: Inherit the capture null-property contract on the AI route. +- `capture-exception`: Inherit the capture null-property contract for additional event properties. + +## Impact + +Specs and acceptance scenarios only; no SDK implementation changes. Python, Node/React Native, Go, Ruby, and iOS explicit `NSNull()` provide preservation precedent. Android currently filters nulls; non-nullable property APIs may require a platform-idiomatic null representation. This change does not claim every SDK already conforms. diff --git a/openspec/changes/preserve-null-capture-properties/specs/capture-ai/spec.md b/openspec/changes/preserve-null-capture-properties/specs/capture-ai/spec.md new file mode 100644 index 0000000..98fa7bb --- /dev/null +++ b/openspec/changes/preserve-null-capture-properties/specs/capture-ai/spec.md @@ -0,0 +1,13 @@ +## ADDED Requirements + +### Requirement: AI capture preserves explicit null properties + +`capture_ai` and its immediate/awaitable variants SHALL follow capture's "Explicit null capture properties are valid JSON data" requirement for caller-supplied custom properties, including nested object values and array elements. Using the AI route MUST NOT introduce null stripping. + +#### Scenario: AI capture preserves null properties on its delivery route (@server) +- **GIVEN** an initialized SDK supporting AI capture with no property-changing hooks or filters +- **WHEN** capture_ai is called with distinct id "user-123", event "$ai_generation", and custom properties represented by JSON `{"optional":null,"nested":{"value":null},"items":["first",null,"last"]}` +- **AND** the SDK is flushed +- **THEN** the event received on the AI endpoint should contain every supplied custom property with the same JSON value +- **AND** "items" should contain three elements with JSON null at index 1 +- **AND** the analytics endpoint should receive no events diff --git a/openspec/changes/preserve-null-capture-properties/specs/capture-exception/spec.md b/openspec/changes/preserve-null-capture-properties/specs/capture-exception/spec.md new file mode 100644 index 0000000..db908c3 --- /dev/null +++ b/openspec/changes/preserve-null-capture-properties/specs/capture-exception/spec.md @@ -0,0 +1,15 @@ +## ADDED Requirements + +### Requirement: Exception capture preserves explicit null custom properties + +`capture_exception` / `captureException` SHALL follow capture's "Explicit null capture properties are valid JSON data" requirement for caller-supplied additional custom event properties, including nested object values and array elements. This MUST NOT change exception-input validation or the field-specific omission rules for SDK-owned exception metadata. + +#### Scenario: Exception capture preserves null custom properties on the wire (@both) +- **GIVEN** an initialized SDK with a valid distinct id and no property-changing hooks or filters +- **AND** a valid handled exception +- **WHEN** capture exception is called for the exception with additional custom properties represented by JSON `{"optional":null,"nested":{"value":null},"items":["first",null,"last"]}` +- **AND** the SDK is flushed +- **THEN** one "$exception" event should be received +- **AND** the received event should contain every supplied custom property with the same JSON value +- **AND** "items" should contain three elements with JSON null at index 1 +- **AND** the event should still include its SDK-generated exception data diff --git a/openspec/changes/preserve-null-capture-properties/specs/capture/spec.md b/openspec/changes/preserve-null-capture-properties/specs/capture/spec.md new file mode 100644 index 0000000..ee38059 --- /dev/null +++ b/openspec/changes/preserve-null-capture-properties/specs/capture/spec.md @@ -0,0 +1,33 @@ +## ADDED Requirements + +### Requirement: Explicit null capture properties are valid JSON data + +The SDK SHALL accept explicit null-valued custom event properties using a platform-idiomatic JSON null representation (for example Python `None`, JavaScript `null`, or Swift `NSNull()`). It SHALL preserve those values as JSON `null` through event preparation, queueing, and wire serialization rather than rejecting the event, removing the key, or converting the value to a string solely because it is null. This applies recursively to nested objects and arrays; null array elements MUST retain their positions. Queued and immediate/synchronous capture variants SHALL follow the same rule. + +An explicitly present null-valued key is distinct from an absent key. The SDK MUST NOT insert null-valued keys for missing caller properties. JavaScript object properties containing `undefined` MAY remain omitted according to normal JSON serialization; `undefined` is not an explicit JSON null. + +This requirement concerns custom event property values, not a missing/null properties container, required envelope fields, or reserved fields with their own type/validation contracts. Existing enrichment precedence, consent gates, and caller-configured filtering or `before_send` mutations/drops remain authoritative. Without such an explicit filter or a field-specific rule, null alone MUST NOT trigger sanitization. The OTLP null-attribute omission requirements for logs and traces are unchanged. + +#### Scenario: Queued capture preserves nulls on the wire (@both) +- **GIVEN** an initialized SDK with a valid distinct id and no property-changing hooks or filters +- **WHEN** capture is called with event "Nullable Properties" and custom properties represented by JSON `{"optional":null,"nested":{"value":null},"items":["first",null,"last"],"empty":"","zero":0,"enabled":false}` +- **AND** the SDK is flushed +- **THEN** the received event should contain every supplied custom property with the same JSON value +- **AND** "optional" and "nested.value" should be present with JSON null values, not the string "null" +- **AND** "items" should contain three elements with JSON null at index 1 +- **AND** the absent custom property "missing" should remain absent + +#### Scenario: Immediate capture preserves nulls on the wire (@both) +- **GIVEN** an initialized SDK supporting immediate delivery with a valid distinct id and no property-changing hooks or filters +- **AND** capture is configured for immediate delivery +- **WHEN** capture is called with event "Nullable Properties" and custom properties represented by JSON `{"optional":null,"nested":{"value":null},"items":["first",null,"last"]}` +- **AND** the immediate send completes +- **THEN** the received event should contain every supplied custom property with the same JSON value +- **AND** "items" should contain three elements with JSON null at index 1 + +#### Scenario: JavaScript object undefined remains distinct from null (@both) +- **GIVEN** an initialized JavaScript SDK with a valid distinct id and no property-changing hooks or filters +- **WHEN** capture is called with event "Absent Versus Null" and properties `{ optional: null, missing: undefined }` +- **AND** the SDK is flushed +- **THEN** the received event property "optional" should be present with JSON null +- **AND** the received event properties should not contain "missing" diff --git a/openspec/changes/preserve-null-capture-properties/tasks.md b/openspec/changes/preserve-null-capture-properties/tasks.md new file mode 100644 index 0000000..e3c8f60 --- /dev/null +++ b/openspec/changes/preserve-null-capture-properties/tasks.md @@ -0,0 +1,11 @@ +## 1. Contract and acceptance coverage + +- [x] 1.1 Define null preservation in capture and reference it from AI and exception capture, with scoped exclusions and SDK compatibility evidence. +- [x] 1.2 Add matching wire-level acceptance scenarios for queued/immediate capture, undefined versus null, AI capture, and exception capture. + +## 2. Validation + +- [x] 2.1 Validate the OpenSpec change strictly and check acceptance syntax and scenario coverage. +- [x] 2.2 Review the delta against canonical specs for scope and compatibility before archive sync. + +Validation: strict change validation passed; all 62 existing canonical specs passed strict validation; all three edited Gherkin files parsed; five new scenarios matched their delta specs and JSON examples parsed successfully; `git diff --check` passed. These are specification checks, not SDK conformance tests. Canonical sync/archive awaits review per repository workflow. From f37cb0db11a59cf92089e220f41632c031c255ae Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Tue, 8 Sep 2026 08:30:56 +0200 Subject: [PATCH 2/6] docs(capture): sync and archive null property specification --- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/capture-ai/spec.md | 0 .../specs/capture-exception/spec.md | 0 .../specs/capture/spec.md | 0 .../tasks.md | 2 +- openspec/specs/capture-ai/spec.md | 14 ++++++-- openspec/specs/capture-exception/spec.md | 16 +++++++-- openspec/specs/capture/spec.md | 34 +++++++++++++++++-- 10 files changed, 59 insertions(+), 7 deletions(-) rename openspec/changes/{preserve-null-capture-properties => archive/2026-09-08-preserve-null-capture-properties}/.openspec.yaml (100%) rename openspec/changes/{preserve-null-capture-properties => archive/2026-09-08-preserve-null-capture-properties}/design.md (100%) rename openspec/changes/{preserve-null-capture-properties => archive/2026-09-08-preserve-null-capture-properties}/proposal.md (100%) rename openspec/changes/{preserve-null-capture-properties => archive/2026-09-08-preserve-null-capture-properties}/specs/capture-ai/spec.md (100%) rename openspec/changes/{preserve-null-capture-properties => archive/2026-09-08-preserve-null-capture-properties}/specs/capture-exception/spec.md (100%) rename openspec/changes/{preserve-null-capture-properties => archive/2026-09-08-preserve-null-capture-properties}/specs/capture/spec.md (100%) rename openspec/changes/{preserve-null-capture-properties => archive/2026-09-08-preserve-null-capture-properties}/tasks.md (85%) diff --git a/openspec/changes/preserve-null-capture-properties/.openspec.yaml b/openspec/changes/archive/2026-09-08-preserve-null-capture-properties/.openspec.yaml similarity index 100% rename from openspec/changes/preserve-null-capture-properties/.openspec.yaml rename to openspec/changes/archive/2026-09-08-preserve-null-capture-properties/.openspec.yaml diff --git a/openspec/changes/preserve-null-capture-properties/design.md b/openspec/changes/archive/2026-09-08-preserve-null-capture-properties/design.md similarity index 100% rename from openspec/changes/preserve-null-capture-properties/design.md rename to openspec/changes/archive/2026-09-08-preserve-null-capture-properties/design.md diff --git a/openspec/changes/preserve-null-capture-properties/proposal.md b/openspec/changes/archive/2026-09-08-preserve-null-capture-properties/proposal.md similarity index 100% rename from openspec/changes/preserve-null-capture-properties/proposal.md rename to openspec/changes/archive/2026-09-08-preserve-null-capture-properties/proposal.md diff --git a/openspec/changes/preserve-null-capture-properties/specs/capture-ai/spec.md b/openspec/changes/archive/2026-09-08-preserve-null-capture-properties/specs/capture-ai/spec.md similarity index 100% rename from openspec/changes/preserve-null-capture-properties/specs/capture-ai/spec.md rename to openspec/changes/archive/2026-09-08-preserve-null-capture-properties/specs/capture-ai/spec.md diff --git a/openspec/changes/preserve-null-capture-properties/specs/capture-exception/spec.md b/openspec/changes/archive/2026-09-08-preserve-null-capture-properties/specs/capture-exception/spec.md similarity index 100% rename from openspec/changes/preserve-null-capture-properties/specs/capture-exception/spec.md rename to openspec/changes/archive/2026-09-08-preserve-null-capture-properties/specs/capture-exception/spec.md diff --git a/openspec/changes/preserve-null-capture-properties/specs/capture/spec.md b/openspec/changes/archive/2026-09-08-preserve-null-capture-properties/specs/capture/spec.md similarity index 100% rename from openspec/changes/preserve-null-capture-properties/specs/capture/spec.md rename to openspec/changes/archive/2026-09-08-preserve-null-capture-properties/specs/capture/spec.md diff --git a/openspec/changes/preserve-null-capture-properties/tasks.md b/openspec/changes/archive/2026-09-08-preserve-null-capture-properties/tasks.md similarity index 85% rename from openspec/changes/preserve-null-capture-properties/tasks.md rename to openspec/changes/archive/2026-09-08-preserve-null-capture-properties/tasks.md index e3c8f60..47d0e63 100644 --- a/openspec/changes/preserve-null-capture-properties/tasks.md +++ b/openspec/changes/archive/2026-09-08-preserve-null-capture-properties/tasks.md @@ -8,4 +8,4 @@ - [x] 2.1 Validate the OpenSpec change strictly and check acceptance syntax and scenario coverage. - [x] 2.2 Review the delta against canonical specs for scope and compatibility before archive sync. -Validation: strict change validation passed; all 62 existing canonical specs passed strict validation; all three edited Gherkin files parsed; five new scenarios matched their delta specs and JSON examples parsed successfully; `git diff --check` passed. These are specification checks, not SDK conformance tests. Canonical sync/archive awaits review per repository workflow. +Validation: strict change validation passed; all 62 existing canonical specs passed strict validation; all three edited Gherkin files parsed; five new scenarios matched their delta specs and JSON examples parsed successfully; `git diff --check` passed. These are specification checks, not SDK conformance tests. Canonical specs were synced through `openspec archive` on 2026-09-08 after owner approval, and all 62 canonical specs passed strict validation again. diff --git a/openspec/specs/capture-ai/spec.md b/openspec/specs/capture-ai/spec.md index 24094cb..827b57a 100644 --- a/openspec/specs/capture-ai/spec.md +++ b/openspec/specs/capture-ai/spec.md @@ -120,9 +120,7 @@ shape), 8 MiB per-event cap, ~5 MiB batch target, compression per SDK default retries with backoff. Capture v1 re-implements the same contract on `/i/v1/ai/...` with per-event outcomes; that cutover must not change anything in the Requirements below. - ## Requirements - ### Requirement: Canonical capture_ai behavior Server SDKs that support AI capture SHALL implement `capture_ai` as described @@ -205,3 +203,15 @@ SDK's AI wrapper libraries capture. It SHALL NOT alter payloads passed to - **AND** the client has privacy mode enabled and the flag set to true - **THEN** the captured event should carry no input or output content +### Requirement: AI capture preserves explicit null properties + +`capture_ai` and its immediate/awaitable variants SHALL follow capture's "Explicit null capture properties are valid JSON data" requirement for caller-supplied custom properties, including nested object values and array elements. Using the AI route MUST NOT introduce null stripping. + +#### Scenario: AI capture preserves null properties on its delivery route (@server) +- **GIVEN** an initialized SDK supporting AI capture with no property-changing hooks or filters +- **WHEN** capture_ai is called with distinct id "user-123", event "$ai_generation", and custom properties represented by JSON `{"optional":null,"nested":{"value":null},"items":["first",null,"last"]}` +- **AND** the SDK is flushed +- **THEN** the event received on the AI endpoint should contain every supplied custom property with the same JSON value +- **AND** "items" should contain three elements with JSON null at index 1 +- **AND** the analytics endpoint should receive no events + diff --git a/openspec/specs/capture-exception/spec.md b/openspec/specs/capture-exception/spec.md index a1e6c2c..c455b59 100644 --- a/openspec/specs/capture-exception/spec.md +++ b/openspec/specs/capture-exception/spec.md @@ -84,9 +84,7 @@ captureException( - **Error-tracking processors/builders** — these convert raw errors/exceptions into the structured PostHog exception property format. - **Event batcher / retry queue** — `$exception` events are delivered through the same batching/retry infrastructure as other events. - **Autocaptured exception systems** — this is the manual companion to automatic exception capture; both typically produce the same event family. - ## Requirements - ### Requirement: Canonical capture-exception behavior The SDK SHALL implement the canonical `capture-exception` behavior described by this spec. Implementations MAY adapt method names, parameter casing, type syntax, and lifecycle hooks to platform idioms where this spec explicitly allows variation, but MUST preserve the observable outcomes in the scenarios below. @@ -256,3 +254,17 @@ When the supplied error-like input already carries stack trace information (for - **THEN** one event named "$exception" should be enqueued - **AND** the enqueued exception's stacktrace frames should reflect the pre-existing stack trace, not a synthesized single-frame stack + +### Requirement: Exception capture preserves explicit null custom properties + +`capture_exception` / `captureException` SHALL follow capture's "Explicit null capture properties are valid JSON data" requirement for caller-supplied additional custom event properties, including nested object values and array elements. This MUST NOT change exception-input validation or the field-specific omission rules for SDK-owned exception metadata. + +#### Scenario: Exception capture preserves null custom properties on the wire (@both) +- **GIVEN** an initialized SDK with a valid distinct id and no property-changing hooks or filters +- **AND** a valid handled exception +- **WHEN** capture exception is called for the exception with additional custom properties represented by JSON `{"optional":null,"nested":{"value":null},"items":["first",null,"last"]}` +- **AND** the SDK is flushed +- **THEN** one "$exception" event should be received +- **AND** the received event should contain every supplied custom property with the same JSON value +- **AND** "items" should contain three elements with JSON null at index 1 +- **AND** the event should still include its SDK-generated exception data diff --git a/openspec/specs/capture/spec.md b/openspec/specs/capture/spec.md index e07abae..78f8dfd 100644 --- a/openspec/specs/capture/spec.md +++ b/openspec/specs/capture/spec.md @@ -143,9 +143,7 @@ The following steps are the canonical flow for a single capture call. Client and - **Autocapture / pageview / heatmaps** — these features internally call capture with reserved event names (`$autocapture`, `$pageview`, `$pageleave`, `$heatmap`, `$web_vitals`). - **Exception capture** — `captureException` wraps `capture` with event name `$exception` and a normalized stacktrace property. Calling `capture('$exception', ...)` directly produces a warning in SDKs that ship `captureException` (posthog-js, posthog-python). - **Before-send hook / privacy filters** — capture is the integration point for drop/mutate hooks; see the before-send hook spec. - ## Requirements - ### Requirement: Canonical capture behavior The SDK SHALL implement the canonical `capture` behavior described by this spec. Implementations MAY adapt method names, parameter casing, type syntax, and lifecycle hooks to platform idioms where this spec explicitly allows variation, but MUST preserve the observable outcomes in the scenarios below. @@ -228,3 +226,35 @@ The SDK SHALL implement the canonical `capture` behavior described by this spec. - **AND** no event named "Sensitive Event" should be enqueued - **AND** no network request should be sent - **AND** the SDK should record a before-send warning + +### Requirement: Explicit null capture properties are valid JSON data + +The SDK SHALL accept explicit null-valued custom event properties using a platform-idiomatic JSON null representation (for example Python `None`, JavaScript `null`, or Swift `NSNull()`). It SHALL preserve those values as JSON `null` through event preparation, queueing, and wire serialization rather than rejecting the event, removing the key, or converting the value to a string solely because it is null. This applies recursively to nested objects and arrays; null array elements MUST retain their positions. Queued and immediate/synchronous capture variants SHALL follow the same rule. + +An explicitly present null-valued key is distinct from an absent key. The SDK MUST NOT insert null-valued keys for missing caller properties. JavaScript object properties containing `undefined` MAY remain omitted according to normal JSON serialization; `undefined` is not an explicit JSON null. + +This requirement concerns custom event property values, not a missing/null properties container, required envelope fields, or reserved fields with their own type/validation contracts. Existing enrichment precedence, consent gates, and caller-configured filtering or `before_send` mutations/drops remain authoritative. Without such an explicit filter or a field-specific rule, null alone MUST NOT trigger sanitization. The OTLP null-attribute omission requirements for logs and traces are unchanged. + +#### Scenario: Queued capture preserves nulls on the wire (@both) +- **GIVEN** an initialized SDK with a valid distinct id and no property-changing hooks or filters +- **WHEN** capture is called with event "Nullable Properties" and custom properties represented by JSON `{"optional":null,"nested":{"value":null},"items":["first",null,"last"],"empty":"","zero":0,"enabled":false}` +- **AND** the SDK is flushed +- **THEN** the received event should contain every supplied custom property with the same JSON value +- **AND** "optional" and "nested.value" should be present with JSON null values, not the string "null" +- **AND** "items" should contain three elements with JSON null at index 1 +- **AND** the absent custom property "missing" should remain absent + +#### Scenario: Immediate capture preserves nulls on the wire (@both) +- **GIVEN** an initialized SDK supporting immediate delivery with a valid distinct id and no property-changing hooks or filters +- **AND** capture is configured for immediate delivery +- **WHEN** capture is called with event "Nullable Properties" and custom properties represented by JSON `{"optional":null,"nested":{"value":null},"items":["first",null,"last"]}` +- **AND** the immediate send completes +- **THEN** the received event should contain every supplied custom property with the same JSON value +- **AND** "items" should contain three elements with JSON null at index 1 + +#### Scenario: JavaScript object undefined remains distinct from null (@both) +- **GIVEN** an initialized JavaScript SDK with a valid distinct id and no property-changing hooks or filters +- **WHEN** capture is called with event "Absent Versus Null" and properties `{ optional: null, missing: undefined }` +- **AND** the SDK is flushed +- **THEN** the received event property "optional" should be present with JSON null +- **AND** the received event properties should not contain "missing" From 94bc4cda74d575e192de94a7362162a804e97be6 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Tue, 8 Sep 2026 10:28:40 +0200 Subject: [PATCH 3/6] docs(capture): align batch endpoint wording in scenarios --- acceptance/public/capture-ai.feature | 4 ++-- .../specs/capture-ai/spec.md | 4 ++-- openspec/specs/capture-ai/spec.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/acceptance/public/capture-ai.feature b/acceptance/public/capture-ai.feature index 95941c2..2573b1a 100644 --- a/acceptance/public/capture-ai.feature +++ b/acceptance/public/capture-ai.feature @@ -27,9 +27,9 @@ Feature: Capture AI {"optional":null,"nested":{"value":null},"items":["first",null,"last"]} """ And the SDK is flushed - Then the event received on the AI endpoint should contain every supplied custom property with the same JSON value + Then the event received on the AI batch endpoint should contain every supplied custom property with the same JSON value And "items" should contain three elements with JSON null at index 1 - And the analytics endpoint should receive no events + And the analytics batch endpoint should receive no events @server Scenario: capture and capture_ai ride separate routes diff --git a/openspec/changes/archive/2026-09-08-preserve-null-capture-properties/specs/capture-ai/spec.md b/openspec/changes/archive/2026-09-08-preserve-null-capture-properties/specs/capture-ai/spec.md index 98fa7bb..cef8c8f 100644 --- a/openspec/changes/archive/2026-09-08-preserve-null-capture-properties/specs/capture-ai/spec.md +++ b/openspec/changes/archive/2026-09-08-preserve-null-capture-properties/specs/capture-ai/spec.md @@ -8,6 +8,6 @@ - **GIVEN** an initialized SDK supporting AI capture with no property-changing hooks or filters - **WHEN** capture_ai is called with distinct id "user-123", event "$ai_generation", and custom properties represented by JSON `{"optional":null,"nested":{"value":null},"items":["first",null,"last"]}` - **AND** the SDK is flushed -- **THEN** the event received on the AI endpoint should contain every supplied custom property with the same JSON value +- **THEN** the event received on the AI batch endpoint should contain every supplied custom property with the same JSON value - **AND** "items" should contain three elements with JSON null at index 1 -- **AND** the analytics endpoint should receive no events +- **AND** the analytics batch endpoint should receive no events diff --git a/openspec/specs/capture-ai/spec.md b/openspec/specs/capture-ai/spec.md index 827b57a..7bcb5ae 100644 --- a/openspec/specs/capture-ai/spec.md +++ b/openspec/specs/capture-ai/spec.md @@ -211,7 +211,7 @@ SDK's AI wrapper libraries capture. It SHALL NOT alter payloads passed to - **GIVEN** an initialized SDK supporting AI capture with no property-changing hooks or filters - **WHEN** capture_ai is called with distinct id "user-123", event "$ai_generation", and custom properties represented by JSON `{"optional":null,"nested":{"value":null},"items":["first",null,"last"]}` - **AND** the SDK is flushed -- **THEN** the event received on the AI endpoint should contain every supplied custom property with the same JSON value +- **THEN** the event received on the AI batch endpoint should contain every supplied custom property with the same JSON value - **AND** "items" should contain three elements with JSON null at index 1 -- **AND** the analytics endpoint should receive no events +- **AND** the analytics batch endpoint should receive no events From eab08ea761546030f6bf932d14320f1b90c64d45 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Tue, 8 Sep 2026 15:11:30 +0200 Subject: [PATCH 4/6] docs(capture): drop null object properties but preserve array elements --- acceptance/public/capture-ai.feature | 11 +-- acceptance/public/capture-exception.feature | 10 +-- acceptance/public/capture.feature | 69 +++++++++++++++---- .../.openspec.yaml | 2 + .../design.md | 30 ++++++++ .../proposal.md | 28 ++++++++ .../specs/capture-ai/spec.md | 20 ++++++ .../specs/capture-exception/spec.md | 21 ++++++ .../specs/capture/spec.md | 57 +++++++++++++++ .../tasks.md | 11 +++ .../proposal.md | 2 + openspec/specs/capture-ai/spec.md | 12 ++-- openspec/specs/capture-exception/spec.md | 11 ++- openspec/specs/capture/spec.md | 51 +++++++++----- 14 files changed, 285 insertions(+), 50 deletions(-) create mode 100644 openspec/changes/archive/2026-09-08-drop-null-capture-properties/.openspec.yaml create mode 100644 openspec/changes/archive/2026-09-08-drop-null-capture-properties/design.md create mode 100644 openspec/changes/archive/2026-09-08-drop-null-capture-properties/proposal.md create mode 100644 openspec/changes/archive/2026-09-08-drop-null-capture-properties/specs/capture-ai/spec.md create mode 100644 openspec/changes/archive/2026-09-08-drop-null-capture-properties/specs/capture-exception/spec.md create mode 100644 openspec/changes/archive/2026-09-08-drop-null-capture-properties/specs/capture/spec.md create mode 100644 openspec/changes/archive/2026-09-08-drop-null-capture-properties/tasks.md diff --git a/acceptance/public/capture-ai.feature b/acceptance/public/capture-ai.feature index 2573b1a..83f8c8a 100644 --- a/acceptance/public/capture-ai.feature +++ b/acceptance/public/capture-ai.feature @@ -19,16 +19,19 @@ Feature: Capture AI And the enqueued event should include a timestamp and uuid @server - Scenario: AI capture preserves null properties on its delivery route + Scenario: AI capture drops null-valued properties on its delivery route Given the SDK is initialized with token "test-token" And the SDK supports AI capture with no property-changing hooks or filters When capture_ai is called with distinct id "user-123", event "$ai_generation", and custom properties represented by JSON: """json - {"optional":null,"nested":{"value":null},"items":["first",null,"last"]} + {"test":null,"nested":{"drop":null},"items":["1",null,2]} """ And the SDK is flushed - Then the event received on the AI batch endpoint should contain every supplied custom property with the same JSON value - And "items" should contain three elements with JSON null at index 1 + Then one event named "$ai_generation" should be received on the AI batch endpoint + And its custom properties should equal JSON: + """json + {"nested":{},"items":["1",null,2]} + """ And the analytics batch endpoint should receive no events @server diff --git a/acceptance/public/capture-exception.feature b/acceptance/public/capture-exception.feature index ad11f93..65b345d 100644 --- a/acceptance/public/capture-exception.feature +++ b/acceptance/public/capture-exception.feature @@ -34,18 +34,20 @@ Feature: Capture Exception | area | checkout | @both - Scenario: Exception capture preserves null custom properties on the wire + Scenario: Exception capture drops null-valued custom properties on the wire Given the SDK is initialized with token "test-token" And capture has a valid distinct id and no property-changing hooks or filters And a valid handled exception When capture exception is called for the exception with additional custom properties represented by JSON: """json - {"optional":null,"nested":{"value":null},"items":["first",null,"last"]} + {"test":null,"nested":{"drop":null},"items":["1",null,2]} """ And the SDK is flushed Then one "$exception" event should be received - And the received event should contain every supplied custom property with the same JSON value - And "items" should contain three elements with JSON null at index 1 + And its custom properties should equal JSON: + """json + {"nested":{},"items":["1",null,2]} + """ And the event should still include its SDK-generated exception data @both diff --git a/acceptance/public/capture.feature b/acceptance/public/capture.feature index 55ca854..3037df7 100644 --- a/acceptance/public/capture.feature +++ b/acceptance/public/capture.feature @@ -43,41 +43,84 @@ Feature: Capture And the enqueued event should include an event uuid @both - Scenario: Queued capture preserves nulls on the wire + Scenario: Queued capture drops null-valued properties and preserves array positions Given the SDK is initialized with token "test-token" And capture has a valid distinct id and no property-changing hooks or filters When capture is called with event "Nullable Properties" and custom properties represented by JSON: """json - {"optional":null,"nested":{"value":null},"items":["first",null,"last"],"empty":"","zero":0,"enabled":false} + {"test":null,"nested":{"drop":null},"items":["1",null,2,{"drop":null},[null]],"empty":"","zero":0,"enabled":false,"literal":"null","emptyArray":[]} """ And the SDK is flushed - Then the received event should contain every supplied custom property with the same JSON value - And "optional" and "nested.value" should be present with JSON null values, not the string "null" - And "items" should contain three elements with JSON null at index 1 + Then one event named "Nullable Properties" should be received + And its custom properties should equal JSON: + """json + {"nested":{},"items":["1",null,2,{},[null]],"empty":"","zero":0,"enabled":false,"literal":"null","emptyArray":[]} + """ And the absent custom property "missing" should remain absent @both - Scenario: Immediate capture preserves nulls on the wire + Scenario: Immediate capture drops null-valued properties and preserves array positions Given the SDK is initialized with token "test-token" And the SDK supports immediate delivery And capture has a valid distinct id and no property-changing hooks or filters And capture is configured for immediate delivery When capture is called with event "Nullable Properties" and custom properties represented by JSON: """json - {"optional":null,"nested":{"value":null},"items":["first",null,"last"]} + {"test":null,"nested":{"drop":null},"items":["1",null,2,{"drop":null},[null]]} """ And the immediate send completes - Then the received event should contain every supplied custom property with the same JSON value - And "items" should contain three elements with JSON null at index 1 + Then one event named "Nullable Properties" should be received + And its custom properties should equal JSON: + """json + {"nested":{},"items":["1",null,2,{},[null]]} + """ + + @both + Scenario: All-null custom properties do not drop the event + Given the SDK is initialized with token "test-token" + And capture has a valid distinct id and no property-changing hooks or filters + When capture is called with event "Only Null Properties" and custom properties represented by JSON: + """json + {"test":null} + """ + And the SDK is flushed + Then one event named "Only Null Properties" should be received + And its custom properties should equal JSON: + """json + {} + """ + And it should retain its normal SDK metadata + + @both + Scenario: Null object properties introduced by before-send are omitted + Given the SDK is initialized with token "test-token" + And the SDK supports before-send with a valid distinct id + And before-send adds custom properties represented by JSON: + """json + {"hookNull":null,"hookItems":[null,{"drop":null}]} + """ + When capture is called with event "Hook Properties" and no custom properties + And the SDK is flushed + Then one event named "Hook Properties" should be received + And its custom properties should equal JSON: + """json + {"hookItems":[null,{}]} + """ @both - Scenario: JavaScript object undefined remains distinct from null + Scenario: JavaScript null and undefined object properties are omitted Given a JavaScript SDK is initialized with token "test-token" And capture has a valid distinct id and no property-changing hooks or filters - When capture is called with event "Absent Versus Null" and JavaScript properties "{ optional: null, missing: undefined }" + When capture is called with event "Null And Undefined" and JavaScript properties: + """javascript + { test: null, missing: undefined, items: ["1", null, 2] } + """ And the SDK is flushed - Then the received event property "optional" should be present with JSON null - And the received event properties should not contain "missing" + Then one event named "Null And Undefined" should be received + And its custom properties should equal JSON: + """json + {"items":["1",null,2]} + """ @both Scenario: Capture honors opt-out state diff --git a/openspec/changes/archive/2026-09-08-drop-null-capture-properties/.openspec.yaml b/openspec/changes/archive/2026-09-08-drop-null-capture-properties/.openspec.yaml new file mode 100644 index 0000000..7a8e2be --- /dev/null +++ b/openspec/changes/archive/2026-09-08-drop-null-capture-properties/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-08 diff --git a/openspec/changes/archive/2026-09-08-drop-null-capture-properties/design.md b/openspec/changes/archive/2026-09-08-drop-null-capture-properties/design.md new file mode 100644 index 0000000..dce974c --- /dev/null +++ b/openspec/changes/archive/2026-09-08-drop-null-capture-properties/design.md @@ -0,0 +1,30 @@ +## Context + +The owner revised the null policy after the initial preservation proposal was archived on this unmerged PR. Backend removal is planned and still in progress. The new target distinguishes object members from array elements, rather than treating all JSON nulls alike. + +Android already drops null-valued map entries, although its current serializer also drops null list elements and therefore does not fully match this target. Python PR #926 proposes top-level omission. Neither is evidence that recursive object-key omission with array-position preservation is already uniform across SDKs. + +## Goals / Non-Goals + +**Goals:** Omit null-valued custom object members before transmission for all capture variants, while preserving null array elements. Give each contract an explicit input and expected wire output. + +**Non-Goals:** SDK/backend implementation, rollout timing, changing required or reserved-field validation, null exception inputs, or OTLP attribute encoding. + +## Decisions + +- Remove null-valued object members recursively, including members of objects contained in arrays. Top-level-only filtering would leave the same kind of null-valued property in nested payloads. +- Preserve array length, ordering, and explicit null elements. For example `["1", null, 2]` is sent unchanged. Objects within arrays are cleaned without removing their array slots, and objects made empty by cleanup remain `{}`. +- Do not reject the event when a property is null, and do not drop an event merely because all custom properties were removed. Preserve `false`, `0`, empty strings, empty arrays/objects, and the literal string `"null"`. +- Make the invariant hold after enrichment and `before_send`, including null-valued members introduced by a hook. Existing privacy drops/mutations remain effective; cleanup never restores data removed by a hook. +- Apply the shared rule to AI capture as a specific normalization exception to its payload pass-through promise. It is not permission to add redaction, truncation, or media processing to manual AI capture. +- Replace the earlier preservation requirements through a new archived delta. Retain the original archive as historical evidence, explicitly marked superseded, rather than rewriting git history. + +## Risks / Trade-offs + +- Changing SDKs that preserve object nulls changes their wire payloads → SDK-specific compatibility review and rollout remain separate. +- Backend normalization is not fully deployed → document it as the planned target, not existing universal behavior. +- Recursive cleanup could accidentally compact arrays or discard empty objects → acceptance fixtures include both positional nulls and objects emptied inside arrays. + +## Migration Plan + +Revise the three capture requirements and acceptance scenarios, validate the delta and Gherkin examples, then sync and archive on the existing PR branch. No SDK conformance is asserted by these specification-only checks. diff --git a/openspec/changes/archive/2026-09-08-drop-null-capture-properties/proposal.md b/openspec/changes/archive/2026-09-08-drop-null-capture-properties/proposal.md new file mode 100644 index 0000000..e6e1b8d --- /dev/null +++ b/openspec/changes/archive/2026-09-08-drop-null-capture-properties/proposal.md @@ -0,0 +1,28 @@ +## Why + +The owner has selected omission of null-valued object properties to align SDK output with planned backend removal. That backend work is still in progress; this change does not claim it is already deployed. Null array elements remain valid because removing them would change array positions. + +## What Changes + +- Supersede the earlier `preserve-null-capture-properties` decision in this PR. +- Drop null-valued custom object properties before sending, recursively through nested objects, including objects inside arrays. +- Preserve null array elements, array order, non-null values, and objects left empty by filtering. +- Apply the same rule to queued, immediate, AI, and exception capture without rejecting an otherwise valid event. +- Update acceptance scenarios to assert the normalized wire payload rather than the original input. +- **BREAKING** for SDKs that currently preserve null-valued object properties. SDK compatibility and rollout work remain separate. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `capture`: Replace null-property preservation with recursive object-key omission, retaining null array elements. +- `capture-ai`: Apply the revised capture normalization contract on the AI route. +- `capture-exception`: Apply the revised contract to additional custom properties. + +## Impact + +Specifications and acceptance scenarios only. No SDK or backend implementation changes. The old archive remains as historical context and is superseded by this change. Existing privacy controls, reserved-field validation, and OTLP attribute rules remain unchanged. diff --git a/openspec/changes/archive/2026-09-08-drop-null-capture-properties/specs/capture-ai/spec.md b/openspec/changes/archive/2026-09-08-drop-null-capture-properties/specs/capture-ai/spec.md new file mode 100644 index 0000000..091ee03 --- /dev/null +++ b/openspec/changes/archive/2026-09-08-drop-null-capture-properties/specs/capture-ai/spec.md @@ -0,0 +1,20 @@ +## REMOVED Requirements + +### Requirement: AI capture preserves explicit null properties + +**Reason**: AI capture follows the revised shared null-property contract. +**Migration**: Omit null-valued object members while preserving null array elements on the AI route. + +## ADDED Requirements + +### Requirement: AI capture drops null-valued object properties + +`capture_ai` and its immediate/awaitable variants SHALL follow capture's "Capture drops null-valued object properties" requirement for caller-supplied custom properties, including nested objects and objects inside arrays. Null array elements SHALL retain their positions. This is a specific normalization exception to manual AI capture's payload pass-through promise, not permission to add redaction, truncation, or media processing. + +#### Scenario: AI capture drops null-valued properties on its delivery route (@server) +- **GIVEN** an initialized SDK supporting AI capture with no property-changing hooks or filters +- **WHEN** capture_ai is called with distinct id "user-123", event "$ai_generation", and custom properties represented by JSON `{"test":null,"nested":{"drop":null},"items":["1",null,2]}` +- **AND** the SDK is flushed +- **THEN** one event named "$ai_generation" should be received on the AI batch endpoint +- **AND** its custom properties should equal JSON `{"nested":{},"items":["1",null,2]}` +- **AND** the analytics batch endpoint should receive no events diff --git a/openspec/changes/archive/2026-09-08-drop-null-capture-properties/specs/capture-exception/spec.md b/openspec/changes/archive/2026-09-08-drop-null-capture-properties/specs/capture-exception/spec.md new file mode 100644 index 0000000..5e50f52 --- /dev/null +++ b/openspec/changes/archive/2026-09-08-drop-null-capture-properties/specs/capture-exception/spec.md @@ -0,0 +1,21 @@ +## REMOVED Requirements + +### Requirement: Exception capture preserves explicit null custom properties + +**Reason**: Exception capture follows the revised shared null-property contract. +**Migration**: Omit null-valued custom object members while preserving null array elements without changing SDK-owned exception metadata rules. + +## ADDED Requirements + +### Requirement: Exception capture drops null-valued custom object properties + +`capture_exception` / `captureException` SHALL follow capture's "Capture drops null-valued object properties" requirement for caller-supplied additional custom event properties, including nested objects and objects inside arrays. Null array elements SHALL retain their positions. This MUST NOT change exception-input validation or field-specific rules for SDK-owned exception metadata. + +#### Scenario: Exception capture drops null-valued custom properties on the wire (@both) +- **GIVEN** an initialized SDK with a valid distinct id and no property-changing hooks or filters +- **AND** a valid handled exception +- **WHEN** capture exception is called for the exception with additional custom properties represented by JSON `{"test":null,"nested":{"drop":null},"items":["1",null,2]}` +- **AND** the SDK is flushed +- **THEN** one "$exception" event should be received +- **AND** its custom properties should equal JSON `{"nested":{},"items":["1",null,2]}` +- **AND** the event should still include its SDK-generated exception data diff --git a/openspec/changes/archive/2026-09-08-drop-null-capture-properties/specs/capture/spec.md b/openspec/changes/archive/2026-09-08-drop-null-capture-properties/specs/capture/spec.md new file mode 100644 index 0000000..5edbb7d --- /dev/null +++ b/openspec/changes/archive/2026-09-08-drop-null-capture-properties/specs/capture/spec.md @@ -0,0 +1,57 @@ +## REMOVED Requirements + +### Requirement: Explicit null capture properties are valid JSON data + +**Reason**: Superseded by the owner's decision to omit null-valued object properties while retaining null array elements. +**Migration**: Drop null-valued object members before sending instead of preserving them. Do not reject the event or compact arrays. + +## ADDED Requirements + +### Requirement: Capture drops null-valued object properties + +The SDK SHALL omit custom event object properties whose values are explicit nulls (for example Python `None`, JavaScript `null`, or Swift `NSNull()`) before sending the event. A null-valued property SHALL NOT cause an otherwise valid event to be rejected, and the SDK MUST NOT send that property as JSON `null` or convert it to the string `"null"`. + +This cleanup SHALL apply recursively to nested objects, including objects inside arrays. Null array elements SHALL remain JSON `null` at their original indices; cleanup MUST NOT compact or reorder arrays. Non-null values, including `false`, `0`, empty strings, the literal string `"null"`, and empty objects/arrays, SHALL remain unchanged by this cleanup. Objects made empty by removing null-valued members SHALL remain `{}`, including when they occupy an array slot. If all custom properties are removed, the SDK SHALL still send an otherwise admissible event with its normal SDK metadata. + +The same rule SHALL apply to queued and immediate/synchronous capture. It SHALL hold after enrichment and `before_send` processing, including for null-valued custom object members introduced by a hook. JavaScript object properties containing `undefined` SHALL remain omitted under normal JSON serialization, and missing custom keys MUST NOT be synthesized. Caller-configured privacy filtering and event drops remain authoritative; cleanup MUST NOT restore removed data. + +This requirement concerns custom event property values, not a missing/null properties container, required envelope fields, or reserved fields with their own type/validation contracts. It does not change those field-specific rules or the OTLP attribute rules for logs and traces. + +#### Scenario: Queued capture drops null-valued properties and preserves array positions (@both) +- **GIVEN** an initialized SDK with a valid distinct id and no property-changing hooks or filters +- **WHEN** capture is called with event "Nullable Properties" and custom properties represented by JSON `{"test":null,"nested":{"drop":null},"items":["1",null,2,{"drop":null},[null]],"empty":"","zero":0,"enabled":false,"literal":"null","emptyArray":[]}` +- **AND** the SDK is flushed +- **THEN** one event named "Nullable Properties" should be received +- **AND** its custom properties should equal JSON `{"nested":{},"items":["1",null,2,{},[null]],"empty":"","zero":0,"enabled":false,"literal":"null","emptyArray":[]}` +- **AND** the absent custom property "missing" should remain absent + +#### Scenario: Immediate capture drops null-valued properties and preserves array positions (@both) +- **GIVEN** an initialized SDK supporting immediate delivery with a valid distinct id and no property-changing hooks or filters +- **AND** capture is configured for immediate delivery +- **WHEN** capture is called with event "Nullable Properties" and custom properties represented by JSON `{"test":null,"nested":{"drop":null},"items":["1",null,2,{"drop":null},[null]]}` +- **AND** the immediate send completes +- **THEN** one event named "Nullable Properties" should be received +- **AND** its custom properties should equal JSON `{"nested":{},"items":["1",null,2,{},[null]]}` + +#### Scenario: All-null custom properties do not drop the event (@both) +- **GIVEN** an initialized SDK with a valid distinct id and no property-changing hooks or filters +- **WHEN** capture is called with event "Only Null Properties" and custom properties represented by JSON `{"test":null}` +- **AND** the SDK is flushed +- **THEN** one event named "Only Null Properties" should be received +- **AND** its custom properties should equal JSON `{}` +- **AND** it should retain its normal SDK metadata + +#### Scenario: Null object properties introduced by before-send are omitted (@both) +- **GIVEN** an initialized SDK supporting before-send with a valid distinct id +- **AND** before-send adds custom properties represented by JSON `{"hookNull":null,"hookItems":[null,{"drop":null}]}` +- **WHEN** capture is called with event "Hook Properties" and no custom properties +- **AND** the SDK is flushed +- **THEN** one event named "Hook Properties" should be received +- **AND** its custom properties should equal JSON `{"hookItems":[null,{}]}` + +#### Scenario: JavaScript null and undefined object properties are omitted (@both) +- **GIVEN** an initialized JavaScript SDK with a valid distinct id and no property-changing hooks or filters +- **WHEN** capture is called with event "Null And Undefined" and JavaScript properties `{ test: null, missing: undefined, items: ["1", null, 2] }` +- **AND** the SDK is flushed +- **THEN** one event named "Null And Undefined" should be received +- **AND** its custom properties should equal JSON `{"items":["1",null,2]}` diff --git a/openspec/changes/archive/2026-09-08-drop-null-capture-properties/tasks.md b/openspec/changes/archive/2026-09-08-drop-null-capture-properties/tasks.md new file mode 100644 index 0000000..89bc287 --- /dev/null +++ b/openspec/changes/archive/2026-09-08-drop-null-capture-properties/tasks.md @@ -0,0 +1,11 @@ +## 1. Revised contract + +- [x] 1.1 Replace the preservation requirements with null object-property omission and explicit array-position preservation. +- [x] 1.2 Revise capture, AI, and exception Gherkin scenarios and mark the previous decision superseded. + +## 2. Validation + +- [x] 2.1 Validate the delta strictly and check Gherkin scenario coverage and JSON input/output examples. +- [x] 2.2 Review scope and compatibility before syncing and archiving on the existing PR branch. + +Strict delta validation passed. All seven new/revised scenarios match their Gherkin counterparts; JSON input/output fixtures were checked against a reference object-key cleanup that preserves array positions. These are specification checks, not SDK conformance tests. The owner approved the replacement policy and requested the existing PR be updated. diff --git a/openspec/changes/archive/2026-09-08-preserve-null-capture-properties/proposal.md b/openspec/changes/archive/2026-09-08-preserve-null-capture-properties/proposal.md index 283c08d..d393bf0 100644 --- a/openspec/changes/archive/2026-09-08-preserve-null-capture-properties/proposal.md +++ b/openspec/changes/archive/2026-09-08-preserve-null-capture-properties/proposal.md @@ -1,3 +1,5 @@ +> Superseded on this PR by `drop-null-capture-properties`: omit null-valued object properties, but preserve null array elements. This archive records the earlier decision only. + ## Why JSON `null` is valid event data, and an explicitly present null-valued property is not the same as an absent key. Python PR [#926](https://github.com/PostHog/posthog-python/pull/926) proposes dropping those properties, exposing a gap in the capture contract despite existing SDKs preserving them. diff --git a/openspec/specs/capture-ai/spec.md b/openspec/specs/capture-ai/spec.md index 7bcb5ae..28ecdd7 100644 --- a/openspec/specs/capture-ai/spec.md +++ b/openspec/specs/capture-ai/spec.md @@ -203,15 +203,15 @@ SDK's AI wrapper libraries capture. It SHALL NOT alter payloads passed to - **AND** the client has privacy mode enabled and the flag set to true - **THEN** the captured event should carry no input or output content -### Requirement: AI capture preserves explicit null properties +### Requirement: AI capture drops null-valued object properties -`capture_ai` and its immediate/awaitable variants SHALL follow capture's "Explicit null capture properties are valid JSON data" requirement for caller-supplied custom properties, including nested object values and array elements. Using the AI route MUST NOT introduce null stripping. +`capture_ai` and its immediate/awaitable variants SHALL follow capture's "Capture drops null-valued object properties" requirement for caller-supplied custom properties, including nested objects and objects inside arrays. Null array elements SHALL retain their positions. This is a specific normalization exception to manual AI capture's payload pass-through promise, not permission to add redaction, truncation, or media processing. -#### Scenario: AI capture preserves null properties on its delivery route (@server) +#### Scenario: AI capture drops null-valued properties on its delivery route (@server) - **GIVEN** an initialized SDK supporting AI capture with no property-changing hooks or filters -- **WHEN** capture_ai is called with distinct id "user-123", event "$ai_generation", and custom properties represented by JSON `{"optional":null,"nested":{"value":null},"items":["first",null,"last"]}` +- **WHEN** capture_ai is called with distinct id "user-123", event "$ai_generation", and custom properties represented by JSON `{"test":null,"nested":{"drop":null},"items":["1",null,2]}` - **AND** the SDK is flushed -- **THEN** the event received on the AI batch endpoint should contain every supplied custom property with the same JSON value -- **AND** "items" should contain three elements with JSON null at index 1 +- **THEN** one event named "$ai_generation" should be received on the AI batch endpoint +- **AND** its custom properties should equal JSON `{"nested":{},"items":["1",null,2]}` - **AND** the analytics batch endpoint should receive no events diff --git a/openspec/specs/capture-exception/spec.md b/openspec/specs/capture-exception/spec.md index c455b59..4cc3a9a 100644 --- a/openspec/specs/capture-exception/spec.md +++ b/openspec/specs/capture-exception/spec.md @@ -255,16 +255,15 @@ When the supplied error-like input already carries stack trace information (for - **AND** the enqueued exception's stacktrace frames should reflect the pre-existing stack trace, not a synthesized single-frame stack -### Requirement: Exception capture preserves explicit null custom properties +### Requirement: Exception capture drops null-valued custom object properties -`capture_exception` / `captureException` SHALL follow capture's "Explicit null capture properties are valid JSON data" requirement for caller-supplied additional custom event properties, including nested object values and array elements. This MUST NOT change exception-input validation or the field-specific omission rules for SDK-owned exception metadata. +`capture_exception` / `captureException` SHALL follow capture's "Capture drops null-valued object properties" requirement for caller-supplied additional custom event properties, including nested objects and objects inside arrays. Null array elements SHALL retain their positions. This MUST NOT change exception-input validation or field-specific rules for SDK-owned exception metadata. -#### Scenario: Exception capture preserves null custom properties on the wire (@both) +#### Scenario: Exception capture drops null-valued custom properties on the wire (@both) - **GIVEN** an initialized SDK with a valid distinct id and no property-changing hooks or filters - **AND** a valid handled exception -- **WHEN** capture exception is called for the exception with additional custom properties represented by JSON `{"optional":null,"nested":{"value":null},"items":["first",null,"last"]}` +- **WHEN** capture exception is called for the exception with additional custom properties represented by JSON `{"test":null,"nested":{"drop":null},"items":["1",null,2]}` - **AND** the SDK is flushed - **THEN** one "$exception" event should be received -- **AND** the received event should contain every supplied custom property with the same JSON value -- **AND** "items" should contain three elements with JSON null at index 1 +- **AND** its custom properties should equal JSON `{"nested":{},"items":["1",null,2]}` - **AND** the event should still include its SDK-generated exception data diff --git a/openspec/specs/capture/spec.md b/openspec/specs/capture/spec.md index 78f8dfd..e9ef648 100644 --- a/openspec/specs/capture/spec.md +++ b/openspec/specs/capture/spec.md @@ -227,34 +227,51 @@ The SDK SHALL implement the canonical `capture` behavior described by this spec. - **AND** no network request should be sent - **AND** the SDK should record a before-send warning -### Requirement: Explicit null capture properties are valid JSON data +### Requirement: Capture drops null-valued object properties -The SDK SHALL accept explicit null-valued custom event properties using a platform-idiomatic JSON null representation (for example Python `None`, JavaScript `null`, or Swift `NSNull()`). It SHALL preserve those values as JSON `null` through event preparation, queueing, and wire serialization rather than rejecting the event, removing the key, or converting the value to a string solely because it is null. This applies recursively to nested objects and arrays; null array elements MUST retain their positions. Queued and immediate/synchronous capture variants SHALL follow the same rule. +The SDK SHALL omit custom event object properties whose values are explicit nulls (for example Python `None`, JavaScript `null`, or Swift `NSNull()`) before sending the event. A null-valued property SHALL NOT cause an otherwise valid event to be rejected, and the SDK MUST NOT send that property as JSON `null` or convert it to the string `"null"`. -An explicitly present null-valued key is distinct from an absent key. The SDK MUST NOT insert null-valued keys for missing caller properties. JavaScript object properties containing `undefined` MAY remain omitted according to normal JSON serialization; `undefined` is not an explicit JSON null. +This cleanup SHALL apply recursively to nested objects, including objects inside arrays. Null array elements SHALL remain JSON `null` at their original indices; cleanup MUST NOT compact or reorder arrays. Non-null values, including `false`, `0`, empty strings, the literal string `"null"`, and empty objects/arrays, SHALL remain unchanged by this cleanup. Objects made empty by removing null-valued members SHALL remain `{}`, including when they occupy an array slot. If all custom properties are removed, the SDK SHALL still send an otherwise admissible event with its normal SDK metadata. -This requirement concerns custom event property values, not a missing/null properties container, required envelope fields, or reserved fields with their own type/validation contracts. Existing enrichment precedence, consent gates, and caller-configured filtering or `before_send` mutations/drops remain authoritative. Without such an explicit filter or a field-specific rule, null alone MUST NOT trigger sanitization. The OTLP null-attribute omission requirements for logs and traces are unchanged. +The same rule SHALL apply to queued and immediate/synchronous capture. It SHALL hold after enrichment and `before_send` processing, including for null-valued custom object members introduced by a hook. JavaScript object properties containing `undefined` SHALL remain omitted under normal JSON serialization, and missing custom keys MUST NOT be synthesized. Caller-configured privacy filtering and event drops remain authoritative; cleanup MUST NOT restore removed data. -#### Scenario: Queued capture preserves nulls on the wire (@both) +This requirement concerns custom event property values, not a missing/null properties container, required envelope fields, or reserved fields with their own type/validation contracts. It does not change those field-specific rules or the OTLP attribute rules for logs and traces. + +#### Scenario: Queued capture drops null-valued properties and preserves array positions (@both) - **GIVEN** an initialized SDK with a valid distinct id and no property-changing hooks or filters -- **WHEN** capture is called with event "Nullable Properties" and custom properties represented by JSON `{"optional":null,"nested":{"value":null},"items":["first",null,"last"],"empty":"","zero":0,"enabled":false}` +- **WHEN** capture is called with event "Nullable Properties" and custom properties represented by JSON `{"test":null,"nested":{"drop":null},"items":["1",null,2,{"drop":null},[null]],"empty":"","zero":0,"enabled":false,"literal":"null","emptyArray":[]}` - **AND** the SDK is flushed -- **THEN** the received event should contain every supplied custom property with the same JSON value -- **AND** "optional" and "nested.value" should be present with JSON null values, not the string "null" -- **AND** "items" should contain three elements with JSON null at index 1 +- **THEN** one event named "Nullable Properties" should be received +- **AND** its custom properties should equal JSON `{"nested":{},"items":["1",null,2,{},[null]],"empty":"","zero":0,"enabled":false,"literal":"null","emptyArray":[]}` - **AND** the absent custom property "missing" should remain absent -#### Scenario: Immediate capture preserves nulls on the wire (@both) +#### Scenario: Immediate capture drops null-valued properties and preserves array positions (@both) - **GIVEN** an initialized SDK supporting immediate delivery with a valid distinct id and no property-changing hooks or filters - **AND** capture is configured for immediate delivery -- **WHEN** capture is called with event "Nullable Properties" and custom properties represented by JSON `{"optional":null,"nested":{"value":null},"items":["first",null,"last"]}` +- **WHEN** capture is called with event "Nullable Properties" and custom properties represented by JSON `{"test":null,"nested":{"drop":null},"items":["1",null,2,{"drop":null},[null]]}` - **AND** the immediate send completes -- **THEN** the received event should contain every supplied custom property with the same JSON value -- **AND** "items" should contain three elements with JSON null at index 1 +- **THEN** one event named "Nullable Properties" should be received +- **AND** its custom properties should equal JSON `{"nested":{},"items":["1",null,2,{},[null]]}` + +#### Scenario: All-null custom properties do not drop the event (@both) +- **GIVEN** an initialized SDK with a valid distinct id and no property-changing hooks or filters +- **WHEN** capture is called with event "Only Null Properties" and custom properties represented by JSON `{"test":null}` +- **AND** the SDK is flushed +- **THEN** one event named "Only Null Properties" should be received +- **AND** its custom properties should equal JSON `{}` +- **AND** it should retain its normal SDK metadata + +#### Scenario: Null object properties introduced by before-send are omitted (@both) +- **GIVEN** an initialized SDK supporting before-send with a valid distinct id +- **AND** before-send adds custom properties represented by JSON `{"hookNull":null,"hookItems":[null,{"drop":null}]}` +- **WHEN** capture is called with event "Hook Properties" and no custom properties +- **AND** the SDK is flushed +- **THEN** one event named "Hook Properties" should be received +- **AND** its custom properties should equal JSON `{"hookItems":[null,{}]}` -#### Scenario: JavaScript object undefined remains distinct from null (@both) +#### Scenario: JavaScript null and undefined object properties are omitted (@both) - **GIVEN** an initialized JavaScript SDK with a valid distinct id and no property-changing hooks or filters -- **WHEN** capture is called with event "Absent Versus Null" and properties `{ optional: null, missing: undefined }` +- **WHEN** capture is called with event "Null And Undefined" and JavaScript properties `{ test: null, missing: undefined, items: ["1", null, 2] }` - **AND** the SDK is flushed -- **THEN** the received event property "optional" should be present with JSON null -- **AND** the received event properties should not contain "missing" +- **THEN** one event named "Null And Undefined" should be received +- **AND** its custom properties should equal JSON `{"items":["1",null,2]}` From 74d961af1d9d4845cfbec4e5d0860e43ee5ad159 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Tue, 8 Sep 2026 15:22:41 +0200 Subject: [PATCH 5/6] docs(capture): preserve nullable APIs and normalize event serialization --- acceptance/public/capture.feature | 51 ++++++++++++ .../.openspec.yaml | 2 + .../design.md | 23 ++++++ .../proposal.md | 24 ++++++ .../specs/capture/spec.md | 80 +++++++++++++++++++ .../tasks.md | 11 +++ openspec/specs/capture/spec.md | 38 ++++++++- 7 files changed, 225 insertions(+), 4 deletions(-) create mode 100644 openspec/changes/archive/2026-09-08-preserve-nullable-capture-api/.openspec.yaml create mode 100644 openspec/changes/archive/2026-09-08-preserve-nullable-capture-api/design.md create mode 100644 openspec/changes/archive/2026-09-08-preserve-nullable-capture-api/proposal.md create mode 100644 openspec/changes/archive/2026-09-08-preserve-nullable-capture-api/specs/capture/spec.md create mode 100644 openspec/changes/archive/2026-09-08-preserve-nullable-capture-api/tasks.md diff --git a/acceptance/public/capture.feature b/acceptance/public/capture.feature index 3037df7..23dc041 100644 --- a/acceptance/public/capture.feature +++ b/acceptance/public/capture.feature @@ -122,6 +122,57 @@ Feature: Capture {"items":["1",null,2]} """ + @both + Scenario: Existing nullable capture inputs remain supported + Given the SDK's existing public capture API accepts null or undefined property values + And an existing caller supplies those values using the supported public property types + When the SDK adopts the serialization cleanup rule + Then the same caller code should remain accepted by the public API, including compilation/type-checking where enforced + And capture should not reject the call or require caller-side filtering solely because of those values + And serializing the captured event should omit null/undefined-valued object members while preserving array positions + + @both + Scenario: Disk-backed event serialization omits null object properties + Given the SDK is initialized with token "test-token" + And the SDK has disk-backed event persistence, a valid distinct id, and no property-changing hooks or filters + And network delivery is paused + When capture is called with event "Persisted Properties" and custom properties represented by JSON: + """json + {"test":null,"nested":{"drop":null},"items":["1",null,2,{"drop":null}]} + """ + And the SDK serializes the queued event to disk + Then the persisted event's decoded custom properties should equal JSON: + """json + {"nested":{},"items":["1",null,2,{}]} + """ + And no network request should have been sent + When the persisted event is restored and delivered + Then the received event's custom properties should equal JSON: + """json + {"nested":{},"items":["1",null,2,{}]} + """ + + @both + Scenario: JavaScript disk serialization omits undefined object members without compacting arrays + Given a JavaScript SDK is initialized with token "test-token" + And the SDK has disk-backed event persistence, a valid distinct id, and no property-changing hooks or filters + And network delivery is paused + When capture is called with event "Persisted Undefined" and JavaScript properties: + """javascript + { test: null, missing: undefined, nested: { missing: undefined }, items: ["1", undefined, null, 2], literal: "undefined" } + """ + And the SDK serializes the queued event to disk + Then the persisted event's decoded custom properties should equal JSON: + """json + {"nested":{},"items":["1",null,null,2],"literal":"undefined"} + """ + And no network request should have been sent + When the persisted event is restored and delivered + Then the received event's custom properties should equal JSON: + """json + {"nested":{},"items":["1",null,null,2],"literal":"undefined"} + """ + @both Scenario: Capture honors opt-out state Given the SDK is initialized with token "test-token" diff --git a/openspec/changes/archive/2026-09-08-preserve-nullable-capture-api/.openspec.yaml b/openspec/changes/archive/2026-09-08-preserve-nullable-capture-api/.openspec.yaml new file mode 100644 index 0000000..7a8e2be --- /dev/null +++ b/openspec/changes/archive/2026-09-08-preserve-nullable-capture-api/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-08 diff --git a/openspec/changes/archive/2026-09-08-preserve-nullable-capture-api/design.md b/openspec/changes/archive/2026-09-08-preserve-nullable-capture-api/design.md new file mode 100644 index 0000000..3745d2e --- /dev/null +++ b/openspec/changes/archive/2026-09-08-preserve-nullable-capture-api/design.md @@ -0,0 +1,23 @@ +## Context + +The current target omits null-valued object members before sending but does not explicitly protect existing nullable input APIs or cover disk-backed event serialization. The owner clarified that cleanup belongs at serialization boundaries, not in stricter public argument types or validation. + +## Goals / Non-Goals + +**Goals:** Keep previously supported null/undefined property inputs source-compatible and runtime-compatible. Normalize serialized event data for both network and disk persistence while retaining array positions. + +**Non-Goals:** Widening APIs that currently reject null values, adding persistence to SDKs without it, changing generic storage/feature-flag caches, or implementing SDK changes. + +## Decisions + +- Extend the shared capture requirement rather than duplicate policy in AI and exception specs. Those capture methods already reference it. +- Preserve existing property-map value types, signatures, and runtime acceptance. Existing caller code must not require casts, filtering, or a different API solely because of this normalization. +- Apply cleanup to event serialization for wire transport and disk-backed event queues/caches. In-memory representations may still contain null/undefined. Do not change hook timing; the wire payload must still satisfy cleanup after before-send processing. +- Preserve null array elements. JavaScript undefined array entries serialize as null under ordinary JSON rules; neither case permits array compaction. +- Validate persisted event contents before network delivery so a wire-only implementation does not satisfy the disk scenario accidentally. After restoring the event, check the wire payload again. + +## Risks / Trade-offs + +- Confusing API acceptance with serialized output → explicitly test previously supported inputs and forbid narrowing public types or rejecting those calls. +- Applying event cleanup to unrelated caches → scope disk normalization to serialized captured events, not general persistent storage semantics. +- Typed/null-restricting SDKs or SDKs without disk queues cannot exercise every scenario → make those scenarios conditional on existing support and require no new public surface or persistence implementation. diff --git a/openspec/changes/archive/2026-09-08-preserve-nullable-capture-api/proposal.md b/openspec/changes/archive/2026-09-08-preserve-nullable-capture-api/proposal.md new file mode 100644 index 0000000..89b7502 --- /dev/null +++ b/openspec/changes/archive/2026-09-08-preserve-nullable-capture-api/proposal.md @@ -0,0 +1,24 @@ +## Why + +Null-property omission is a serialization rule, not a reason to narrow an SDK's existing public API. Callers that can already supply null or undefined property values must remain compatible, and serialized event caches should use the same cleanup as network payloads. + +## What Changes + +- Preserve public signatures and property types that already accept null/undefined values. Do not reject previously supported calls or require callers to pre-filter their properties. +- Apply object-member omission during event serialization for both wire delivery and disk-backed event caches/queues. +- Preserve the existing recursive cleanup and array-position rules. Null array elements remain valid, and JavaScript undefined array entries follow normal JSON serialization without compacting arrays. +- Add acceptance scenarios for existing nullable calls and event persistence, including undefined values where supported. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `capture`: Clarify API compatibility and event serialization boundaries. AI and exception capture inherit the clarification through their existing references to this requirement. + +## Impact + +Specifications and acceptance scenarios only. This clarification does not require making currently non-nullable APIs nullable, adding disk persistence, changing unrelated caches, or implementing SDK/backend changes. Serialized payloads still change for SDKs currently retaining null-valued object members, but their public input APIs must not be narrowed as part of this work. diff --git a/openspec/changes/archive/2026-09-08-preserve-nullable-capture-api/specs/capture/spec.md b/openspec/changes/archive/2026-09-08-preserve-nullable-capture-api/specs/capture/spec.md new file mode 100644 index 0000000..f6b4e7a --- /dev/null +++ b/openspec/changes/archive/2026-09-08-preserve-nullable-capture-api/specs/capture/spec.md @@ -0,0 +1,80 @@ +## MODIFIED Requirements + +### Requirement: Capture drops null-valued object properties + +The SDK SHALL omit custom event object properties whose values are explicit nulls (for example Python `None`, JavaScript `null`, or Swift `NSNull()`) or `undefined` where supported by the runtime, when serializing events for wire delivery or disk-backed event queues/caches. A null/undefined-valued property SHALL NOT cause an otherwise valid, previously supported capture call to be rejected. The SDK MUST NOT serialize that object member as JSON `null` or convert its value to the string `"null"` or `"undefined"`. + +This is a serialization rule, not a public API restriction. If an SDK's public capture API already accepts null/undefined property values, it SHALL continue accepting them with the same signatures and property value types. Implementations MUST NOT narrow those types, introduce null/undefined input validation errors for previously supported values, or require callers to pre-filter properties to comply. The rule does not require widening APIs that do not currently accept those values. In-memory event representations MAY retain them until serialization. This compatibility rule also applies to the AI and exception capture methods that inherit this requirement. + +This cleanup SHALL apply recursively to nested objects, including objects inside arrays. Null array elements SHALL remain JSON `null` at their original indices; cleanup MUST NOT compact or reorder arrays. JavaScript undefined array entries SHALL follow normal JSON serialization as null array elements without changing their positions. Other values, including `false`, `0`, empty strings, the literal strings `"null"` and `"undefined"`, and empty objects/arrays, SHALL remain unchanged by this cleanup. Objects made empty by removing null/undefined-valued members SHALL remain `{}`, including when they occupy an array slot. If all custom properties are removed, the SDK SHALL still send an otherwise admissible event with its normal SDK metadata. + +The same rule SHALL apply to queued and immediate/synchronous capture. The wire payload SHALL satisfy it after enrichment and `before_send` processing, including for null/undefined-valued custom object members introduced by a hook. Each disk serialization of a captured event SHALL apply the same object-member cleanup to the event being written; cleanup only at network send time is insufficient for SDKs that persist events. This does not require adding disk persistence or changing hook timing. Events restored from disk SHALL also satisfy the wire serialization rule. Missing custom keys MUST NOT be synthesized. Caller-configured privacy filtering and event drops remain authoritative; cleanup MUST NOT restore removed data. + +This requirement concerns custom event property values, not a missing/null properties container, required envelope fields, or reserved fields with their own type/validation contracts. It does not change those field-specific rules, unrelated persistent-storage/cache semantics, or the OTLP attribute rules for logs and traces. + +#### Scenario: Queued capture drops null-valued properties and preserves array positions (@both) +- **GIVEN** an initialized SDK with a valid distinct id and no property-changing hooks or filters +- **WHEN** capture is called with event "Nullable Properties" and custom properties represented by JSON `{"test":null,"nested":{"drop":null},"items":["1",null,2,{"drop":null},[null]],"empty":"","zero":0,"enabled":false,"literal":"null","emptyArray":[]}` +- **AND** the SDK is flushed +- **THEN** one event named "Nullable Properties" should be received +- **AND** its custom properties should equal JSON `{"nested":{},"items":["1",null,2,{},[null]],"empty":"","zero":0,"enabled":false,"literal":"null","emptyArray":[]}` +- **AND** the absent custom property "missing" should remain absent + +#### Scenario: Immediate capture drops null-valued properties and preserves array positions (@both) +- **GIVEN** an initialized SDK supporting immediate delivery with a valid distinct id and no property-changing hooks or filters +- **AND** capture is configured for immediate delivery +- **WHEN** capture is called with event "Nullable Properties" and custom properties represented by JSON `{"test":null,"nested":{"drop":null},"items":["1",null,2,{"drop":null},[null]]}` +- **AND** the immediate send completes +- **THEN** one event named "Nullable Properties" should be received +- **AND** its custom properties should equal JSON `{"nested":{},"items":["1",null,2,{},[null]]}` + +#### Scenario: All-null custom properties do not drop the event (@both) +- **GIVEN** an initialized SDK with a valid distinct id and no property-changing hooks or filters +- **WHEN** capture is called with event "Only Null Properties" and custom properties represented by JSON `{"test":null}` +- **AND** the SDK is flushed +- **THEN** one event named "Only Null Properties" should be received +- **AND** its custom properties should equal JSON `{}` +- **AND** it should retain its normal SDK metadata + +#### Scenario: Null object properties introduced by before-send are omitted (@both) +- **GIVEN** an initialized SDK supporting before-send with a valid distinct id +- **AND** before-send adds custom properties represented by JSON `{"hookNull":null,"hookItems":[null,{"drop":null}]}` +- **WHEN** capture is called with event "Hook Properties" and no custom properties +- **AND** the SDK is flushed +- **THEN** one event named "Hook Properties" should be received +- **AND** its custom properties should equal JSON `{"hookItems":[null,{}]}` + +#### Scenario: JavaScript null and undefined object properties are omitted (@both) +- **GIVEN** an initialized JavaScript SDK with a valid distinct id and no property-changing hooks or filters +- **WHEN** capture is called with event "Null And Undefined" and JavaScript properties `{ test: null, missing: undefined, items: ["1", null, 2] }` +- **AND** the SDK is flushed +- **THEN** one event named "Null And Undefined" should be received +- **AND** its custom properties should equal JSON `{"items":["1",null,2]}` + +#### Scenario: Existing nullable capture inputs remain supported (@both) +- **GIVEN** the SDK's existing public capture API accepts null or undefined property values +- **AND** an existing caller supplies those values using the supported public property types +- **WHEN** the SDK adopts the serialization cleanup rule +- **THEN** the same caller code should remain accepted by the public API, including compilation/type-checking where enforced +- **AND** capture should not reject the call or require caller-side filtering solely because of those values +- **AND** serializing the captured event should omit null/undefined-valued object members while preserving array positions + +#### Scenario: Disk-backed event serialization omits null object properties (@both) +- **GIVEN** an initialized SDK with disk-backed event persistence, a valid distinct id, and no property-changing hooks or filters +- **AND** network delivery is paused +- **WHEN** capture is called with event "Persisted Properties" and custom properties represented by JSON `{"test":null,"nested":{"drop":null},"items":["1",null,2,{"drop":null}]}` +- **AND** the SDK serializes the queued event to disk +- **THEN** the persisted event's decoded custom properties should equal JSON `{"nested":{},"items":["1",null,2,{}]}` +- **AND** no network request should have been sent +- **WHEN** the persisted event is restored and delivered +- **THEN** the received event's custom properties should equal JSON `{"nested":{},"items":["1",null,2,{}]}` + +#### Scenario: JavaScript disk serialization omits undefined object members without compacting arrays (@both) +- **GIVEN** an initialized JavaScript SDK with disk-backed event persistence, a valid distinct id, and no property-changing hooks or filters +- **AND** network delivery is paused +- **WHEN** capture is called with event "Persisted Undefined" and JavaScript properties `{ test: null, missing: undefined, nested: { missing: undefined }, items: ["1", undefined, null, 2], literal: "undefined" }` +- **AND** the SDK serializes the queued event to disk +- **THEN** the persisted event's decoded custom properties should equal JSON `{"nested":{},"items":["1",null,null,2],"literal":"undefined"}` +- **AND** no network request should have been sent +- **WHEN** the persisted event is restored and delivered +- **THEN** the received event's custom properties should equal JSON `{"nested":{},"items":["1",null,null,2],"literal":"undefined"}` diff --git a/openspec/changes/archive/2026-09-08-preserve-nullable-capture-api/tasks.md b/openspec/changes/archive/2026-09-08-preserve-nullable-capture-api/tasks.md new file mode 100644 index 0000000..37135c4 --- /dev/null +++ b/openspec/changes/archive/2026-09-08-preserve-nullable-capture-api/tasks.md @@ -0,0 +1,11 @@ +## 1. Contract and acceptance + +- [x] 1.1 Clarify existing public API compatibility and event serialization for wire and disk in the shared capture requirement. +- [x] 1.2 Add acceptance scenarios for existing nullable inputs and persisted null/undefined values without changing earlier array-position coverage. + +## 2. Validation + +- [x] 2.1 Validate the delta, Gherkin syntax, scenario coverage, and serialized JSON examples. +- [x] 2.2 Review the clarification against the existing AI/exception references and disk-storage scope before sync/archive. + +Strict delta validation passed. Ten current null-policy scenarios across capture, AI, and exception capture match the Gherkin files. JSON input/output examples, including disk and restored-event payloads, match reference normalization without array compaction. API compatibility wording was inspected statically; no SDK compilation, runtime, or persistence conformance tests were run. diff --git a/openspec/specs/capture/spec.md b/openspec/specs/capture/spec.md index e9ef648..083e4f0 100644 --- a/openspec/specs/capture/spec.md +++ b/openspec/specs/capture/spec.md @@ -229,13 +229,15 @@ The SDK SHALL implement the canonical `capture` behavior described by this spec. ### Requirement: Capture drops null-valued object properties -The SDK SHALL omit custom event object properties whose values are explicit nulls (for example Python `None`, JavaScript `null`, or Swift `NSNull()`) before sending the event. A null-valued property SHALL NOT cause an otherwise valid event to be rejected, and the SDK MUST NOT send that property as JSON `null` or convert it to the string `"null"`. +The SDK SHALL omit custom event object properties whose values are explicit nulls (for example Python `None`, JavaScript `null`, or Swift `NSNull()`) or `undefined` where supported by the runtime, when serializing events for wire delivery or disk-backed event queues/caches. A null/undefined-valued property SHALL NOT cause an otherwise valid, previously supported capture call to be rejected. The SDK MUST NOT serialize that object member as JSON `null` or convert its value to the string `"null"` or `"undefined"`. -This cleanup SHALL apply recursively to nested objects, including objects inside arrays. Null array elements SHALL remain JSON `null` at their original indices; cleanup MUST NOT compact or reorder arrays. Non-null values, including `false`, `0`, empty strings, the literal string `"null"`, and empty objects/arrays, SHALL remain unchanged by this cleanup. Objects made empty by removing null-valued members SHALL remain `{}`, including when they occupy an array slot. If all custom properties are removed, the SDK SHALL still send an otherwise admissible event with its normal SDK metadata. +This is a serialization rule, not a public API restriction. If an SDK's public capture API already accepts null/undefined property values, it SHALL continue accepting them with the same signatures and property value types. Implementations MUST NOT narrow those types, introduce null/undefined input validation errors for previously supported values, or require callers to pre-filter properties to comply. The rule does not require widening APIs that do not currently accept those values. In-memory event representations MAY retain them until serialization. This compatibility rule also applies to the AI and exception capture methods that inherit this requirement. -The same rule SHALL apply to queued and immediate/synchronous capture. It SHALL hold after enrichment and `before_send` processing, including for null-valued custom object members introduced by a hook. JavaScript object properties containing `undefined` SHALL remain omitted under normal JSON serialization, and missing custom keys MUST NOT be synthesized. Caller-configured privacy filtering and event drops remain authoritative; cleanup MUST NOT restore removed data. +This cleanup SHALL apply recursively to nested objects, including objects inside arrays. Null array elements SHALL remain JSON `null` at their original indices; cleanup MUST NOT compact or reorder arrays. JavaScript undefined array entries SHALL follow normal JSON serialization as null array elements without changing their positions. Other values, including `false`, `0`, empty strings, the literal strings `"null"` and `"undefined"`, and empty objects/arrays, SHALL remain unchanged by this cleanup. Objects made empty by removing null/undefined-valued members SHALL remain `{}`, including when they occupy an array slot. If all custom properties are removed, the SDK SHALL still send an otherwise admissible event with its normal SDK metadata. -This requirement concerns custom event property values, not a missing/null properties container, required envelope fields, or reserved fields with their own type/validation contracts. It does not change those field-specific rules or the OTLP attribute rules for logs and traces. +The same rule SHALL apply to queued and immediate/synchronous capture. The wire payload SHALL satisfy it after enrichment and `before_send` processing, including for null/undefined-valued custom object members introduced by a hook. Each disk serialization of a captured event SHALL apply the same object-member cleanup to the event being written; cleanup only at network send time is insufficient for SDKs that persist events. This does not require adding disk persistence or changing hook timing. Events restored from disk SHALL also satisfy the wire serialization rule. Missing custom keys MUST NOT be synthesized. Caller-configured privacy filtering and event drops remain authoritative; cleanup MUST NOT restore removed data. + +This requirement concerns custom event property values, not a missing/null properties container, required envelope fields, or reserved fields with their own type/validation contracts. It does not change those field-specific rules, unrelated persistent-storage/cache semantics, or the OTLP attribute rules for logs and traces. #### Scenario: Queued capture drops null-valued properties and preserves array positions (@both) - **GIVEN** an initialized SDK with a valid distinct id and no property-changing hooks or filters @@ -275,3 +277,31 @@ This requirement concerns custom event property values, not a missing/null prope - **AND** the SDK is flushed - **THEN** one event named "Null And Undefined" should be received - **AND** its custom properties should equal JSON `{"items":["1",null,2]}` + +#### Scenario: Existing nullable capture inputs remain supported (@both) +- **GIVEN** the SDK's existing public capture API accepts null or undefined property values +- **AND** an existing caller supplies those values using the supported public property types +- **WHEN** the SDK adopts the serialization cleanup rule +- **THEN** the same caller code should remain accepted by the public API, including compilation/type-checking where enforced +- **AND** capture should not reject the call or require caller-side filtering solely because of those values +- **AND** serializing the captured event should omit null/undefined-valued object members while preserving array positions + +#### Scenario: Disk-backed event serialization omits null object properties (@both) +- **GIVEN** an initialized SDK with disk-backed event persistence, a valid distinct id, and no property-changing hooks or filters +- **AND** network delivery is paused +- **WHEN** capture is called with event "Persisted Properties" and custom properties represented by JSON `{"test":null,"nested":{"drop":null},"items":["1",null,2,{"drop":null}]}` +- **AND** the SDK serializes the queued event to disk +- **THEN** the persisted event's decoded custom properties should equal JSON `{"nested":{},"items":["1",null,2,{}]}` +- **AND** no network request should have been sent +- **WHEN** the persisted event is restored and delivered +- **THEN** the received event's custom properties should equal JSON `{"nested":{},"items":["1",null,2,{}]}` + +#### Scenario: JavaScript disk serialization omits undefined object members without compacting arrays (@both) +- **GIVEN** an initialized JavaScript SDK with disk-backed event persistence, a valid distinct id, and no property-changing hooks or filters +- **AND** network delivery is paused +- **WHEN** capture is called with event "Persisted Undefined" and JavaScript properties `{ test: null, missing: undefined, nested: { missing: undefined }, items: ["1", undefined, null, 2], literal: "undefined" }` +- **AND** the SDK serializes the queued event to disk +- **THEN** the persisted event's decoded custom properties should equal JSON `{"nested":{},"items":["1",null,null,2],"literal":"undefined"}` +- **AND** no network request should have been sent +- **WHEN** the persisted event is restored and delivered +- **THEN** the received event's custom properties should equal JSON `{"nested":{},"items":["1",null,null,2],"literal":"undefined"}` From 6259b1bbc84ab6441f5d891a5974ce22aa44ae18 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Tue, 8 Sep 2026 21:34:48 +0200 Subject: [PATCH 6/6] docs(capture): consolidate final specification into one archive --- .../.openspec.yaml | 2 - .../design.md | 30 ---------- .../proposal.md | 28 --------- .../specs/capture/spec.md | 57 ------------------- .../tasks.md | 11 ---- .../.openspec.yaml | 2 - .../design.md | 34 ----------- .../proposal.md | 29 ---------- .../specs/capture-ai/spec.md | 13 ----- .../specs/capture-exception/spec.md | 15 ----- .../specs/capture/spec.md | 33 ----------- .../tasks.md | 11 ---- .../design.md | 28 +++++---- .../proposal.md | 20 ++++--- .../specs/capture-ai/spec.md | 7 --- .../specs/capture-exception/spec.md | 7 --- .../specs/capture/spec.md | 2 +- .../tasks.md | 13 +++-- 18 files changed, 38 insertions(+), 304 deletions(-) delete mode 100644 openspec/changes/archive/2026-09-08-drop-null-capture-properties/.openspec.yaml delete mode 100644 openspec/changes/archive/2026-09-08-drop-null-capture-properties/design.md delete mode 100644 openspec/changes/archive/2026-09-08-drop-null-capture-properties/proposal.md delete mode 100644 openspec/changes/archive/2026-09-08-drop-null-capture-properties/specs/capture/spec.md delete mode 100644 openspec/changes/archive/2026-09-08-drop-null-capture-properties/tasks.md delete mode 100644 openspec/changes/archive/2026-09-08-preserve-null-capture-properties/.openspec.yaml delete mode 100644 openspec/changes/archive/2026-09-08-preserve-null-capture-properties/design.md delete mode 100644 openspec/changes/archive/2026-09-08-preserve-null-capture-properties/proposal.md delete mode 100644 openspec/changes/archive/2026-09-08-preserve-null-capture-properties/specs/capture-ai/spec.md delete mode 100644 openspec/changes/archive/2026-09-08-preserve-null-capture-properties/specs/capture-exception/spec.md delete mode 100644 openspec/changes/archive/2026-09-08-preserve-null-capture-properties/specs/capture/spec.md delete mode 100644 openspec/changes/archive/2026-09-08-preserve-null-capture-properties/tasks.md rename openspec/changes/archive/{2026-09-08-drop-null-capture-properties => 2026-09-08-preserve-nullable-capture-api}/specs/capture-ai/spec.md (81%) rename openspec/changes/archive/{2026-09-08-drop-null-capture-properties => 2026-09-08-preserve-nullable-capture-api}/specs/capture-exception/spec.md (77%) diff --git a/openspec/changes/archive/2026-09-08-drop-null-capture-properties/.openspec.yaml b/openspec/changes/archive/2026-09-08-drop-null-capture-properties/.openspec.yaml deleted file mode 100644 index 7a8e2be..0000000 --- a/openspec/changes/archive/2026-09-08-drop-null-capture-properties/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-09-08 diff --git a/openspec/changes/archive/2026-09-08-drop-null-capture-properties/design.md b/openspec/changes/archive/2026-09-08-drop-null-capture-properties/design.md deleted file mode 100644 index dce974c..0000000 --- a/openspec/changes/archive/2026-09-08-drop-null-capture-properties/design.md +++ /dev/null @@ -1,30 +0,0 @@ -## Context - -The owner revised the null policy after the initial preservation proposal was archived on this unmerged PR. Backend removal is planned and still in progress. The new target distinguishes object members from array elements, rather than treating all JSON nulls alike. - -Android already drops null-valued map entries, although its current serializer also drops null list elements and therefore does not fully match this target. Python PR #926 proposes top-level omission. Neither is evidence that recursive object-key omission with array-position preservation is already uniform across SDKs. - -## Goals / Non-Goals - -**Goals:** Omit null-valued custom object members before transmission for all capture variants, while preserving null array elements. Give each contract an explicit input and expected wire output. - -**Non-Goals:** SDK/backend implementation, rollout timing, changing required or reserved-field validation, null exception inputs, or OTLP attribute encoding. - -## Decisions - -- Remove null-valued object members recursively, including members of objects contained in arrays. Top-level-only filtering would leave the same kind of null-valued property in nested payloads. -- Preserve array length, ordering, and explicit null elements. For example `["1", null, 2]` is sent unchanged. Objects within arrays are cleaned without removing their array slots, and objects made empty by cleanup remain `{}`. -- Do not reject the event when a property is null, and do not drop an event merely because all custom properties were removed. Preserve `false`, `0`, empty strings, empty arrays/objects, and the literal string `"null"`. -- Make the invariant hold after enrichment and `before_send`, including null-valued members introduced by a hook. Existing privacy drops/mutations remain effective; cleanup never restores data removed by a hook. -- Apply the shared rule to AI capture as a specific normalization exception to its payload pass-through promise. It is not permission to add redaction, truncation, or media processing to manual AI capture. -- Replace the earlier preservation requirements through a new archived delta. Retain the original archive as historical evidence, explicitly marked superseded, rather than rewriting git history. - -## Risks / Trade-offs - -- Changing SDKs that preserve object nulls changes their wire payloads → SDK-specific compatibility review and rollout remain separate. -- Backend normalization is not fully deployed → document it as the planned target, not existing universal behavior. -- Recursive cleanup could accidentally compact arrays or discard empty objects → acceptance fixtures include both positional nulls and objects emptied inside arrays. - -## Migration Plan - -Revise the three capture requirements and acceptance scenarios, validate the delta and Gherkin examples, then sync and archive on the existing PR branch. No SDK conformance is asserted by these specification-only checks. diff --git a/openspec/changes/archive/2026-09-08-drop-null-capture-properties/proposal.md b/openspec/changes/archive/2026-09-08-drop-null-capture-properties/proposal.md deleted file mode 100644 index e6e1b8d..0000000 --- a/openspec/changes/archive/2026-09-08-drop-null-capture-properties/proposal.md +++ /dev/null @@ -1,28 +0,0 @@ -## Why - -The owner has selected omission of null-valued object properties to align SDK output with planned backend removal. That backend work is still in progress; this change does not claim it is already deployed. Null array elements remain valid because removing them would change array positions. - -## What Changes - -- Supersede the earlier `preserve-null-capture-properties` decision in this PR. -- Drop null-valued custom object properties before sending, recursively through nested objects, including objects inside arrays. -- Preserve null array elements, array order, non-null values, and objects left empty by filtering. -- Apply the same rule to queued, immediate, AI, and exception capture without rejecting an otherwise valid event. -- Update acceptance scenarios to assert the normalized wire payload rather than the original input. -- **BREAKING** for SDKs that currently preserve null-valued object properties. SDK compatibility and rollout work remain separate. - -## Capabilities - -### New Capabilities - -None. - -### Modified Capabilities - -- `capture`: Replace null-property preservation with recursive object-key omission, retaining null array elements. -- `capture-ai`: Apply the revised capture normalization contract on the AI route. -- `capture-exception`: Apply the revised contract to additional custom properties. - -## Impact - -Specifications and acceptance scenarios only. No SDK or backend implementation changes. The old archive remains as historical context and is superseded by this change. Existing privacy controls, reserved-field validation, and OTLP attribute rules remain unchanged. diff --git a/openspec/changes/archive/2026-09-08-drop-null-capture-properties/specs/capture/spec.md b/openspec/changes/archive/2026-09-08-drop-null-capture-properties/specs/capture/spec.md deleted file mode 100644 index 5edbb7d..0000000 --- a/openspec/changes/archive/2026-09-08-drop-null-capture-properties/specs/capture/spec.md +++ /dev/null @@ -1,57 +0,0 @@ -## REMOVED Requirements - -### Requirement: Explicit null capture properties are valid JSON data - -**Reason**: Superseded by the owner's decision to omit null-valued object properties while retaining null array elements. -**Migration**: Drop null-valued object members before sending instead of preserving them. Do not reject the event or compact arrays. - -## ADDED Requirements - -### Requirement: Capture drops null-valued object properties - -The SDK SHALL omit custom event object properties whose values are explicit nulls (for example Python `None`, JavaScript `null`, or Swift `NSNull()`) before sending the event. A null-valued property SHALL NOT cause an otherwise valid event to be rejected, and the SDK MUST NOT send that property as JSON `null` or convert it to the string `"null"`. - -This cleanup SHALL apply recursively to nested objects, including objects inside arrays. Null array elements SHALL remain JSON `null` at their original indices; cleanup MUST NOT compact or reorder arrays. Non-null values, including `false`, `0`, empty strings, the literal string `"null"`, and empty objects/arrays, SHALL remain unchanged by this cleanup. Objects made empty by removing null-valued members SHALL remain `{}`, including when they occupy an array slot. If all custom properties are removed, the SDK SHALL still send an otherwise admissible event with its normal SDK metadata. - -The same rule SHALL apply to queued and immediate/synchronous capture. It SHALL hold after enrichment and `before_send` processing, including for null-valued custom object members introduced by a hook. JavaScript object properties containing `undefined` SHALL remain omitted under normal JSON serialization, and missing custom keys MUST NOT be synthesized. Caller-configured privacy filtering and event drops remain authoritative; cleanup MUST NOT restore removed data. - -This requirement concerns custom event property values, not a missing/null properties container, required envelope fields, or reserved fields with their own type/validation contracts. It does not change those field-specific rules or the OTLP attribute rules for logs and traces. - -#### Scenario: Queued capture drops null-valued properties and preserves array positions (@both) -- **GIVEN** an initialized SDK with a valid distinct id and no property-changing hooks or filters -- **WHEN** capture is called with event "Nullable Properties" and custom properties represented by JSON `{"test":null,"nested":{"drop":null},"items":["1",null,2,{"drop":null},[null]],"empty":"","zero":0,"enabled":false,"literal":"null","emptyArray":[]}` -- **AND** the SDK is flushed -- **THEN** one event named "Nullable Properties" should be received -- **AND** its custom properties should equal JSON `{"nested":{},"items":["1",null,2,{},[null]],"empty":"","zero":0,"enabled":false,"literal":"null","emptyArray":[]}` -- **AND** the absent custom property "missing" should remain absent - -#### Scenario: Immediate capture drops null-valued properties and preserves array positions (@both) -- **GIVEN** an initialized SDK supporting immediate delivery with a valid distinct id and no property-changing hooks or filters -- **AND** capture is configured for immediate delivery -- **WHEN** capture is called with event "Nullable Properties" and custom properties represented by JSON `{"test":null,"nested":{"drop":null},"items":["1",null,2,{"drop":null},[null]]}` -- **AND** the immediate send completes -- **THEN** one event named "Nullable Properties" should be received -- **AND** its custom properties should equal JSON `{"nested":{},"items":["1",null,2,{},[null]]}` - -#### Scenario: All-null custom properties do not drop the event (@both) -- **GIVEN** an initialized SDK with a valid distinct id and no property-changing hooks or filters -- **WHEN** capture is called with event "Only Null Properties" and custom properties represented by JSON `{"test":null}` -- **AND** the SDK is flushed -- **THEN** one event named "Only Null Properties" should be received -- **AND** its custom properties should equal JSON `{}` -- **AND** it should retain its normal SDK metadata - -#### Scenario: Null object properties introduced by before-send are omitted (@both) -- **GIVEN** an initialized SDK supporting before-send with a valid distinct id -- **AND** before-send adds custom properties represented by JSON `{"hookNull":null,"hookItems":[null,{"drop":null}]}` -- **WHEN** capture is called with event "Hook Properties" and no custom properties -- **AND** the SDK is flushed -- **THEN** one event named "Hook Properties" should be received -- **AND** its custom properties should equal JSON `{"hookItems":[null,{}]}` - -#### Scenario: JavaScript null and undefined object properties are omitted (@both) -- **GIVEN** an initialized JavaScript SDK with a valid distinct id and no property-changing hooks or filters -- **WHEN** capture is called with event "Null And Undefined" and JavaScript properties `{ test: null, missing: undefined, items: ["1", null, 2] }` -- **AND** the SDK is flushed -- **THEN** one event named "Null And Undefined" should be received -- **AND** its custom properties should equal JSON `{"items":["1",null,2]}` diff --git a/openspec/changes/archive/2026-09-08-drop-null-capture-properties/tasks.md b/openspec/changes/archive/2026-09-08-drop-null-capture-properties/tasks.md deleted file mode 100644 index 89bc287..0000000 --- a/openspec/changes/archive/2026-09-08-drop-null-capture-properties/tasks.md +++ /dev/null @@ -1,11 +0,0 @@ -## 1. Revised contract - -- [x] 1.1 Replace the preservation requirements with null object-property omission and explicit array-position preservation. -- [x] 1.2 Revise capture, AI, and exception Gherkin scenarios and mark the previous decision superseded. - -## 2. Validation - -- [x] 2.1 Validate the delta strictly and check Gherkin scenario coverage and JSON input/output examples. -- [x] 2.2 Review scope and compatibility before syncing and archiving on the existing PR branch. - -Strict delta validation passed. All seven new/revised scenarios match their Gherkin counterparts; JSON input/output fixtures were checked against a reference object-key cleanup that preserves array positions. These are specification checks, not SDK conformance tests. The owner approved the replacement policy and requested the existing PR be updated. diff --git a/openspec/changes/archive/2026-09-08-preserve-null-capture-properties/.openspec.yaml b/openspec/changes/archive/2026-09-08-preserve-null-capture-properties/.openspec.yaml deleted file mode 100644 index 7a8e2be..0000000 --- a/openspec/changes/archive/2026-09-08-preserve-null-capture-properties/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-09-08 diff --git a/openspec/changes/archive/2026-09-08-preserve-null-capture-properties/design.md b/openspec/changes/archive/2026-09-08-preserve-null-capture-properties/design.md deleted file mode 100644 index e4c4b96..0000000 --- a/openspec/changes/archive/2026-09-08-preserve-null-capture-properties/design.md +++ /dev/null @@ -1,34 +0,0 @@ -## Context - -The capture spec does not define null-property serialization. Python's `clean()` explicitly preserves `None`; `json.dumps(..., cls=DatetimeSerializer)` emits JSON `null`, recursively. Node/React Native, Go, Ruby, and iOS explicit `NSNull()` also preserve it. Android's `GsonSafeMapSerializer` instead skips null map values and list elements. This contract selects preservation, not the lowest common denominator. - -Evidence from the inspected SDK revisions: -- [Python PR #926](https://github.com/PostHog/posthog-python/pull/926) proposes changing existing top-level preservation. -- [Node/core payload construction](https://github.com/PostHog/posthog-js/blob/f902b705ed07efb4c78695646da6d6708ae082b9/packages/core/src/posthog-core-stateless.ts#L477-L501) retains caller values before JSON serialization. -- [iOS sanitizer](https://github.com/PostHog/posthog-ios/blob/611db7567af31263da4b80317bfe7d2340b8df40/PostHog/Utils/DictUtils.swift#L44-L89) accepts JSON-compatible values, including `NSNull()`. -- [Android serializer](https://github.com/PostHog/posthog-android/blob/0d7f567006bf24b6913d7fd00e025597dcbd605e/posthog/src/main/java/com/posthog/internal/GsonSafeMapSerializer.kt) drops nulls recursively. - -## Goals / Non-Goals - -**Goals:** Preserve caller-supplied JSON nulls on the wire for ordinary, immediate, AI, and exception capture. Make absent keys and null array positions unambiguous in acceptance scenarios. - -**Non-Goals:** SDK implementation changes, backend query/person-update semantics, accepting null for required or typed reserved fields, changing privacy hooks, or changing OTLP attribute encoding. - -## Decisions - -- Define one shared requirement in `capture`; reference it from AI and exception capture rather than duplicating policy. Explicit null is valid JSON data, not a serialization error or implicit deletion instruction. -- Preserve nulls recursively. Top-level-only preservation leaves nested custom payloads lossy; removing null array elements also changes positional meaning. -- Allow platform-idiomatic null representations (such as Swift `NSNull()`). A nullable properties argument is not the same as nullable entries; languages with non-nullable entry types need a way to represent explicit JSON null to conform. -- Keep absence distinct: an omitted key stays absent. JavaScript object `undefined` is not JSON null and can continue to be omitted by normal JSON serialization. This does not redefine JavaScript array serialization. -- Existing property precedence, reserved-field validation, consent, and explicitly configured filters still apply. Null preservation must not bypass privacy controls or populate missing exception metadata. -- Assert decoded wire JSON, not only queued dictionaries, for both queued and immediate delivery where supported. Spec-only Gherkin scenarios describe the contract; SDK adapters implement them separately. - -## Risks / Trade-offs - -- Existing null-dropping SDKs change emitted data when converging → require per-SDK compatibility review and release notes; do not bundle SDK changes here. -- Slightly larger payloads than stripping nulls → preserve caller intent; applications can deliberately filter unwanted values. -- Confusion with OTLP null omission → explicitly exclude logs and traces from this analytics contract. - -## Migration Plan - -Add delta requirements and matching acceptance scenarios, validate the change, then use OpenSpec archive to sync canonical specs. No SDK release or conformance claim is made by this spec change. diff --git a/openspec/changes/archive/2026-09-08-preserve-null-capture-properties/proposal.md b/openspec/changes/archive/2026-09-08-preserve-null-capture-properties/proposal.md deleted file mode 100644 index d393bf0..0000000 --- a/openspec/changes/archive/2026-09-08-preserve-null-capture-properties/proposal.md +++ /dev/null @@ -1,29 +0,0 @@ -> Superseded on this PR by `drop-null-capture-properties`: omit null-valued object properties, but preserve null array elements. This archive records the earlier decision only. - -## Why - -JSON `null` is valid event data, and an explicitly present null-valued property is not the same as an absent key. Python PR [#926](https://github.com/PostHog/posthog-python/pull/926) proposes dropping those properties, exposing a gap in the capture contract despite existing SDKs preserving them. - -## What Changes - -- Require capture methods to accept explicit null-valued custom properties and preserve them as JSON `null`, including nested objects and array positions, for queued and immediate delivery. -- Distinguish null values from absent properties and JavaScript object properties containing `undefined`. -- Apply the same contract to caller-supplied properties on exception and AI capture. -- Keep caller-configured filtering, reserved-field validation, and OTLP logs/traces rules unchanged. -- Add wire-level acceptance scenarios. **BREAKING** for implementations that currently strip explicit nulls: conformance changes their emitted payloads; SDK rollout needs compatibility review. - -## Capabilities - -### New Capabilities - -None. - -### Modified Capabilities - -- `capture`: Define explicit null acceptance and preservation through serialization. -- `capture-ai`: Inherit the capture null-property contract on the AI route. -- `capture-exception`: Inherit the capture null-property contract for additional event properties. - -## Impact - -Specs and acceptance scenarios only; no SDK implementation changes. Python, Node/React Native, Go, Ruby, and iOS explicit `NSNull()` provide preservation precedent. Android currently filters nulls; non-nullable property APIs may require a platform-idiomatic null representation. This change does not claim every SDK already conforms. diff --git a/openspec/changes/archive/2026-09-08-preserve-null-capture-properties/specs/capture-ai/spec.md b/openspec/changes/archive/2026-09-08-preserve-null-capture-properties/specs/capture-ai/spec.md deleted file mode 100644 index cef8c8f..0000000 --- a/openspec/changes/archive/2026-09-08-preserve-null-capture-properties/specs/capture-ai/spec.md +++ /dev/null @@ -1,13 +0,0 @@ -## ADDED Requirements - -### Requirement: AI capture preserves explicit null properties - -`capture_ai` and its immediate/awaitable variants SHALL follow capture's "Explicit null capture properties are valid JSON data" requirement for caller-supplied custom properties, including nested object values and array elements. Using the AI route MUST NOT introduce null stripping. - -#### Scenario: AI capture preserves null properties on its delivery route (@server) -- **GIVEN** an initialized SDK supporting AI capture with no property-changing hooks or filters -- **WHEN** capture_ai is called with distinct id "user-123", event "$ai_generation", and custom properties represented by JSON `{"optional":null,"nested":{"value":null},"items":["first",null,"last"]}` -- **AND** the SDK is flushed -- **THEN** the event received on the AI batch endpoint should contain every supplied custom property with the same JSON value -- **AND** "items" should contain three elements with JSON null at index 1 -- **AND** the analytics batch endpoint should receive no events diff --git a/openspec/changes/archive/2026-09-08-preserve-null-capture-properties/specs/capture-exception/spec.md b/openspec/changes/archive/2026-09-08-preserve-null-capture-properties/specs/capture-exception/spec.md deleted file mode 100644 index db908c3..0000000 --- a/openspec/changes/archive/2026-09-08-preserve-null-capture-properties/specs/capture-exception/spec.md +++ /dev/null @@ -1,15 +0,0 @@ -## ADDED Requirements - -### Requirement: Exception capture preserves explicit null custom properties - -`capture_exception` / `captureException` SHALL follow capture's "Explicit null capture properties are valid JSON data" requirement for caller-supplied additional custom event properties, including nested object values and array elements. This MUST NOT change exception-input validation or the field-specific omission rules for SDK-owned exception metadata. - -#### Scenario: Exception capture preserves null custom properties on the wire (@both) -- **GIVEN** an initialized SDK with a valid distinct id and no property-changing hooks or filters -- **AND** a valid handled exception -- **WHEN** capture exception is called for the exception with additional custom properties represented by JSON `{"optional":null,"nested":{"value":null},"items":["first",null,"last"]}` -- **AND** the SDK is flushed -- **THEN** one "$exception" event should be received -- **AND** the received event should contain every supplied custom property with the same JSON value -- **AND** "items" should contain three elements with JSON null at index 1 -- **AND** the event should still include its SDK-generated exception data diff --git a/openspec/changes/archive/2026-09-08-preserve-null-capture-properties/specs/capture/spec.md b/openspec/changes/archive/2026-09-08-preserve-null-capture-properties/specs/capture/spec.md deleted file mode 100644 index ee38059..0000000 --- a/openspec/changes/archive/2026-09-08-preserve-null-capture-properties/specs/capture/spec.md +++ /dev/null @@ -1,33 +0,0 @@ -## ADDED Requirements - -### Requirement: Explicit null capture properties are valid JSON data - -The SDK SHALL accept explicit null-valued custom event properties using a platform-idiomatic JSON null representation (for example Python `None`, JavaScript `null`, or Swift `NSNull()`). It SHALL preserve those values as JSON `null` through event preparation, queueing, and wire serialization rather than rejecting the event, removing the key, or converting the value to a string solely because it is null. This applies recursively to nested objects and arrays; null array elements MUST retain their positions. Queued and immediate/synchronous capture variants SHALL follow the same rule. - -An explicitly present null-valued key is distinct from an absent key. The SDK MUST NOT insert null-valued keys for missing caller properties. JavaScript object properties containing `undefined` MAY remain omitted according to normal JSON serialization; `undefined` is not an explicit JSON null. - -This requirement concerns custom event property values, not a missing/null properties container, required envelope fields, or reserved fields with their own type/validation contracts. Existing enrichment precedence, consent gates, and caller-configured filtering or `before_send` mutations/drops remain authoritative. Without such an explicit filter or a field-specific rule, null alone MUST NOT trigger sanitization. The OTLP null-attribute omission requirements for logs and traces are unchanged. - -#### Scenario: Queued capture preserves nulls on the wire (@both) -- **GIVEN** an initialized SDK with a valid distinct id and no property-changing hooks or filters -- **WHEN** capture is called with event "Nullable Properties" and custom properties represented by JSON `{"optional":null,"nested":{"value":null},"items":["first",null,"last"],"empty":"","zero":0,"enabled":false}` -- **AND** the SDK is flushed -- **THEN** the received event should contain every supplied custom property with the same JSON value -- **AND** "optional" and "nested.value" should be present with JSON null values, not the string "null" -- **AND** "items" should contain three elements with JSON null at index 1 -- **AND** the absent custom property "missing" should remain absent - -#### Scenario: Immediate capture preserves nulls on the wire (@both) -- **GIVEN** an initialized SDK supporting immediate delivery with a valid distinct id and no property-changing hooks or filters -- **AND** capture is configured for immediate delivery -- **WHEN** capture is called with event "Nullable Properties" and custom properties represented by JSON `{"optional":null,"nested":{"value":null},"items":["first",null,"last"]}` -- **AND** the immediate send completes -- **THEN** the received event should contain every supplied custom property with the same JSON value -- **AND** "items" should contain three elements with JSON null at index 1 - -#### Scenario: JavaScript object undefined remains distinct from null (@both) -- **GIVEN** an initialized JavaScript SDK with a valid distinct id and no property-changing hooks or filters -- **WHEN** capture is called with event "Absent Versus Null" and properties `{ optional: null, missing: undefined }` -- **AND** the SDK is flushed -- **THEN** the received event property "optional" should be present with JSON null -- **AND** the received event properties should not contain "missing" diff --git a/openspec/changes/archive/2026-09-08-preserve-null-capture-properties/tasks.md b/openspec/changes/archive/2026-09-08-preserve-null-capture-properties/tasks.md deleted file mode 100644 index 47d0e63..0000000 --- a/openspec/changes/archive/2026-09-08-preserve-null-capture-properties/tasks.md +++ /dev/null @@ -1,11 +0,0 @@ -## 1. Contract and acceptance coverage - -- [x] 1.1 Define null preservation in capture and reference it from AI and exception capture, with scoped exclusions and SDK compatibility evidence. -- [x] 1.2 Add matching wire-level acceptance scenarios for queued/immediate capture, undefined versus null, AI capture, and exception capture. - -## 2. Validation - -- [x] 2.1 Validate the OpenSpec change strictly and check acceptance syntax and scenario coverage. -- [x] 2.2 Review the delta against canonical specs for scope and compatibility before archive sync. - -Validation: strict change validation passed; all 62 existing canonical specs passed strict validation; all three edited Gherkin files parsed; five new scenarios matched their delta specs and JSON examples parsed successfully; `git diff --check` passed. These are specification checks, not SDK conformance tests. Canonical specs were synced through `openspec archive` on 2026-09-08 after owner approval, and all 62 canonical specs passed strict validation again. diff --git a/openspec/changes/archive/2026-09-08-preserve-nullable-capture-api/design.md b/openspec/changes/archive/2026-09-08-preserve-nullable-capture-api/design.md index 3745d2e..448206f 100644 --- a/openspec/changes/archive/2026-09-08-preserve-nullable-capture-api/design.md +++ b/openspec/changes/archive/2026-09-08-preserve-nullable-capture-api/design.md @@ -1,23 +1,29 @@ ## Context -The current target omits null-valued object members before sending but does not explicitly protect existing nullable input APIs or cover disk-backed event serialization. The owner clarified that cleanup belongs at serialization boundaries, not in stricter public argument types or validation. +The capture contract needs a consistent serialization policy that distinguishes object members from array elements without breaking existing public input types. Backend object-null removal is planned and still in progress, not universally deployed. + +Android already drops null-valued map entries but also drops null list elements, so it does not fully match this target. Python PR #926 proposes top-level omission. SDK implementation and rollout are separate from this specification change. ## Goals / Non-Goals -**Goals:** Keep previously supported null/undefined property inputs source-compatible and runtime-compatible. Normalize serialized event data for both network and disk persistence while retaining array positions. +**Goals:** Omit null/undefined-valued custom object members when serializing events for the wire or disk. Preserve array positions and existing public API acceptance across ordinary, immediate, AI, and exception capture. -**Non-Goals:** Widening APIs that currently reject null values, adding persistence to SDKs without it, changing generic storage/feature-flag caches, or implementing SDK changes. +**Non-Goals:** SDK/backend implementation, widening currently non-nullable APIs, adding persistence, changing generic storage/feature-flag caches, or changing reserved-field validation and OTLP encoding. ## Decisions -- Extend the shared capture requirement rather than duplicate policy in AI and exception specs. Those capture methods already reference it. -- Preserve existing property-map value types, signatures, and runtime acceptance. Existing caller code must not require casts, filtering, or a different API solely because of this normalization. -- Apply cleanup to event serialization for wire transport and disk-backed event queues/caches. In-memory representations may still contain null/undefined. Do not change hook timing; the wire payload must still satisfy cleanup after before-send processing. -- Preserve null array elements. JavaScript undefined array entries serialize as null under ordinary JSON rules; neither case permits array compaction. -- Validate persisted event contents before network delivery so a wire-only implementation does not satisfy the disk scenario accidentally. After restoring the event, check the wire payload again. +- Define one shared capture requirement and reference it from AI and exception capture. +- Clean object members recursively, including objects inside arrays. Do not compact arrays or discard objects made empty by cleanup. Preserve `false`, `0`, empty strings, and literal strings such as `"null"` and `"undefined"`. +- Preserve null array elements. JavaScript undefined array entries follow normal JSON serialization as null without shifting positions. +- Preserve existing signatures, property value types, and runtime acceptance. Callers must not need casts, filtering, or a different API solely because of this normalization. In-memory events may retain null/undefined until serialization. +- Apply cleanup during serialization for network delivery and disk-backed event queues/caches. Do not change hook timing; the wire payload must still satisfy cleanup after enrichment and before-send processing. +- Treat AI normalization as a specific exception to payload pass-through, not permission for manual-payload redaction, truncation, or media processing. +- Validate disk contents before delivery, then validate the restored event on the wire. An event whose custom properties are all removed must still be delivered if otherwise admissible. ## Risks / Trade-offs -- Confusing API acceptance with serialized output → explicitly test previously supported inputs and forbid narrowing public types or rejecting those calls. -- Applying event cleanup to unrelated caches → scope disk normalization to serialized captured events, not general persistent storage semantics. -- Typed/null-restricting SDKs or SDKs without disk queues cannot exercise every scenario → make those scenarios conditional on existing support and require no new public surface or persistence implementation. +- Existing serialized output changes → require per-SDK compatibility review and rollout without narrowing supported public inputs. +- Backend work is unfinished → document it as planned behavior, not an existing universal guarantee. +- Recursive cleanup can accidentally compact arrays → fixtures include null positions and objects emptied inside arrays. +- Event normalization could affect unrelated caches → limit persistence cleanup to captured events. +- Some SDKs lack nullable inputs or disk queues → condition relevant scenarios on existing support rather than require new APIs or persistence. diff --git a/openspec/changes/archive/2026-09-08-preserve-nullable-capture-api/proposal.md b/openspec/changes/archive/2026-09-08-preserve-nullable-capture-api/proposal.md index 89b7502..5ca7ac2 100644 --- a/openspec/changes/archive/2026-09-08-preserve-nullable-capture-api/proposal.md +++ b/openspec/changes/archive/2026-09-08-preserve-nullable-capture-api/proposal.md @@ -1,13 +1,15 @@ ## Why -Null-property omission is a serialization rule, not a reason to narrow an SDK's existing public API. Callers that can already supply null or undefined property values must remain compatible, and serialized event caches should use the same cleanup as network payloads. +SDKs should omit null/undefined-valued object properties when serializing captured events, while preserving array positions and existing public APIs that accept those values. The owner selected object-member omission to align with planned backend removal; that backend work is still in progress. ## What Changes -- Preserve public signatures and property types that already accept null/undefined values. Do not reject previously supported calls or require callers to pre-filter their properties. -- Apply object-member omission during event serialization for both wire delivery and disk-backed event caches/queues. -- Preserve the existing recursive cleanup and array-position rules. Null array elements remain valid, and JavaScript undefined array entries follow normal JSON serialization without compacting arrays. -- Add acceptance scenarios for existing nullable calls and event persistence, including undefined values where supported. +- Omit null/undefined-valued custom object members recursively during event serialization for wire delivery and disk-backed event queues/caches. +- Preserve null array elements, array positions, non-null values, and objects made empty by cleanup. +- Preserve public signatures, property types, and runtime acceptance for existing nullable/undefined inputs. Do not require callers to pre-filter their properties. +- Apply the shared contract to queued, immediate, AI, and exception capture, including values introduced by `before_send`. +- Add ten acceptance scenarios covering wire output, existing API compatibility, and event persistence/restore. +- **BREAKING** serialized-output change for SDKs currently retaining null-valued object members. SDK compatibility review and rollout remain separate; public inputs must not be narrowed. ## Capabilities @@ -17,8 +19,12 @@ None. ### Modified Capabilities -- `capture`: Clarify API compatibility and event serialization boundaries. AI and exception capture inherit the clarification through their existing references to this requirement. +- `capture`: Define object-member omission at serialization boundaries, preserve array positions, and protect existing public input APIs. +- `capture-ai`: Apply the shared contract to the AI route. +- `capture-exception`: Apply the shared contract to additional custom properties without changing SDK-owned metadata rules. ## Impact -Specifications and acceptance scenarios only. This clarification does not require making currently non-nullable APIs nullable, adding disk persistence, changing unrelated caches, or implementing SDK/backend changes. Serialized payloads still change for SDKs currently retaining null-valued object members, but their public input APIs must not be narrowed as part of this work. +Specifications and acceptance scenarios only. No SDK or backend implementation changes. This does not require widening currently non-nullable APIs, adding disk persistence, or changing unrelated caches, privacy controls, reserved-field validation, or OTLP logs/traces rules. + +Context: https://github.com/PostHog/posthog-python/pull/926 diff --git a/openspec/changes/archive/2026-09-08-drop-null-capture-properties/specs/capture-ai/spec.md b/openspec/changes/archive/2026-09-08-preserve-nullable-capture-api/specs/capture-ai/spec.md similarity index 81% rename from openspec/changes/archive/2026-09-08-drop-null-capture-properties/specs/capture-ai/spec.md rename to openspec/changes/archive/2026-09-08-preserve-nullable-capture-api/specs/capture-ai/spec.md index 091ee03..4d1de02 100644 --- a/openspec/changes/archive/2026-09-08-drop-null-capture-properties/specs/capture-ai/spec.md +++ b/openspec/changes/archive/2026-09-08-preserve-nullable-capture-api/specs/capture-ai/spec.md @@ -1,10 +1,3 @@ -## REMOVED Requirements - -### Requirement: AI capture preserves explicit null properties - -**Reason**: AI capture follows the revised shared null-property contract. -**Migration**: Omit null-valued object members while preserving null array elements on the AI route. - ## ADDED Requirements ### Requirement: AI capture drops null-valued object properties diff --git a/openspec/changes/archive/2026-09-08-drop-null-capture-properties/specs/capture-exception/spec.md b/openspec/changes/archive/2026-09-08-preserve-nullable-capture-api/specs/capture-exception/spec.md similarity index 77% rename from openspec/changes/archive/2026-09-08-drop-null-capture-properties/specs/capture-exception/spec.md rename to openspec/changes/archive/2026-09-08-preserve-nullable-capture-api/specs/capture-exception/spec.md index 5e50f52..089e1d6 100644 --- a/openspec/changes/archive/2026-09-08-drop-null-capture-properties/specs/capture-exception/spec.md +++ b/openspec/changes/archive/2026-09-08-preserve-nullable-capture-api/specs/capture-exception/spec.md @@ -1,10 +1,3 @@ -## REMOVED Requirements - -### Requirement: Exception capture preserves explicit null custom properties - -**Reason**: Exception capture follows the revised shared null-property contract. -**Migration**: Omit null-valued custom object members while preserving null array elements without changing SDK-owned exception metadata rules. - ## ADDED Requirements ### Requirement: Exception capture drops null-valued custom object properties diff --git a/openspec/changes/archive/2026-09-08-preserve-nullable-capture-api/specs/capture/spec.md b/openspec/changes/archive/2026-09-08-preserve-nullable-capture-api/specs/capture/spec.md index f6b4e7a..33ca30a 100644 --- a/openspec/changes/archive/2026-09-08-preserve-nullable-capture-api/specs/capture/spec.md +++ b/openspec/changes/archive/2026-09-08-preserve-nullable-capture-api/specs/capture/spec.md @@ -1,4 +1,4 @@ -## MODIFIED Requirements +## ADDED Requirements ### Requirement: Capture drops null-valued object properties diff --git a/openspec/changes/archive/2026-09-08-preserve-nullable-capture-api/tasks.md b/openspec/changes/archive/2026-09-08-preserve-nullable-capture-api/tasks.md index 37135c4..1b7a9f6 100644 --- a/openspec/changes/archive/2026-09-08-preserve-nullable-capture-api/tasks.md +++ b/openspec/changes/archive/2026-09-08-preserve-nullable-capture-api/tasks.md @@ -1,11 +1,12 @@ ## 1. Contract and acceptance -- [x] 1.1 Clarify existing public API compatibility and event serialization for wire and disk in the shared capture requirement. -- [x] 1.2 Add acceptance scenarios for existing nullable inputs and persisted null/undefined values without changing earlier array-position coverage. +- [x] 1.1 Define recursive null/undefined object-member omission during event serialization while preserving array positions and existing public APIs. +- [x] 1.2 Apply the shared contract to AI and exception capture. +- [x] 1.3 Add ten acceptance scenarios for wire delivery, nullable input compatibility, and disk persistence/restore. -## 2. Validation +## 2. Validation and archive -- [x] 2.1 Validate the delta, Gherkin syntax, scenario coverage, and serialized JSON examples. -- [x] 2.2 Review the clarification against the existing AI/exception references and disk-storage scope before sync/archive. +- [x] 2.1 Validate OpenSpec and Gherkin syntax, scenario coverage, and JSON input/output examples. +- [x] 2.2 Sync canonical specs and consolidate the final contract into one archive relative to the PR base. -Strict delta validation passed. Ten current null-policy scenarios across capture, AI, and exception capture match the Gherkin files. JSON input/output examples, including disk and restored-event payloads, match reference normalization without array compaction. API compatibility wording was inspected statically; no SDK compilation, runtime, or persistence conformance tests were run. +Validation covers specification consistency and serialized JSON fixtures, not SDK/backend conformance. API compatibility was inspected statically; no SDK compilation, runtime, or persistence tests were run.