Skip to content

Commit 8b5f16b

Browse files
committed
fix(artifact): advance wire versions for provenance fields
The manifest and segment schemas gained session_kind and prompt_source without a version bump, so pre-provenance peers would import new artifacts under versions they claim to understand, silently drop the unknown fields, and mark the import complete. Advance the manifest format to v3 and the message segment format to v2 so older peers park new artifacts behind the future-version gate until they upgrade, and add explicit min-decode versions so v2 manifests and v1 segments still decode with the provenance fields defaulting to empty.
1 parent b8c062e commit 8b5f16b

7 files changed

Lines changed: 70 additions & 30 deletions

File tree

internal/artifact/format_test.go

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,10 +97,10 @@ func TestCanonicalManifestGolden(t *testing.T) {
9797
require.NoError(t, err)
9898

9999
assert.Equal(t,
100-
"{\"data_version\":99,\"generation\":3,\"native_session_id\":\"sess-1\",\"origin\":\"laptop-a1b2c3\",\"raw_source\":{\"hash\":\"raw123\",\"media_type\":\"application/jsonl\",\"path\":\"claude/session.jsonl\",\"size\":4096},\"segments\":[\"seg222\",\"seg111\"],\"session\":{\"agent\":\"claude\",\"compaction_count\":0,\"consecutive_failure_max\":0,\"created_at\":\"2026-06-14T01:02:03Z\",\"edit_churn_count\":0,\"ended_at\":\"2026-06-14T01:03:03Z\",\"ended_with_role\":\"\",\"final_failure_streak\":0,\"first_message\":\"hello\",\"has_peak_context_tokens\":false,\"has_total_output_tokens\":false,\"id\":\"sess-1\",\"is_automated\":false,\"machine\":\"laptop-a1b2c3\",\"message_count\":2,\"mid_task_compaction_count\":0,\"outcome\":\"\",\"outcome_confidence\":\"\",\"parent_session_id\":\"parent-1\",\"peak_context_tokens\":0,\"project\":\"alpha\",\"relationship_type\":\"subagent\",\"secret_leak_count\":0,\"started_at\":\"2026-06-14T01:02:03Z\",\"tool_failure_signal_count\":0,\"tool_retry_count\":0,\"total_output_tokens\":42,\"user_message_count\":1},\"session_has_context_data\":true,\"session_has_tool_calls\":true,\"session_name\":\"Fixture\",\"session_quality_signals\":{\"duplicate_prompt_count\":6,\"missing_success_criteria_count\":4,\"missing_verification_count\":5,\"no_code_context_count\":7,\"runaway_tool_loop_count\":1,\"short_prompt_count\":2,\"unstructured_start\":true,\"version\":3},\"usage_events\":[{\"cost\":{\"microdollars\":31250},\"cost_source\":\"fixture\",\"cost_status\":\"known\",\"dedup_key\":\"usage-1\",\"input_tokens\":11,\"message_ordinal\":2,\"model\":\"claude-test\",\"occurred_at\":\"2026-06-14T01:02:04Z\",\"output_tokens\":7,\"source\":\"fixture\"}],\"v\":2}\n",
100+
"{\"data_version\":99,\"generation\":3,\"native_session_id\":\"sess-1\",\"origin\":\"laptop-a1b2c3\",\"raw_source\":{\"hash\":\"raw123\",\"media_type\":\"application/jsonl\",\"path\":\"claude/session.jsonl\",\"size\":4096},\"segments\":[\"seg222\",\"seg111\"],\"session\":{\"agent\":\"claude\",\"compaction_count\":0,\"consecutive_failure_max\":0,\"created_at\":\"2026-06-14T01:02:03Z\",\"edit_churn_count\":0,\"ended_at\":\"2026-06-14T01:03:03Z\",\"ended_with_role\":\"\",\"final_failure_streak\":0,\"first_message\":\"hello\",\"has_peak_context_tokens\":false,\"has_total_output_tokens\":false,\"id\":\"sess-1\",\"is_automated\":false,\"machine\":\"laptop-a1b2c3\",\"message_count\":2,\"mid_task_compaction_count\":0,\"outcome\":\"\",\"outcome_confidence\":\"\",\"parent_session_id\":\"parent-1\",\"peak_context_tokens\":0,\"project\":\"alpha\",\"relationship_type\":\"subagent\",\"secret_leak_count\":0,\"started_at\":\"2026-06-14T01:02:03Z\",\"tool_failure_signal_count\":0,\"tool_retry_count\":0,\"total_output_tokens\":42,\"user_message_count\":1},\"session_has_context_data\":true,\"session_has_tool_calls\":true,\"session_name\":\"Fixture\",\"session_quality_signals\":{\"duplicate_prompt_count\":6,\"missing_success_criteria_count\":4,\"missing_verification_count\":5,\"no_code_context_count\":7,\"runaway_tool_loop_count\":1,\"short_prompt_count\":2,\"unstructured_start\":true,\"version\":3},\"usage_events\":[{\"cost\":{\"microdollars\":31250},\"cost_source\":\"fixture\",\"cost_status\":\"known\",\"dedup_key\":\"usage-1\",\"input_tokens\":11,\"message_ordinal\":2,\"model\":\"claude-test\",\"occurred_at\":\"2026-06-14T01:02:04Z\",\"output_tokens\":7,\"source\":\"fixture\"}],\"v\":3}\n",
101101
string(data),
102102
)
103-
assert.Equal(t, "d84a3987c40f800f23ea943ebd50c0910c64444e6ff4b64b6f8a68782f7a32d6", hashHex(data))
103+
assert.Equal(t, "855d1a7ed086095582cc94136837bcb93d74f4ad0cf446b0a77177d1b31aba23", hashHex(data))
104104
}
105105

