From c2a225377b7903a30757a74359f9d81af9a58847 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Chaonan=E2=80=9D?= Date: Sat, 22 Aug 2026 16:59:52 +0800 Subject: [PATCH] fix(protocol): align trace schemas with v2/v3 classify rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RecordedTrace validation now follows classify_value — numeric version selects v3, otherwise v2 — so mixed pages/states and v2 envelopes with a version field fail Schema checks before Recall import. Keep standalone trace_step as a v2|v3 union for tools that validate a single step. --- apps/extension/src/transport/types.ts | 2 + .../schema/tool_record_await_result.json | 32 +- .../schema/tool_record_stop_result.json | 32 +- crates/bsk-protocol/schema/trace.json | 32 +- crates/bsk-protocol/schema/trace_step.json | 929 ++++++++++++------ crates/bsk-protocol/schema/trace_step_v2.json | 368 +++++++ crates/bsk-protocol/schema/trace_step_v3.json | 375 +++++++ crates/bsk-protocol/schema/trace_v2.json | 9 +- crates/bsk-protocol/src/bin/dump-schema.rs | 4 +- crates/bsk-protocol/src/tools/record.rs | 238 ++++- crates/bsk-protocol/src/tools/record_v2.rs | 83 +- 11 files changed, 1784 insertions(+), 320 deletions(-) create mode 100644 crates/bsk-protocol/schema/trace_step_v2.json create mode 100644 crates/bsk-protocol/schema/trace_step_v3.json diff --git a/apps/extension/src/transport/types.ts b/apps/extension/src/transport/types.ts index b10425c..c72a44c 100644 --- a/apps/extension/src/transport/types.ts +++ b/apps/extension/src/transport/types.ts @@ -661,6 +661,7 @@ export interface EmulateResult { // -------------------------------------------------------------------------- export const TRACE_VERSION_V3 = 3; +/** Logical v2 identifier. Not a wire field — v2 envelopes omit `version`. */ export const TRACE_VERSION_V2 = 2; export const VOM_FORMAT_VERSION = 1; @@ -853,6 +854,7 @@ export interface TraceV3 { } export type RecordedTrace = TraceV2 | TraceV3; +export type RecordedStep = StepV2 | StepV3; export interface RecordStartParams { session_id: string; diff --git a/crates/bsk-protocol/schema/tool_record_await_result.json b/crates/bsk-protocol/schema/tool_record_await_result.json index 28b140e..df2a122 100644 --- a/crates/bsk-protocol/schema/tool_record_await_result.json +++ b/crates/bsk-protocol/schema/tool_record_await_result.json @@ -64,14 +64,22 @@ } }, "RecordedTrace": { - "oneOf": [ - { - "$ref": "#/definitions/TraceV2" - }, - { - "$ref": "#/definitions/TraceV3" + "if": { + "required": [ + "version" + ], + "properties": { + "version": { + "type": "integer" + } } - ] + }, + "then": { + "$ref": "#/definitions/TraceV3" + }, + "else": { + "$ref": "#/definitions/TraceV2" + } }, "RecorderInfo": { "type": "object", @@ -834,7 +842,8 @@ } }, "TraceV2": { - "description": "Persisted user-action trace exported by legacy `tool.record_stop` / `await`.\n\nUnknown extension fields are ignored so older traces remain readable. Mixed v2/v3 envelopes are rejected by `RecordedTrace` classification.", + "title": "TraceV2", + "description": "Persisted user-action trace exported by legacy `tool.record_stop` / `await`.\n\nUnknown extension fields are ignored so older traces remain readable. `states[]` and a numeric `version` are reserved for Trace v3 / `RecordedTrace` classification.", "type": "object", "required": [ "entry", @@ -868,11 +877,18 @@ "null" ] }, + "states": false, "steps": { "type": "array", "items": { "$ref": "#/definitions/StepV2" } + }, + "version": { + "description": "Numeric version selects Trace v3. Legacy v2 envelopes omit this field.", + "not": { + "type": "integer" + } } } }, diff --git a/crates/bsk-protocol/schema/tool_record_stop_result.json b/crates/bsk-protocol/schema/tool_record_stop_result.json index c1477d4..c1dfef0 100644 --- a/crates/bsk-protocol/schema/tool_record_stop_result.json +++ b/crates/bsk-protocol/schema/tool_record_stop_result.json @@ -64,14 +64,22 @@ } }, "RecordedTrace": { - "oneOf": [ - { - "$ref": "#/definitions/TraceV2" - }, - { - "$ref": "#/definitions/TraceV3" + "if": { + "required": [ + "version" + ], + "properties": { + "version": { + "type": "integer" + } } - ] + }, + "then": { + "$ref": "#/definitions/TraceV3" + }, + "else": { + "$ref": "#/definitions/TraceV2" + } }, "RecorderInfo": { "type": "object", @@ -834,7 +842,8 @@ } }, "TraceV2": { - "description": "Persisted user-action trace exported by legacy `tool.record_stop` / `await`.\n\nUnknown extension fields are ignored so older traces remain readable. Mixed v2/v3 envelopes are rejected by `RecordedTrace` classification.", + "title": "TraceV2", + "description": "Persisted user-action trace exported by legacy `tool.record_stop` / `await`.\n\nUnknown extension fields are ignored so older traces remain readable. `states[]` and a numeric `version` are reserved for Trace v3 / `RecordedTrace` classification.", "type": "object", "required": [ "entry", @@ -868,11 +877,18 @@ "null" ] }, + "states": false, "steps": { "type": "array", "items": { "$ref": "#/definitions/StepV2" } + }, + "version": { + "description": "Numeric version selects Trace v3. Legacy v2 envelopes omit this field.", + "not": { + "type": "integer" + } } } }, diff --git a/crates/bsk-protocol/schema/trace.json b/crates/bsk-protocol/schema/trace.json index 4737d0a..664e035 100644 --- a/crates/bsk-protocol/schema/trace.json +++ b/crates/bsk-protocol/schema/trace.json @@ -1,14 +1,22 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "RecordedTrace", - "oneOf": [ - { - "$ref": "#/definitions/TraceV2" - }, - { - "$ref": "#/definitions/TraceV3" + "if": { + "required": [ + "version" + ], + "properties": { + "version": { + "type": "integer" + } } - ], + }, + "then": { + "$ref": "#/definitions/TraceV3" + }, + "else": { + "$ref": "#/definitions/TraceV2" + }, "definitions": { "FillCommit": { "type": "string", @@ -823,7 +831,8 @@ } }, "TraceV2": { - "description": "Persisted user-action trace exported by legacy `tool.record_stop` / `await`.\n\nUnknown extension fields are ignored so older traces remain readable. Mixed v2/v3 envelopes are rejected by `RecordedTrace` classification.", + "title": "TraceV2", + "description": "Persisted user-action trace exported by legacy `tool.record_stop` / `await`.\n\nUnknown extension fields are ignored so older traces remain readable. `states[]` and a numeric `version` are reserved for Trace v3 / `RecordedTrace` classification.", "type": "object", "required": [ "entry", @@ -857,11 +866,18 @@ "null" ] }, + "states": false, "steps": { "type": "array", "items": { "$ref": "#/definitions/StepV2" } + }, + "version": { + "description": "Numeric version selects Trace v3. Legacy v2 envelopes omit this field.", + "not": { + "type": "integer" + } } } }, diff --git a/crates/bsk-protocol/schema/trace_step.json b/crates/bsk-protocol/schema/trace_step.json index 14a33ee..ae74942 100644 --- a/crates/bsk-protocol/schema/trace_step.json +++ b/crates/bsk-protocol/schema/trace_step.json @@ -1,283 +1,23 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "title": "StepV3", - "description": "One recorded user action — discriminated union by `op`.", - "oneOf": [ - { - "description": "Fields shared by every step variant (flattened in JSON).", - "type": "object", - "required": [ - "cause", - "id", - "op", - "result", - "state", - "to" - ], - "properties": { - "cause": { - "$ref": "#/definitions/NavigationCause" - }, - "id": { - "type": "integer", - "format": "uint32", - "minimum": 0.0 - }, - "op": { - "type": "string", - "enum": [ - "navigate" - ] - }, - "result": { - "$ref": "#/definitions/StepResultV3" - }, - "state": { - "description": "Observation id immediately before this action.", - "type": "string" - }, - "to": { - "type": "string" - } - } - }, - { - "description": "Fields shared by every step variant (flattened in JSON).", - "type": "object", - "required": [ - "id", - "op", - "result", - "state", - "target" - ], - "properties": { - "id": { - "type": "integer", - "format": "uint32", - "minimum": 0.0 - }, - "op": { - "type": "string", - "enum": [ - "click" - ] - }, - "result": { - "$ref": "#/definitions/StepResultV3" - }, - "state": { - "description": "Observation id immediately before this action.", - "type": "string" - }, - "target": { - "$ref": "#/definitions/TargetDescriptorV3" - } - } - }, - { - "description": "Fields shared by every step variant (flattened in JSON).", - "type": "object", - "required": [ - "id", - "op", - "result", - "state", - "target" - ], - "properties": { - "id": { - "type": "integer", - "format": "uint32", - "minimum": 0.0 - }, - "op": { - "type": "string", - "enum": [ - "hover" - ] - }, - "result": { - "$ref": "#/definitions/StepResultV3" - }, - "state": { - "description": "Observation id immediately before this action.", - "type": "string" - }, - "target": { - "$ref": "#/definitions/TargetDescriptorV3" - } - } - }, - { - "description": "Fields shared by every step variant (flattened in JSON).", - "type": "object", - "required": [ - "commit", - "id", - "op", - "result", - "state", - "target", - "value" - ], - "properties": { - "commit": { - "$ref": "#/definitions/FillCommit" - }, - "id": { - "type": "integer", - "format": "uint32", - "minimum": 0.0 - }, - "op": { - "type": "string", - "enum": [ - "fill" - ] - }, - "redacted": { - "type": "boolean" - }, - "result": { - "$ref": "#/definitions/StepResultV3" - }, - "state": { - "description": "Observation id immediately before this action.", - "type": "string" - }, - "target": { - "$ref": "#/definitions/TargetDescriptorV3" - }, - "value": { - "type": "string" - } - } - }, - { - "description": "Fields shared by every step variant (flattened in JSON).", - "type": "object", - "required": [ - "id", - "op", - "result", - "state", - "target" - ], - "properties": { - "id": { - "type": "integer", - "format": "uint32", - "minimum": 0.0 - }, - "op": { - "type": "string", - "enum": [ - "select" - ] - }, - "result": { - "$ref": "#/definitions/StepResultV3" - }, - "selection": { - "type": "array", - "items": { - "$ref": "#/definitions/SelectedOptionV3" - } - }, - "state": { - "description": "Observation id immediately before this action.", - "type": "string" - }, - "target": { - "$ref": "#/definitions/TargetDescriptorV3" - } - } - }, - { - "description": "Fields shared by every step variant (flattened in JSON).", - "type": "object", - "required": [ - "id", - "key", - "op", - "result", - "state" - ], - "properties": { - "id": { - "type": "integer", - "format": "uint32", - "minimum": 0.0 - }, - "key": { - "type": "string" - }, - "modifiers": { - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/KeyModifier" - } - }, - "op": { - "type": "string", - "enum": [ - "press" - ] - }, - "result": { - "$ref": "#/definitions/StepResultV3" - }, - "state": { - "description": "Observation id immediately before this action.", - "type": "string" - }, - "target": { - "anyOf": [ - { - "$ref": "#/definitions/TargetDescriptorV3" - }, - { - "type": "null" - } - ] - } - } - }, - { - "description": "Fields shared by every step variant (flattened in JSON).", - "type": "object", - "required": [ - "id", - "op", - "result", - "state" - ], - "properties": { - "id": { - "type": "integer", - "format": "uint32", - "minimum": 0.0 - }, - "op": { - "type": "string", - "enum": [ - "scroll" - ] - }, - "result": { - "$ref": "#/definitions/StepResultV3" - }, - "state": { - "description": "Observation id immediately before this action.", - "type": "string" - } - } - } - ], + "title": "RecordedStep", + "not": { + "required": [ + "page", + "state" + ] + }, + "if": { + "required": [ + "state" + ] + }, + "then": { + "$ref": "#/definitions/StepV3" + }, + "else": { + "$ref": "#/definitions/StepV2" + }, "definitions": { "FillCommit": { "type": "string", @@ -309,6 +49,24 @@ "browser" ] }, + "SelectedOptionV2": { + "description": "One selected option (`select` op).", + "type": "object", + "required": [ + "value" + ], + "properties": { + "label": { + "type": [ + "string", + "null" + ] + }, + "value": { + "type": "string" + } + } + }, "SelectedOptionV3": { "description": "One selected option (`select` op).", "type": "object", @@ -327,6 +85,19 @@ } } }, + "StepEffectV2": { + "description": "Observed navigation after a step (objective fact only).", + "type": "object", + "required": [ + "navigated_to" + ], + "properties": { + "navigated_to": { + "description": "Reference into `pages[]` for the destination page.", + "type": "string" + } + } + }, "StepResultV3": { "type": "object", "required": [ @@ -338,6 +109,608 @@ } } }, + "StepV2": { + "description": "One recorded user action — discriminated union by `op` (v2).", + "oneOf": [ + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "page", + "to" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "navigate" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "to": { + "type": "string" + } + } + }, + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "page", + "target" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "click" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV2" + } + } + }, + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "page", + "target" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "hover" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV2" + } + } + }, + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "page", + "target", + "value" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "fill" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "redacted": { + "type": [ + "boolean", + "null" + ] + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV2" + }, + "value": { + "type": "string" + } + } + }, + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "page", + "selection", + "target" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "select" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "selection": { + "type": "array", + "items": { + "$ref": "#/definitions/SelectedOptionV2" + } + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV2" + } + } + }, + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "key", + "op", + "page" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "key": { + "type": "string" + }, + "modifiers": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/KeyModifier" + } + }, + "op": { + "type": "string", + "enum": [ + "press" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "target": { + "anyOf": [ + { + "$ref": "#/definitions/TargetDescriptorV2" + }, + { + "type": "null" + } + ] + } + } + } + ] + }, + "StepV3": { + "description": "One recorded user action — discriminated union by `op`.", + "oneOf": [ + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "cause", + "id", + "op", + "result", + "state", + "to" + ], + "properties": { + "cause": { + "$ref": "#/definitions/NavigationCause" + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "navigate" + ] + }, + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, + "to": { + "type": "string" + } + } + }, + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "result", + "state", + "target" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "click" + ] + }, + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV3" + } + } + }, + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "result", + "state", + "target" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "hover" + ] + }, + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV3" + } + } + }, + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "commit", + "id", + "op", + "result", + "state", + "target", + "value" + ], + "properties": { + "commit": { + "$ref": "#/definitions/FillCommit" + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "fill" + ] + }, + "redacted": { + "type": "boolean" + }, + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV3" + }, + "value": { + "type": "string" + } + } + }, + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "result", + "state", + "target" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "select" + ] + }, + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "selection": { + "type": "array", + "items": { + "$ref": "#/definitions/SelectedOptionV3" + } + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV3" + } + } + }, + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "key", + "op", + "result", + "state" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "key": { + "type": "string" + }, + "modifiers": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/KeyModifier" + } + }, + "op": { + "type": "string", + "enum": [ + "press" + ] + }, + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, + "target": { + "anyOf": [ + { + "$ref": "#/definitions/TargetDescriptorV3" + }, + { + "type": "null" + } + ] + } + } + }, + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "result", + "state" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "scroll" + ] + }, + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + } + } + } + ] + }, + "TargetDescriptorV2": { + "description": "Stable semantic handle for an interacted element (v2).\n\n`name` and `nearby_label` are **untrusted page text**.", + "type": "object", + "required": [ + "tag" + ], + "properties": { + "name": { + "type": [ + "string", + "null" + ] + }, + "name_attr": { + "type": [ + "string", + "null" + ] + }, + "nearby_label": { + "type": [ + "string", + "null" + ] + }, + "placeholder": { + "type": [ + "string", + "null" + ] + }, + "role": { + "type": [ + "string", + "null" + ] + }, + "tag": { + "type": "string" + } + } + }, "TargetDescriptorV3": { "description": "Stable semantic handle for an interacted element within a page observation.", "type": "object", diff --git a/crates/bsk-protocol/schema/trace_step_v2.json b/crates/bsk-protocol/schema/trace_step_v2.json new file mode 100644 index 0000000..2d11f98 --- /dev/null +++ b/crates/bsk-protocol/schema/trace_step_v2.json @@ -0,0 +1,368 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "StepV2", + "description": "One recorded user action — discriminated union by `op` (v2).", + "oneOf": [ + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "page", + "to" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "navigate" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "to": { + "type": "string" + } + } + }, + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "page", + "target" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "click" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV2" + } + } + }, + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "page", + "target" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "hover" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV2" + } + } + }, + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "page", + "target", + "value" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "fill" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "redacted": { + "type": [ + "boolean", + "null" + ] + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV2" + }, + "value": { + "type": "string" + } + } + }, + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "page", + "selection", + "target" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "select" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "selection": { + "type": "array", + "items": { + "$ref": "#/definitions/SelectedOptionV2" + } + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV2" + } + } + }, + { + "description": "Fields shared by every v2 step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "key", + "op", + "page" + ], + "properties": { + "effect": { + "anyOf": [ + { + "$ref": "#/definitions/StepEffectV2" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "key": { + "type": "string" + }, + "modifiers": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/KeyModifier" + } + }, + "op": { + "type": "string", + "enum": [ + "press" + ] + }, + "page": { + "description": "Reference into `pages[]`.", + "type": "string" + }, + "target": { + "anyOf": [ + { + "$ref": "#/definitions/TargetDescriptorV2" + }, + { + "type": "null" + } + ] + } + } + } + ], + "definitions": { + "KeyModifier": { + "description": "Keyboard modifier flags. Multiple flags may be combined; the extension folds them into CDP's bitfield (`alt=1, ctrl=2, meta=4, shift=8`).", + "type": "string", + "enum": [ + "alt", + "ctrl", + "meta", + "shift" + ] + }, + "SelectedOptionV2": { + "description": "One selected option (`select` op).", + "type": "object", + "required": [ + "value" + ], + "properties": { + "label": { + "type": [ + "string", + "null" + ] + }, + "value": { + "type": "string" + } + } + }, + "StepEffectV2": { + "description": "Observed navigation after a step (objective fact only).", + "type": "object", + "required": [ + "navigated_to" + ], + "properties": { + "navigated_to": { + "description": "Reference into `pages[]` for the destination page.", + "type": "string" + } + } + }, + "TargetDescriptorV2": { + "description": "Stable semantic handle for an interacted element (v2).\n\n`name` and `nearby_label` are **untrusted page text**.", + "type": "object", + "required": [ + "tag" + ], + "properties": { + "name": { + "type": [ + "string", + "null" + ] + }, + "name_attr": { + "type": [ + "string", + "null" + ] + }, + "nearby_label": { + "type": [ + "string", + "null" + ] + }, + "placeholder": { + "type": [ + "string", + "null" + ] + }, + "role": { + "type": [ + "string", + "null" + ] + }, + "tag": { + "type": "string" + } + } + } + } +} diff --git a/crates/bsk-protocol/schema/trace_step_v3.json b/crates/bsk-protocol/schema/trace_step_v3.json new file mode 100644 index 0000000..14a33ee --- /dev/null +++ b/crates/bsk-protocol/schema/trace_step_v3.json @@ -0,0 +1,375 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "StepV3", + "description": "One recorded user action — discriminated union by `op`.", + "oneOf": [ + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "cause", + "id", + "op", + "result", + "state", + "to" + ], + "properties": { + "cause": { + "$ref": "#/definitions/NavigationCause" + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "navigate" + ] + }, + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, + "to": { + "type": "string" + } + } + }, + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "result", + "state", + "target" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "click" + ] + }, + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV3" + } + } + }, + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "result", + "state", + "target" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "hover" + ] + }, + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV3" + } + } + }, + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "commit", + "id", + "op", + "result", + "state", + "target", + "value" + ], + "properties": { + "commit": { + "$ref": "#/definitions/FillCommit" + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "fill" + ] + }, + "redacted": { + "type": "boolean" + }, + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV3" + }, + "value": { + "type": "string" + } + } + }, + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "result", + "state", + "target" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "select" + ] + }, + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "selection": { + "type": "array", + "items": { + "$ref": "#/definitions/SelectedOptionV3" + } + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, + "target": { + "$ref": "#/definitions/TargetDescriptorV3" + } + } + }, + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "key", + "op", + "result", + "state" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "key": { + "type": "string" + }, + "modifiers": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/KeyModifier" + } + }, + "op": { + "type": "string", + "enum": [ + "press" + ] + }, + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + }, + "target": { + "anyOf": [ + { + "$ref": "#/definitions/TargetDescriptorV3" + }, + { + "type": "null" + } + ] + } + } + }, + { + "description": "Fields shared by every step variant (flattened in JSON).", + "type": "object", + "required": [ + "id", + "op", + "result", + "state" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "op": { + "type": "string", + "enum": [ + "scroll" + ] + }, + "result": { + "$ref": "#/definitions/StepResultV3" + }, + "state": { + "description": "Observation id immediately before this action.", + "type": "string" + } + } + } + ], + "definitions": { + "FillCommit": { + "type": "string", + "enum": [ + "enter", + "suggestion", + "blur" + ] + }, + "KeyModifier": { + "description": "Keyboard modifier flags. Multiple flags may be combined; the extension folds them into CDP's bitfield (`alt=1, ctrl=2, meta=4, shift=8`).", + "type": "string", + "enum": [ + "alt", + "ctrl", + "meta", + "shift" + ] + }, + "NavigationCause": { + "type": "string", + "enum": [ + "user_typed", + "link", + "form_submit", + "reload", + "history", + "script", + "browser" + ] + }, + "SelectedOptionV3": { + "description": "One selected option (`select` op).", + "type": "object", + "required": [ + "value" + ], + "properties": { + "label": { + "type": [ + "string", + "null" + ] + }, + "value": { + "type": "string" + } + } + }, + "StepResultV3": { + "type": "object", + "required": [ + "state" + ], + "properties": { + "state": { + "type": "string" + } + } + }, + "TargetDescriptorV3": { + "description": "Stable semantic handle for an interacted element within a page observation.", + "type": "object", + "properties": { + "ctx": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "ref": { + "type": [ + "string", + "null" + ] + }, + "role": { + "type": [ + "string", + "null" + ] + }, + "unmatched": { + "type": "boolean" + } + } + } + } +} diff --git a/crates/bsk-protocol/schema/trace_v2.json b/crates/bsk-protocol/schema/trace_v2.json index a914250..bd1081c 100644 --- a/crates/bsk-protocol/schema/trace_v2.json +++ b/crates/bsk-protocol/schema/trace_v2.json @@ -1,7 +1,7 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "TraceV2", - "description": "Persisted user-action trace exported by legacy `tool.record_stop` / `await`.\n\nUnknown extension fields are ignored so older traces remain readable. Mixed v2/v3 envelopes are rejected by `RecordedTrace` classification.", + "description": "Persisted user-action trace exported by legacy `tool.record_stop` / `await`.\n\nUnknown extension fields are ignored so older traces remain readable. `states[]` and a numeric `version` are reserved for Trace v3 / `RecordedTrace` classification.", "type": "object", "required": [ "entry", @@ -35,11 +35,18 @@ "null" ] }, + "states": false, "steps": { "type": "array", "items": { "$ref": "#/definitions/StepV2" } + }, + "version": { + "description": "Numeric version selects Trace v3. Legacy v2 envelopes omit this field.", + "not": { + "type": "integer" + } } }, "definitions": { diff --git a/crates/bsk-protocol/src/bin/dump-schema.rs b/crates/bsk-protocol/src/bin/dump-schema.rs index 9ec21e8..e1f4e6d 100644 --- a/crates/bsk-protocol/src/bin/dump-schema.rs +++ b/crates/bsk-protocol/src/bin/dump-schema.rs @@ -116,7 +116,9 @@ fn main() { dump!(TraceV2, "trace_v2"); dump!(TraceV3, "trace_v3"); dump!(RecordedTrace, "trace"); - dump!(StepV3, "trace_step"); + dump!(StepV2, "trace_step_v2"); + dump!(StepV3, "trace_step_v3"); + dump!(RecordedStep, "trace_step"); dump!(RecordStartParams, "tool_record_start_params"); dump!(RecordStartResult, "tool_record_start_result"); dump!(RecordStopParams, "tool_record_stop_params"); diff --git a/crates/bsk-protocol/src/tools/record.rs b/crates/bsk-protocol/src/tools/record.rs index cfb1b1a..d18eca1 100644 --- a/crates/bsk-protocol/src/tools/record.rs +++ b/crates/bsk-protocol/src/tools/record.rs @@ -16,6 +16,7 @@ pub use super::record_v3::{ VOM_FORMAT_VERSION, }; +/// Logical v2 identifier. Not a wire field — v2 envelopes omit `version`. pub const TRACE_VERSION_V2: u32 = 2; // --------------------------------------------------------------------------- @@ -45,6 +46,10 @@ pub struct RecordStopParams { } /// Wire trace payload — v2 (legacy `pages[]`) or v3 (`version: 3`, `states[]`). +/// +/// Classification matches [`RecordedTrace::classify_value`]: a numeric `version` +/// selects v3 (and forbids `pages[]`); otherwise the envelope is v2 and must +/// not include `states[]`. #[derive(Debug, Clone, PartialEq)] pub enum RecordedTrace { V2(TraceV2), @@ -104,12 +109,134 @@ impl JsonSchema for RecordedTrace { } fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::schema::Schema { + // Mirror classify_value: a numeric `version` selects v3; otherwise v2. + // TraceV2 stays open for unknown extension fields but forbids `states[]` + // and a numeric `version`. TraceV3 already denies `pages[]`. + let mut version_props = schemars::Map::new(); + version_props.insert( + "version".into(), + schemars::schema::SchemaObject { + instance_type: Some(schemars::schema::InstanceType::Integer.into()), + ..Default::default() + } + .into(), + ); + let mut required = schemars::Set::new(); + required.insert("version".into()); + schemars::schema::SchemaObject { subschemas: Some(Box::new(schemars::schema::SubschemaValidation { - one_of: Some(vec![ - generator.subschema_for::(), - generator.subschema_for::(), - ]), + if_schema: Some(Box::new( + schemars::schema::SchemaObject { + object: Some(Box::new(schemars::schema::ObjectValidation { + properties: version_props, + required, + ..Default::default() + })), + ..Default::default() + } + .into(), + )), + then_schema: Some(Box::new(generator.subschema_for::())), + else_schema: Some(Box::new(generator.subschema_for::())), + ..Default::default() + })), + ..Default::default() + } + .into() + } +} + +/// One recorded step — v2 (`page`) or v3 (`state` / `result.state`). +/// +/// Classification matches [`RecordedStep::classify_value`]: `state` selects v3 +/// (and forbids `page`); otherwise the step is v2 and must include `page`. +#[derive(Debug, Clone, PartialEq)] +pub enum RecordedStep { + V2(StepV2), + V3(StepV3), +} + +impl RecordedStep { + pub fn classify_value(v: &serde_json::Value) -> Result { + if v.get("state").is_some() { + if v.get("page").is_some() { + return Err("step v3 must not include legacy page".into()); + } + return serde_json::from_value(v.clone()) + .map(RecordedStep::V3) + .map_err(|e| e.to_string()); + } + if v.get("page").is_some() { + return serde_json::from_value(v.clone()) + .map(RecordedStep::V2) + .map_err(|e| e.to_string()); + } + Err("ambiguous or unparseable step".into()) + } +} + +impl Serialize for RecordedStep { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + RecordedStep::V2(s) => s.serialize(serializer), + RecordedStep::V3(s) => s.serialize(serializer), + } + } +} + +impl<'de> Deserialize<'de> for RecordedStep { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = serde_json::Value::deserialize(deserializer)?; + Self::classify_value(&value).map_err(serde::de::Error::custom) + } +} + +impl JsonSchema for RecordedStep { + fn schema_name() -> String { + "RecordedStep".into() + } + + fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::schema::Schema { + // Mirror classify_value: `state` selects v3; otherwise v2. + // Mixed page + state is rejected at this layer because StepV2/StepV3 + // still allow unknown extension fields. + let mut required = schemars::Set::new(); + required.insert("state".into()); + let mut mixed_keys = schemars::Set::new(); + mixed_keys.insert("page".into()); + mixed_keys.insert("state".into()); + + schemars::schema::SchemaObject { + subschemas: Some(Box::new(schemars::schema::SubschemaValidation { + if_schema: Some(Box::new( + schemars::schema::SchemaObject { + object: Some(Box::new(schemars::schema::ObjectValidation { + required, + ..Default::default() + })), + ..Default::default() + } + .into(), + )), + then_schema: Some(Box::new(generator.subschema_for::())), + else_schema: Some(Box::new(generator.subschema_for::())), + not: Some(Box::new( + schemars::schema::SchemaObject { + object: Some(Box::new(schemars::schema::ObjectValidation { + required: mixed_keys, + ..Default::default() + })), + ..Default::default() + } + .into(), + )), ..Default::default() })), ..Default::default() @@ -204,26 +331,43 @@ mod tests { assert!(serde_json::from_value::(unsupported).is_err()); } + fn assert_classifies_by_integer_version(schema: &serde_json::Value) { + assert_eq!( + schema["if"]["required"], + json!(["version"]), + "numeric version is the RecordedTrace classification key" + ); + assert_eq!(schema["if"]["properties"]["version"]["type"], "integer"); + assert_eq!(schema["then"]["$ref"], "#/definitions/TraceV3"); + assert_eq!(schema["else"]["$ref"], "#/definitions/TraceV2"); + } + #[test] fn recorded_trace_schema_includes_v2_and_v3() { let schema = serde_json::to_value(schemars::schema_for!(RecordStopResult)).unwrap(); - let variants = schema["definitions"]["RecordedTrace"]["oneOf"] - .as_array() - .expect("RecordedTrace schema should use oneOf"); + assert_classifies_by_integer_version(&schema["definitions"]["RecordedTrace"]); + assert_eq!( + schema["definitions"]["TraceV3"]["properties"]["version"]["const"], + 3 + ); + } - assert_eq!(variants.len(), 2); - let trace_schema = schema["definitions"]["TraceV3"].clone(); - assert_eq!(trace_schema["properties"]["version"]["const"], 3); + #[test] + fn recorded_trace_schema_follows_classify_value() { + let standalone = serde_json::to_value(schemars::schema_for!(RecordedTrace)).unwrap(); + assert_classifies_by_integer_version(&standalone); + assert!(standalone["definitions"].get("TraceV2").is_some()); + assert!(standalone["definitions"].get("TraceV3").is_some()); + + let stop_result = serde_json::to_value(schemars::schema_for!(RecordStopResult)).unwrap(); + assert_classifies_by_integer_version(&stop_result["definitions"]["RecordedTrace"]); } #[test] fn standalone_trace_schema_is_recorded_trace_union() { let schema = serde_json::to_value(schemars::schema_for!(RecordedTrace)).unwrap(); assert_eq!(schema["title"], "RecordedTrace"); - let variants = schema["oneOf"] - .as_array() - .expect("standalone trace schema should be a v2|v3 oneOf"); - assert_eq!(variants.len(), 2); + assert_classifies_by_integer_version(&schema); assert!(schema["definitions"].get("TraceV2").is_some()); assert!(schema["definitions"].get("TraceV3").is_some()); } @@ -242,4 +386,70 @@ mod tests { other => panic!("expected v2, got {other:?}"), } } + + fn v2_click() -> serde_json::Value { + json!({ + "op": "click", + "id": 1, + "page": "p1", + "target": { "tag": "button", "role": "button", "name": "发布" } + }) + } + + fn v3_click() -> serde_json::Value { + json!({ + "op": "click", + "id": 1, + "state": "s1", + "result": { "state": "s2" }, + "target": { "ref": "e1", "role": "button", "name": "发布" } + }) + } + + fn assert_step_classifies_by_state(schema: &serde_json::Value) { + assert_eq!( + schema["if"]["required"], + json!(["state"]), + "state is the RecordedStep classification key" + ); + assert_eq!(schema["then"]["$ref"], "#/definitions/StepV3"); + assert_eq!(schema["else"]["$ref"], "#/definitions/StepV2"); + let mut forbidden = schema["not"]["required"] + .as_array() + .expect("RecordedStep schema must reject mixed page + state steps") + .iter() + .filter_map(|v| v.as_str()) + .collect::>(); + forbidden.sort_unstable(); + assert_eq!(forbidden, ["page", "state"]); + } + + #[test] + fn recorded_step_classifies_v2_and_v3() { + match RecordedStep::classify_value(&v2_click()).unwrap() { + RecordedStep::V2(StepV2::Click { .. }) => {} + other => panic!("expected v2 click, got {other:?}"), + } + match RecordedStep::classify_value(&v3_click()).unwrap() { + RecordedStep::V3(StepV3::Click { .. }) => {} + other => panic!("expected v3 click, got {other:?}"), + } + } + + #[test] + fn recorded_step_rejects_mixed_page_and_state() { + let mut mixed = v2_click(); + mixed["state"] = json!("s1"); + mixed["result"] = json!({ "state": "s2" }); + assert!(RecordedStep::classify_value(&mixed).is_err()); + } + + #[test] + fn recorded_step_schema_follows_classify_value() { + let schema = serde_json::to_value(schemars::schema_for!(RecordedStep)).unwrap(); + assert_eq!(schema["title"], "RecordedStep"); + assert_step_classifies_by_state(&schema); + assert!(schema["definitions"].get("StepV2").is_some()); + assert!(schema["definitions"].get("StepV3").is_some()); + } } diff --git a/crates/bsk-protocol/src/tools/record_v2.rs b/crates/bsk-protocol/src/tools/record_v2.rs index b75b55d..2392381 100644 --- a/crates/bsk-protocol/src/tools/record_v2.rs +++ b/crates/bsk-protocol/src/tools/record_v2.rs @@ -107,8 +107,9 @@ pub enum StepV2 { /// Persisted user-action trace exported by legacy `tool.record_stop` / `await`. /// /// Unknown extension fields are ignored so older traces remain readable. -/// Mixed v2/v3 envelopes are rejected by `RecordedTrace` classification. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +/// `states[]` and a numeric `version` are reserved for Trace v3 / `RecordedTrace` +/// classification — serde still ignores them, but the JSON Schema rejects them. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct TraceV2 { /// RFC 3339 timestamp when recording stopped. pub recorded_at: String, @@ -121,6 +122,70 @@ pub struct TraceV2 { pub steps: Vec, } +fn constrain_trace_v2_schema(mut schema: schemars::schema::Schema) -> schemars::schema::Schema { + let schemars::schema::Schema::Object(obj) = &mut schema else { + return schema; + }; + obj.metadata().title = Some("TraceV2".into()); + obj.metadata().description = Some( + "Persisted user-action trace exported by legacy `tool.record_stop` / `await`.\n\n\ + Unknown extension fields are ignored so older traces remain readable. \ + `states[]` and a numeric `version` are reserved for Trace v3 / `RecordedTrace` classification." + .into(), + ); + + let object = obj.object.get_or_insert_with(Default::default); + object + .properties + .insert("states".into(), schemars::schema::Schema::Bool(false)); + object.properties.insert( + "version".into(), + schemars::schema::SchemaObject { + metadata: Some(Box::new(schemars::schema::Metadata { + description: Some( + "Numeric version selects Trace v3. Legacy v2 envelopes omit this field.".into(), + ), + ..Default::default() + })), + subschemas: Some(Box::new(schemars::schema::SubschemaValidation { + not: Some(Box::new( + schemars::schema::SchemaObject { + instance_type: Some(schemars::schema::InstanceType::Integer.into()), + ..Default::default() + } + .into(), + )), + ..Default::default() + })), + ..Default::default() + } + .into(), + ); + schema +} + +impl JsonSchema for TraceV2 { + fn schema_name() -> String { + "TraceV2".into() + } + + fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::schema::Schema { + #[derive(JsonSchema)] + #[allow(dead_code)] + struct TraceV2Shape { + /// RFC 3339 timestamp when recording stopped. + recorded_at: String, + started_at: Option, + purpose: Option, + entry: TraceEntry, + pages: Vec, + steps: Vec, + } + + constrain_trace_v2_schema(TraceV2Shape::json_schema(generator)) + } +} + #[cfg(test)] mod tests { use super::*; @@ -248,4 +313,18 @@ mod tests { "Trace v2 must keep accepting traces with unknown extension fields" ); } + + #[test] + fn trace_v2_schema_forbids_classification_keys() { + let schema = serde_json::to_value(schemars::schema_for!(TraceV2)).unwrap(); + assert_eq!( + schema["properties"]["states"], + json!(false), + "Trace v2 schema must reject states[] (reserved for v3)" + ); + assert_eq!( + schema["properties"]["version"]["not"]["type"], "integer", + "Trace v2 schema must reject a numeric version (that selects the v3 path)" + ); + } }