106106
func TestDecodeManifestRejectsUnsupportedOlderVersion(t *testing.T) {
@@ -122,6 +122,37 @@ func TestDecodeManifestRejectsUnsupportedOlderVersion(t *testing.T) {
122122
assert.Contains(t, err.Error(), "manifest has unsupported artifact version 1")
123123
}
124124

125+
func TestDecodeManifestAcceptsPriorVersionWithoutSessionKind(t *testing.T) {
126+
data := []byte(`{
127+
"v": 2,
128+
"origin": "laptop-a1b2c3",
129+
"native_session_id": "sess-1",
130+
"segments": [],
131+
"session": {
132+
"id": "sess-1",
133+
"machine": "laptop-a1b2c3",
134+
"agent": "claude",
135+
"created_at": "2026-06-14T01:02:03Z"
136+
}
137+
}`)
138+
139+
decoded, err := decodeManifestWithLimits(data, productionArtifactLimits())
140+
require.NoError(t, err)
141+
assert.Equal(t, 2, decoded.Version)
142+
assert.Empty(t, decoded.Session.SessionKind,
143+
"v2 manifests predate session_kind and must decode with it empty")
144+
}
145+
146+
func TestDecodeSegmentAcceptsPriorVersionWithoutPromptSource(t *testing.T) {
147+
data := []byte("{\"content\":\"hello\",\"ordinal\":0,\"role\":\"user\",\"v\":1}\n")
148+
149+
msgs, err := decodeSegmentWithLimits(data, productionArtifactLimits())
150+
require.NoError(t, err)
151+
require.Len(t, msgs, 1)
152+
assert.Empty(t, msgs[0].PromptSource,
153+
"v1 segments predate prompt_source and must decode with it empty")
154+
}
155+
125156
func TestCanonicalMessageSegmentGolden(t *testing.T) {
126157
msgs := []db.Message{
127158
{
@@ -177,13 +208,13 @@ func TestCanonicalMessageSegmentGolden(t *testing.T) {
177208
require.NoError(t, err)
178209

179210
assert.Equal(t,
180-
"{\"claude_message_id\":\"msg-1\",\"claude_request_id\":\"req-1\",\"content\":\"world\",\"content_length\":5,\"has_output_tokens\":true,\"has_tool_use\":true,\"model\":\"claude-test\",\"ordinal\":2,\"output_tokens\":2,\"role\":\"assistant\",\"source_parent_uuid\":\"uuid-parent\",\"source_subtype\":\"assistant\",\"source_type\":\"jsonl\",\"source_uuid\":\"uuid-msg-1\",\"timestamp\":\"2026-06-14T01:02:05Z\",\"token_usage\":{\"input\":1,\"output\":2},\"tool_calls\":[{\"call_index\":0,\"category\":\"file\",\"file_path\":\"README.md\",\"input_json\":\"{\\\"file_path\\\":\\\"README.md\\\"}\",\"result_content\":\"file content\",\"result_content_length\":12,\"result_events\":[{\"agent_id\":\"agent-1\",\"content\":\"done\",\"content_length\":4,\"event_index\":0,\"source\":\"tool_result\",\"status\":\"success\",\"subagent_session_id\":\"child-1\",\"timestamp\":\"2026-06-14T01:02:06Z\",\"tool_use_id\":\"tool-1\"}],\"subagent_session_id\":\"child-1\",\"tool_name\":\"Read\",\"tool_use_id\":\"tool-1\"}],\"v\":1}\n",
211+
"{\"claude_message_id\":\"msg-1\",\"claude_request_id\":\"req-1\",\"content\":\"world\",\"content_length\":5,\"has_output_tokens\":true,\"has_tool_use\":true,\"model\":\"claude-test\",\"ordinal\":2,\"output_tokens\":2,\"role\":\"assistant\",\"source_parent_uuid\":\"uuid-parent\",\"source_subtype\":\"assistant\",\"source_type\":\"jsonl\",\"source_uuid\":\"uuid-msg-1\",\"timestamp\":\"2026-06-14T01:02:05Z\",\"token_usage\":{\"input\":1,\"output\":2},\"tool_calls\":[{\"call_index\":0,\"category\":\"file\",\"file_path\":\"README.md\",\"input_json\":\"{\\\"file_path\\\":\\\"README.md\\\"}\",\"result_content\":\"file content\",\"result_content_length\":12,\"result_events\":[{\"agent_id\":\"agent-1\",\"content\":\"done\",\"content_length\":4,\"event_index\":0,\"source\":\"tool_result\",\"status\":\"success\",\"subagent_session_id\":\"child-1\",\"timestamp\":\"2026-06-14T01:02:06Z\",\"tool_use_id\":\"tool-1\"}],\"subagent_session_id\":\"child-1\",\"tool_name\":\"Read\",\"tool_use_id\":\"tool-1\"}],\"v\":2}\n",
181212
string(data),
182213
)
183214
assert.NotContains(t, string(data), `"id"`)
184215
assert.NotContains(t, string(data), `"session_id"`)
185216
assert.NotContains(t, string(data), `"message_id"`)
186-
assert.Equal(t, "f46c1edbc77dab4eb15f43bcb3ce196243c784445b07e9135c223b5d58c6dea5", hashHex(data))
217+
assert.Equal(t, "e45219604ab58bb9685ac3dfb0f5cc5e865705e29dc8a985ccebe1b748a6d396", hashHex(data))
187218
}
188219

189220
func TestEncodeSegmentPreservesPromptSource(t *testing.T) {

internal/artifact/import_checkpoint_test.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -72,34 +72,34 @@ func TestReadVerifiedImportArtifactByteBoundaries(t *testing.T) {
7272
func TestFutureArtifactVersionErrorsIdentifyDependencyKind(t *testing.T) {
7373
t.Run("manifest", func(t *testing.T) {
7474
_, err := decodeManifestWithLimits(
75-
[]byte(`{"origin":"contract-a1b2c3","v":3}`),
75+
[]byte(`{"origin":"contract-a1b2c3","v":4}`),
7676
productionArtifactLimits(),
7777
)
7878
require.ErrorIs(t, err, errFutureArtifactVersion)
7979
var future *futureArtifactVersionError
8080
require.ErrorAs(t, err, &future)
8181
assert.Equal(t, Kind(KindManifests), future.Kind)
82-
assert.Equal(t, 3, future.Version)
82+
assert.Equal(t, 4, future.Version)
8383
})
8484

8585
t.Run("segment", func(t *testing.T) {
8686
_, err := decodeSegmentWithLimits(
87-
[]byte("{\"content\":\"future\",\"ordinal\":0,\"role\":\"user\",\"v\":2}\n"),
87+
[]byte("{\"content\":\"future\",\"ordinal\":0,\"role\":\"user\",\"v\":3}\n"),
8888
productionArtifactLimits(),
8989
)
9090
require.ErrorIs(t, err, errFutureArtifactVersion)
9191
var future *futureArtifactVersionError
9292
require.ErrorAs(t, err, &future)
9393
assert.Equal(t, Kind(KindSegments), future.Kind)
94-
assert.Equal(t, 2, future.Version)
94+
assert.Equal(t, 3, future.Version)
9595
})
9696
}
9797

9898
func TestFutureSegmentVersionPrecedesCurrentRecordLimit(t *testing.T) {
9999
var body strings.Builder
100100
for range productionArtifactLimits().segmentMessages + 1 {
101101
body.WriteString(
102-
"{\"content\":\"future\",\"ordinal\":0,\"role\":\"user\",\"v\":2}\n",
102+
"{\"content\":\"future\",\"ordinal\":0,\"role\":\"user\",\"v\":3}\n",
103103
)
104104
}
105105

@@ -110,7 +110,7 @@ func TestFutureSegmentVersionPrecedesCurrentRecordLimit(t *testing.T) {
110110
var future *futureArtifactVersionError
111111
require.ErrorAs(t, err, &future)
112112
assert.Equal(t, Kind(KindSegments), future.Kind)
113-
assert.Equal(t, 2, future.Version)
113+
assert.Equal(t, 3, future.Version)
114114
}
115115

116116
func TestCurrentSegmentRecordLimitPrecedesLaterRecordDecode(t *testing.T) {

internal/artifact/import_session.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ func loadImportedSession(
159159
func validateImportedManifest(
160160
m manifest, origin, gid string, limits artifactLimits,
161161
) error {
162-
if m.Version != manifestFormatVersion {
162+
if m.Version < manifestMinDecodeVersion || m.Version > manifestFormatVersion {
163163
return errors.New("manifest version is unsupported")
164164
}
165165
if m.Origin != origin {

internal/artifact/import_session_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ func TestLoadImportedSessionDefersOversizedFutureSegment(t *testing.T) {
143143
var segment strings.Builder
144144
for range productionArtifactLimits().segmentMessages + 1 {
145145
segment.WriteString(
146-
"{\"content\":\"future\",\"ordinal\":0,\"role\":\"user\",\"v\":2}\n",
146+
"{\"content\":\"future\",\"ordinal\":0,\"role\":\"user\",\"v\":3}\n",
147147
)
148148
}
149149
segmentHash := createHashedImportArtifact(
@@ -165,7 +165,7 @@ func TestLoadImportedSessionDefersOversizedFutureSegment(t *testing.T) {
165165
var future *futureArtifactVersionError
166166
require.ErrorAs(t, err, &future)
167167
assert.Equal(t, Kind(KindSegments), future.Kind)
168-
assert.Equal(t, 2, future.Version)
168+
assert.Equal(t, 3, future.Version)
169169
}
170170

171171
func TestLoadImportedSessionDefersMissingAndFutureDependencies(t *testing.T) {
@@ -191,7 +191,7 @@ func TestLoadImportedSessionDefersMissingAndFutureDependencies(t *testing.T) {
191191
{
192192
name: "future manifest",
193193
prepare: func(t *testing.T, store ArtifactStore) string {
194-
body := []byte(`{"origin":"contract-a1b2c3","v":3}`)
194+
body := []byte(`{"origin":"contract-a1b2c3","v":4}`)
195195
return createHashedImportArtifact(
196196
t, store, KindManifests, ".json", body,
197197
)
@@ -202,7 +202,7 @@ func TestLoadImportedSessionDefersMissingAndFutureDependencies(t *testing.T) {
202202
name: "future segment",
203203
prepare: func(t *testing.T, store ArtifactStore) string {
204204
segment := []byte(
205-
"{\"content\":\"future\",\"ordinal\":0,\"role\":\"user\",\"v\":2}\n",
205+
"{\"content\":\"future\",\"ordinal\":0,\"role\":\"user\",\"v\":3}\n",
206206
)
207207
segmentHash := createHashedImportArtifact(
208208
t, store, KindSegments, ".ndjson", segment,

internal/artifact/import_test.go

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -217,21 +217,22 @@ func TestStoreImportCoordinatorTracksIndependentFutureRequirements(t *testing.T)
217217
prepare: func(t *testing.T, store ArtifactStore) string {
218218
return createHashedImportArtifact(
219219
t, store, KindManifests, ".json",
220-
[]byte(`{"origin":"contract-a1b2c3","v":3}`),
220+
[]byte(`{"origin":"contract-a1b2c3","v":4}`),
221221
)
222222
},
223-
wantManifest: 3, wantSegment: messageSegmentFormatVersion,
223+
wantManifest: manifestFormatVersion + 1,
224+
wantSegment: messageSegmentFormatVersion,
224225
understood: db.ArtifactImportVersions{
225226
Checkpoint: checkpointFormatVersion,
226-
Manifest: 3,
227+
Manifest: manifestFormatVersion + 1,
227228
Segment: messageSegmentFormatVersion,
228229
},
229230
},
230231
{
231232
name: "future segment",
232233
prepare: func(t *testing.T, store ArtifactStore) string {
233234
segment := []byte(
234-
"{\"content\":\"future\",\"ordinal\":0,\"role\":\"user\",\"v\":2}\n",
235+
"{\"content\":\"future\",\"ordinal\":0,\"role\":\"user\",\"v\":3}\n",
235236
)
236237
segmentHash := createHashedImportArtifact(
237238
t, store, KindSegments, ".ndjson", segment,
@@ -240,11 +241,12 @@ func TestStoreImportCoordinatorTracksIndependentFutureRequirements(t *testing.T)
240241
m.Segments = []string{segmentHash}
241242
return createImportTestManifest(t, store, m, false)
242243
},
243-
wantManifest: manifestFormatVersion, wantSegment: 2,
244+
wantManifest: manifestFormatVersion,
245+
wantSegment: messageSegmentFormatVersion + 1,
244246
understood: db.ArtifactImportVersions{
245247
Checkpoint: checkpointFormatVersion,
246248
Manifest: manifestFormatVersion,
247-
Segment: 2,
249+
Segment: messageSegmentFormatVersion + 1,
248250
},
249251
},
250252
}
@@ -343,7 +345,7 @@ func TestStoreImportCoordinatorFinishesSupportedSessionsBeforeFutureGate(
343345
sessionMap := map[string]string{
344346
contractOrigin + "~000-future": createHashedImportArtifact(
345347
t, store, KindManifests, ".json",
346-
[]byte(`{"origin":"contract-a1b2c3","v":3}`),
348+
[]byte(`{"origin":"contract-a1b2c3","v":4}`),
347349
),
348350
}
349351
const supportedSessions = artifactImportDrainLimit + 1
@@ -405,15 +407,15 @@ func TestStoreImportCoordinatorFinishesSupportedSessionsBeforeFutureGate(
405407
t.Context(),
406408
db.ArtifactImportVersions{
407409
Checkpoint: checkpointFormatVersion,
408-
Manifest: 3,
410+
Manifest: manifestFormatVersion + 1,
409411
Segment: messageSegmentFormatVersion,
410412
},
411413
attempt,
412414
10,
413415
)
414416
require.NoError(t, err)
415417
require.Len(t, pending, 1)
416-
assert.Equal(t, 3, pending[0].RequiredManifestVersion)
418+
assert.Equal(t, manifestFormatVersion+1, pending[0].RequiredManifestVersion)
417419
}
418420

419421
func TestStoreImportCoordinatorQuarantinesInvalidCheckpointAndContinues(

internal/artifact/wire.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,17 @@ var errIncompleteArtifact = errors.New("incomplete artifact")
3838
const (
3939
checkpointFormatVersion = 1
4040
// Manifest v2 replaces usage_events[].cost_usd floats with exact
41-
// integer-microdollar cost objects.
42-
manifestFormatVersion = 2
43-
messageSegmentFormatVersion = 1
44-
metadataEventFormatVersion = 1
41+
// integer-microdollar cost objects. Manifest v3 adds the optional
42+
// session_kind provenance field; v2 manifests still decode, with the
43+
// field defaulting to empty.
44+
manifestFormatVersion = 3
45+
manifestMinDecodeVersion = 2
46+
// Segment v2 adds the optional prompt_source provenance field on
47+
// message records; v1 segments still decode, with the field defaulting
48+
// to empty.
49+
messageSegmentFormatVersion = 2
50+
messageSegmentMinDecodeVersion = 1
51+
metadataEventFormatVersion = 1
4552
)
4653

4754
// metadataEventExtension is the file extension for metadata event artifacts.

internal/artifact/wire_decode.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ func decodeManifestWithLimits(data []byte, limits artifactLimits) (manifest, err
2727
Kind: KindManifests, Version: envelope.Version,
2828
}
2929
}
30-
if envelope.Version != manifestFormatVersion {
30+
if envelope.Version < manifestMinDecodeVersion {
3131
return manifest{}, fmt.Errorf(
3232
"manifest has unsupported artifact version %d", envelope.Version,
3333
)
@@ -211,7 +211,7 @@ func preflightSegmentData(data []byte, limits artifactLimits) (segmentPreflight,
211211
Kind: KindSegments, Version: version,
212212
}
213213
}
214-
if version != messageSegmentFormatVersion {
214+
if version < messageSegmentMinDecodeVersion {
215215
return segmentPreflight{}, fmt.Errorf(
216216
"message segment has unsupported artifact version %d",
217217
version,

0 commit comments

Comments
 (0)