diff --git a/.trajectories/completed/2026-10/traj_0qdrmrp6ijnu.json b/.trajectories/completed/2026-10/traj_0qdrmrp6ijnu.json new file mode 100644 index 00000000..690a91ef --- /dev/null +++ b/.trajectories/completed/2026-10/traj_0qdrmrp6ijnu.json @@ -0,0 +1,53 @@ +{ + "id": "traj_0qdrmrp6ijnu", + "version": 1, + "task": { + "title": "Repair upstream event cursor checkpoint validation" + }, + "status": "completed", + "startedAt": "2026-10-04T08:43:18.694Z", + "completedAt": "2026-10-04T08:43:23.917Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-10-04T08:43:18.916Z" + } + ], + "chapters": [ + { + "id": "chap_leubiq4kl800", + "title": "Work", + "agentName": "default", + "startedAt": "2026-10-04T08:43:18.916Z", + "endedAt": "2026-10-04T08:43:23.917Z", + "events": [ + { + "ts": 1791103398917, + "type": "decision", + "content": "Validate event cursors without trimming: Validate event cursors without trimming", + "raw": { + "question": "Validate event cursors without trimming", + "chosen": "Validate event cursors without trimming", + "alternatives": [], + "reasoning": "The cursor contract requires whitespace and controls to be rejected; trimming before the bounded regex silently accepted padded malformed cursors." + }, + "significance": "high" + } + ] + } + ], + "retrospective": { + "summary": "Tightened checkpoint event-cursor validation to reject padded whitespace, added regression cases, and verified all Go packages plus contract checks.", + "approach": "Standard approach", + "confidence": 0.9 + }, + "commits": [], + "filesChanged": [], + "projectId": "AgentWorkforce/relayfile", + "tags": [], + "_trace": { + "startRef": "f2777d92d8cc70de11b23460f2eb0eb152ad0c06", + "endRef": "f2777d92d8cc70de11b23460f2eb0eb152ad0c06" + } +} \ No newline at end of file diff --git a/.trajectories/completed/2026-10/traj_0qdrmrp6ijnu.md b/.trajectories/completed/2026-10/traj_0qdrmrp6ijnu.md new file mode 100644 index 00000000..90a07220 --- /dev/null +++ b/.trajectories/completed/2026-10/traj_0qdrmrp6ijnu.md @@ -0,0 +1,31 @@ +# Trajectory: Repair upstream event cursor checkpoint validation + +> **Status:** ✅ Completed +> **Confidence:** 90% +> **Started:** October 4, 2026 at 08:43 AM +> **Completed:** October 4, 2026 at 08:43 AM + +--- + +## Summary + +Tightened checkpoint event-cursor validation to reject padded whitespace, added regression cases, and verified all Go packages plus contract checks. + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Validate event cursors without trimming +- **Chose:** Validate event cursors without trimming +- **Reasoning:** The cursor contract requires whitespace and controls to be rejected; trimming before the bounded regex silently accepted padded malformed cursors. + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Validate event cursors without trimming: Validate event cursors without trimming diff --git a/.trajectories/completed/2026-10/traj_36h89bsoig5x.json b/.trajectories/completed/2026-10/traj_36h89bsoig5x.json new file mode 100644 index 00000000..855ecab1 --- /dev/null +++ b/.trajectories/completed/2026-10/traj_36h89bsoig5x.json @@ -0,0 +1,53 @@ +{ + "id": "traj_36h89bsoig5x", + "version": 1, + "task": { + "title": "Accept upstream event cursors in mountsync checkpoints" + }, + "status": "completed", + "startedAt": "2026-10-04T08:46:27.242Z", + "completedAt": "2026-10-04T08:50:31.407Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-10-04T08:50:27.813Z" + } + ], + "chapters": [ + { + "id": "chap_ar50dfcveu54", + "title": "Work", + "agentName": "default", + "startedAt": "2026-10-04T08:50:27.813Z", + "endedAt": "2026-10-04T08:50:31.407Z", + "events": [ + { + "ts": 1791103827813, + "type": "decision", + "content": "Treat SDK ErrorEvent failure as toolchain setup mismatch: Treat SDK ErrorEvent failure as toolchain setup mismatch", + "raw": { + "question": "Treat SDK ErrorEvent failure as toolchain setup mismatch", + "chosen": "Treat SDK ErrorEvent failure as toolchain setup mismatch", + "alternatives": [], + "reasoning": "" + }, + "significance": "high" + } + ] + } + ], + "retrospective": { + "summary": "Verified upstream checkpoint cursor support and repaired the local check environment to match CI Node 22; full repository checks pass.", + "approach": "Standard approach", + "confidence": 0.98 + }, + "commits": [], + "filesChanged": [], + "projectId": "AgentWorkforce/relayfile", + "tags": [], + "_trace": { + "startRef": "767ae7c90dee98904b36099f5d398bee5a1a4816", + "endRef": "767ae7c90dee98904b36099f5d398bee5a1a4816" + } +} \ No newline at end of file diff --git a/.trajectories/completed/2026-10/traj_36h89bsoig5x.md b/.trajectories/completed/2026-10/traj_36h89bsoig5x.md new file mode 100644 index 00000000..5836cc97 --- /dev/null +++ b/.trajectories/completed/2026-10/traj_36h89bsoig5x.md @@ -0,0 +1,23 @@ +# Trajectory: Accept upstream event cursors in mountsync checkpoints + +> **Status:** ✅ Completed +> **Confidence:** 98% +> **Started:** October 4, 2026 at 08:46 AM +> **Completed:** October 4, 2026 at 08:50 AM + +--- + +## Summary + +Verified upstream checkpoint cursor support and repaired the local check environment to match CI Node 22; full repository checks pass. + +**Approach:** Standard approach + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Treat SDK ErrorEvent failure as toolchain setup mismatch: Treat SDK ErrorEvent failure as toolchain setup mismatch diff --git a/.trajectories/index.json b/.trajectories/index.json index 7228ae75..a13f683d 100644 --- a/.trajectories/index.json +++ b/.trajectories/index.json @@ -463,6 +463,20 @@ "startedAt": "2026-10-04T15:50:26.132Z", "completedAt": "2026-10-04T16:07:54.908Z", "path": ".trajectories/completed/2026-10/traj_t8ngcsdcvvc7.json" + }, + "traj_0qdrmrp6ijnu": { + "title": "Repair upstream event cursor checkpoint validation", + "status": "completed", + "startedAt": "2026-10-04T08:43:18.694Z", + "completedAt": "2026-10-04T08:43:23.917Z", + "path": ".trajectories/completed/2026-10/traj_0qdrmrp6ijnu.json" + }, + "traj_36h89bsoig5x": { + "title": "Accept upstream event cursors in mountsync checkpoints", + "status": "completed", + "startedAt": "2026-10-04T08:46:27.242Z", + "completedAt": "2026-10-04T08:50:31.407Z", + "path": ".trajectories/completed/2026-10/traj_36h89bsoig5x.json" } } } diff --git a/cmd/relayfile-cli/checkpoint_lifecycle.go b/cmd/relayfile-cli/checkpoint_lifecycle.go index 96690d14..113c5c80 100644 --- a/cmd/relayfile-cli/checkpoint_lifecycle.go +++ b/cmd/relayfile-cli/checkpoint_lifecycle.go @@ -39,7 +39,7 @@ var ( checkpointLifecycleIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$`) checkpointDigestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) checkpointRevisionPattern = regexp.MustCompile(`^(?:0|rev_[0-9]+)$`) - checkpointCursorPattern = regexp.MustCompile(`^(?:0|evt_[0-9]+)$`) + checkpointCursorPattern = regexp.MustCompile(`^(?:0|evt_[0-9]+|upstream:v1:[A-Za-z0-9._:\-]{1,501})$`) ) type checkpointCLIError struct { diff --git a/cmd/relayfile-cli/checkpoint_lifecycle_test.go b/cmd/relayfile-cli/checkpoint_lifecycle_test.go index 7196dc1b..540da9ac 100644 --- a/cmd/relayfile-cli/checkpoint_lifecycle_test.go +++ b/cmd/relayfile-cli/checkpoint_lifecycle_test.go @@ -24,6 +24,21 @@ type fakeCheckpointLease struct{ released bool } func (l *fakeCheckpointLease) Release() error { l.released = true; return nil } +func TestCheckpointCursorPattern(t *testing.T) { + valid := []string{"0", "evt_42", "upstream:v1:notion:page_123", "upstream:v1:notion:page_123:2", "upstream:v1:" + strings.Repeat("a", 501)} + invalid := []string{"", " ", "evt_", "upstream:v1:", "upstream:v1:notion page_123", " upstream:v1:notion:page_123 ", "upstream:v1:notion\npage_123", "upstream:v1:slack:bad\\id", "upstream:v1:slack:bad\x7fid", "upstream:v1:" + strings.Repeat("a", 502)} + for _, cursor := range valid { + if !checkpointCursorPattern.MatchString(cursor) { + t.Errorf("valid cursor rejected: %q", cursor) + } + } + for _, cursor := range invalid { + if checkpointCursorPattern.MatchString(cursor) { + t.Errorf("invalid cursor accepted: %q", cursor) + } + } +} + func installCheckpointLifecycleSeams(t *testing.T, active activeCheckpointMount, receipt mountsync.CheckpointSeal) (*fakeCheckpointLease, *int, *int) { t.Helper() originalResolve := checkpointResolveActive diff --git a/internal/mountsync/realtime_collaboration_test.go b/internal/mountsync/realtime_collaboration_test.go index 1e930b5e..e2ea4ecb 100644 --- a/internal/mountsync/realtime_collaboration_test.go +++ b/internal/mountsync/realtime_collaboration_test.go @@ -1150,6 +1150,13 @@ func TestAdvanceEventCursorNeverRegressesRelayfileOrdinal(t *testing.T) { if got := advanceEventCursor("evt_42", "evt_43"); got != "evt_43" { t.Fatalf("cursor did not advance: %q", got) } + upstream := "upstream:v1:linear:issue_123:2" + if got := advanceEventCursor("evt_43", upstream); got != upstream { + t.Fatalf("mixed feed cursor = %q, want %q", got, upstream) + } + if !checkpointEventCursorPattern.MatchString(upstream) { + t.Fatalf("latest mixed feed cursor is not checkpoint-safe: %q", upstream) + } } // TestWebSocketURLScopesToMountRoot guards the path-scoped dial. A mount whose diff --git a/internal/mountsync/syncer.go b/internal/mountsync/syncer.go index 68683508..698a2a72 100644 --- a/internal/mountsync/syncer.go +++ b/internal/mountsync/syncer.go @@ -51,7 +51,7 @@ var ( ErrCheckpointNonConverged = errors.New("local and durable Relayfile state did not converge") checkpointSessionPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$`) checkpointRevisionPattern = regexp.MustCompile(`^(?:0|rev_[0-9]+)$`) - checkpointEventCursorPattern = regexp.MustCompile(`^(?:0|evt_[0-9]+)$`) + checkpointEventCursorPattern = regexp.MustCompile(`^(?:0|evt_[0-9]+|upstream:v1:[A-Za-z0-9._:\-]{1,501})$`) mountCorrelationIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$`) ) @@ -3244,7 +3244,7 @@ func (s *Syncer) HandbackCheckpoint(ctx context.Context, consumed CheckpointSeal // requiring a second (impossible) transition. if prepared.Status == "released" { if prepared.SealID != consumed.SealID || prepared.WorkspaceID != s.workspace || prepared.Root != "/" || prepared.SessionID != consumed.SessionID || prepared.Generation != consumed.Generation || - prepared.Digest != secondDigest || !checkpointEventCursorPattern.MatchString(strings.TrimSpace(prepared.EventCursor)) || !checkpointRevisionPattern.MatchString(strings.TrimSpace(prepared.WorkspaceRevision)) || prepared.ConsumedAt != consumed.ConsumedAt || strings.TrimSpace(prepared.PreparedAt) == "" || strings.TrimSpace(prepared.ReleasedAt) == "" || prepared.SourceResumedAt != "" { + prepared.Digest != secondDigest || !checkpointEventCursorPattern.MatchString(prepared.EventCursor) || !checkpointRevisionPattern.MatchString(strings.TrimSpace(prepared.WorkspaceRevision)) || prepared.ConsumedAt != consumed.ConsumedAt || strings.TrimSpace(prepared.PreparedAt) == "" || strings.TrimSpace(prepared.ReleasedAt) == "" || prepared.SourceResumedAt != "" { return CheckpointSealOwnership{}, health, fmt.Errorf("%w: server returned a changed released handback replay", ErrCheckpointNonConverged) } for _, raw := range []string{prepared.PreparedAt, prepared.ReleasedAt} { @@ -3263,7 +3263,7 @@ func (s *Syncer) HandbackCheckpoint(ctx context.Context, consumed CheckpointSeal return prepared, health, nil } if prepared.Status != "prepared" || prepared.SealID != consumed.SealID || prepared.WorkspaceID != s.workspace || prepared.Root != "/" || prepared.SessionID != consumed.SessionID || prepared.Generation != consumed.Generation || - prepared.Digest != secondDigest || !checkpointEventCursorPattern.MatchString(strings.TrimSpace(prepared.EventCursor)) || !checkpointRevisionPattern.MatchString(strings.TrimSpace(prepared.WorkspaceRevision)) || prepared.ConsumedAt != consumed.ConsumedAt || strings.TrimSpace(prepared.PreparedAt) == "" || prepared.ReleasedAt != "" || prepared.SourceResumedAt != "" { + prepared.Digest != secondDigest || !checkpointEventCursorPattern.MatchString(prepared.EventCursor) || !checkpointRevisionPattern.MatchString(strings.TrimSpace(prepared.WorkspaceRevision)) || prepared.ConsumedAt != consumed.ConsumedAt || strings.TrimSpace(prepared.PreparedAt) == "" || prepared.ReleasedAt != "" || prepared.SourceResumedAt != "" { return CheckpointSealOwnership{}, health, fmt.Errorf("%w: server returned a changed handback preparation", ErrCheckpointNonConverged) } if _, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(prepared.PreparedAt)); err != nil { @@ -3434,7 +3434,7 @@ func (s *Syncer) VerifyCheckpointOwnership(ctx context.Context, proof Checkpoint } if proof.Status != "source-resumed" || strings.TrimSpace(proof.SealID) == "" || proof.WorkspaceID != s.workspace || proof.Root != "/" || s.remoteRoot != "/" || !checkpointSessionPattern.MatchString(strings.TrimSpace(proof.SessionID)) || proof.Generation == 0 || !validCheckpointDigestString(proof.Digest) || - !checkpointRevisionPattern.MatchString(strings.TrimSpace(proof.WorkspaceRevision)) || !checkpointEventCursorPattern.MatchString(strings.TrimSpace(proof.EventCursor)) || + !checkpointRevisionPattern.MatchString(strings.TrimSpace(proof.WorkspaceRevision)) || !checkpointEventCursorPattern.MatchString(proof.EventCursor) || strings.TrimSpace(proof.ReleasedAt) == "" || strings.TrimSpace(proof.SourceResumedAt) == "" { return verification, fmt.Errorf("%w: malformed or mismatched source-resume proof", ErrCheckpointNonConverged) } @@ -3497,7 +3497,7 @@ func (s *Syncer) VerifyCheckpointOwnership(ctx context.Context, proof Checkpoint func validateConsumedCheckpointReceipt(seal CheckpointSeal) error { if strings.TrimSpace(seal.SealID) == "" || strings.TrimSpace(seal.SealToken) != "" || strings.TrimSpace(seal.WorkspaceID) == "" || seal.Root != "/" || - !checkpointSessionPattern.MatchString(strings.TrimSpace(seal.SessionID)) || seal.Generation == 0 || !validCheckpointDigestString(seal.Digest) || !checkpointRevisionPattern.MatchString(strings.TrimSpace(seal.WorkspaceRevision)) || !checkpointEventCursorPattern.MatchString(strings.TrimSpace(seal.EventCursor)) || strings.TrimSpace(seal.ConsumedAt) == "" { + !checkpointSessionPattern.MatchString(strings.TrimSpace(seal.SessionID)) || seal.Generation == 0 || !validCheckpointDigestString(seal.Digest) || !checkpointRevisionPattern.MatchString(strings.TrimSpace(seal.WorkspaceRevision)) || !checkpointEventCursorPattern.MatchString(seal.EventCursor) || strings.TrimSpace(seal.ConsumedAt) == "" { return errors.New("receipt must be an exact consumed full-root seal without sealToken") } for _, raw := range []string{seal.IssuedAt, seal.ExpiresAt, seal.ConsumedAt} { diff --git a/internal/mountsync/syncer_test.go b/internal/mountsync/syncer_test.go index 41d098aa..d6895c76 100644 --- a/internal/mountsync/syncer_test.go +++ b/internal/mountsync/syncer_test.go @@ -10731,6 +10731,25 @@ func TestCheckpointOwnershipEndToEndHandbackPreservesDestinationTurn(t *testing. } func TestVerifyCheckpointRequiresLocalExactnessAndServerReattestation(t *testing.T) { + t.Run("upstream cursor is opaque and server-attested", func(t *testing.T) { + client := &fakeClient{} + syncer, _ := newManagedCheckpointSyncer(t, client) + receipt := consumedCheckpointReceiptForTest(t, syncer) + receipt.EventCursor = "upstream:v1:linear:issue_123:2" + client.checkpointVerifySeal = receipt + syncer.state.EventsCursor = receipt.EventCursor + if err := syncer.saveStateWithoutLocalScan(); err != nil { + t.Fatal(err) + } + verification, err := syncer.VerifyCheckpoint(context.Background(), receipt) + if err != nil { + t.Fatalf("verify upstream cursor: %v", err) + } + if verification.Observed.EventCursor != receipt.EventCursor || client.checkpointVerifyCalls != 1 { + t.Fatalf("verification=%+v calls=%d", verification, client.checkpointVerifyCalls) + } + }) + t.Run("empty local cursor normalizes to canonical zero", func(t *testing.T) { client := &fakeClient{} syncer, _ := newManagedCheckpointSyncer(t, client) @@ -10868,6 +10887,7 @@ func TestVerifyCheckpointRequiresLocalExactnessAndServerReattestation(t *testing for name, mutate := range map[string]func(*CheckpointSeal){ "bare revision": func(receipt *CheckpointSeal) { receipt.WorkspaceRevision = "12" }, "bare cursor": func(receipt *CheckpointSeal) { receipt.EventCursor = "12" }, + "padded cursor": func(receipt *CheckpointSeal) { receipt.EventCursor = " evt_2 " }, } { t.Run(name, func(t *testing.T) { client := &fakeClient{} @@ -10885,6 +10905,75 @@ func TestVerifyCheckpointRequiresLocalExactnessAndServerReattestation(t *testing }) } +func TestUpstreamCheckpointHandbackAndResume(t *testing.T) { + client := &fakeClient{} + syncer, _ := newManagedCheckpointSyncer(t, client) + receipt := consumedCheckpointReceiptForTest(t, syncer) + cursor := "upstream:v1:" + strings.Repeat("p", 64) + ":" + strings.Repeat("i", 384) + ":2" + receipt.EventCursor = cursor + syncer.state.EventsCursor = cursor + if err := syncer.saveStateWithoutLocalScan(); err != nil { + t.Fatal(err) + } + preparedAt := time.Now().UTC().Format(time.RFC3339Nano) + client.checkpointHandbackFunc = func(_ context.Context, workspaceID string, request CheckpointSealHandbackRequest) (CheckpointSealOwnership, error) { + now := preparedAt + proof := CheckpointSealOwnership{SealID: request.SealID, WorkspaceID: workspaceID, Root: request.Root, + SessionID: request.SessionID, Generation: request.Generation, Digest: request.ExpectedDigest, + WorkspaceRevision: "rev_1", EventCursor: cursor, ConsumedAt: request.ConsumedAt, PreparedAt: now, Status: "prepared"} + if request.Phase == CheckpointHandbackPhaseCommit { + proof.Status = "released" + proof.ReleasedAt = now + } + return proof, nil + } + proof, _, err := syncer.HandbackCheckpoint(context.Background(), receipt, "cutover-upstream", "handback-upstream") + if err != nil { + t.Fatalf("upstream prepare/commit: %v", err) + } + if client.checkpointHandbackCalls != 2 || proof.EventCursor != cursor { + t.Fatalf("proof=%+v calls=%d", proof, client.checkpointHandbackCalls) + } + proof.Status = "source-resumed" + proof.SourceResumedAt = time.Now().UTC().Format(time.RFC3339Nano) + verification, err := syncer.VerifyCheckpointOwnership(context.Background(), proof) + if err != nil || verification.Observed.EventCursor != cursor { + t.Fatalf("upstream resume: %+v, %v", verification, err) + } +} + +func TestCheckpointEventCursorPattern(t *testing.T) { + valid := []string{ + "0", + "evt_42", + "upstream:v1:linear:issue_123", + "upstream:v1:linear:issue_123:2", + "upstream:v1:" + strings.Repeat("a", 501), + } + invalid := []string{ + "", + " ", + "evt_", + "12", + "upstream:v1:", + "upstream:v1:linear issue_123", + " upstream:v1:linear:issue_123 ", + "upstream:v1:linear/issue_123", + "upstream:v1:linear\nissue_123", + "upstream:v1:slack:bad\\id", "upstream:v1:slack:bad\x7fid", "upstream:v1:" + strings.Repeat("a", 502), + } + for _, cursor := range valid { + if !checkpointEventCursorPattern.MatchString(cursor) { + t.Errorf("valid cursor rejected: %q", cursor) + } + } + for _, cursor := range invalid { + if checkpointEventCursorPattern.MatchString(cursor) { + t.Errorf("invalid cursor accepted: %q", cursor) + } + } +} + func consumedCheckpointReceiptForTest(t *testing.T, syncer *Syncer) CheckpointSeal { t.Helper() digest, err := syncer.checkpointLocalDigestLocked() diff --git a/internal/relayfile/checkpoint_seal.go b/internal/relayfile/checkpoint_seal.go index 95058969..91a0863c 100644 --- a/internal/relayfile/checkpoint_seal.go +++ b/internal/relayfile/checkpoint_seal.go @@ -37,7 +37,7 @@ var ( ErrCheckpointAdminConflict = errors.New("checkpoint administrative reconciliation identity conflicts with durable state") checkpointSessionPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$`) checkpointRevisionPattern = regexp.MustCompile(`^(?:0|rev_[0-9]+)$`) - checkpointEventCursorPattern = regexp.MustCompile(`^(?:0|evt_[0-9]+)$`) + checkpointEventCursorPattern = regexp.MustCompile(`^(?:0|evt_[0-9]+|upstream:v1:[A-Za-z0-9._:\-]{1,501})$`) ) const ( @@ -528,7 +528,7 @@ func (s *Store) VerifyConsumedCheckpointSeal(workspaceID string, req CheckpointS if err != nil || workspaceID == "" || strings.TrimSpace(req.SealID) == "" || !checkpointSessionPattern.MatchString(strings.TrimSpace(req.SessionID)) || req.Generation == 0 || consumerPrincipal == "" { return CheckpointSeal{}, ErrInvalidInput } - if !validCheckpointDigest(req.Digest) || !checkpointRevisionPattern.MatchString(strings.TrimSpace(req.WorkspaceRevision)) || !checkpointEventCursorPattern.MatchString(strings.TrimSpace(req.EventCursor)) || strings.TrimSpace(req.ConsumedAt) == "" { + if !validCheckpointDigest(req.Digest) || !checkpointRevisionPattern.MatchString(strings.TrimSpace(req.WorkspaceRevision)) || !checkpointEventCursorPattern.MatchString(req.EventCursor) || strings.TrimSpace(req.ConsumedAt) == "" { return CheckpointSeal{}, ErrInvalidInput } for _, raw := range []string{req.IssuedAt, req.ExpiresAt, req.ConsumedAt} { diff --git a/internal/relayfile/checkpoint_seal_test.go b/internal/relayfile/checkpoint_seal_test.go index 64b2f3d6..7ca25175 100644 --- a/internal/relayfile/checkpoint_seal_test.go +++ b/internal/relayfile/checkpoint_seal_test.go @@ -12,6 +12,21 @@ import ( const checkpointTestConsumerPrincipal = "cloud-dashboard-observer" +func TestCheckpointEventCursorPattern(t *testing.T) { + valid := []string{"0", "evt_42", "upstream:v1:github:pull_123", "upstream:v1:github:pull_123:2", "upstream:v1:" + strings.Repeat("a", 501)} + invalid := []string{"", " ", "evt_", "upstream:v1:", "upstream:v1:github pull_123", " upstream:v1:github:pull_123 ", "upstream:v1:github\tpull_123", "upstream:v1:slack:bad\\id", "upstream:v1:slack:bad\x7fid", "upstream:v1:" + strings.Repeat("a", 502)} + for _, cursor := range valid { + if !checkpointEventCursorPattern.MatchString(cursor) { + t.Errorf("valid cursor rejected: %q", cursor) + } + } + for _, cursor := range invalid { + if checkpointEventCursorPattern.MatchString(cursor) { + t.Errorf("invalid cursor accepted: %q", cursor) + } + } +} + func TestCheckpointIssuanceResponseLossRotatesBearerAcrossRestart(t *testing.T) { stateFile := filepath.Join(t.TempDir(), "relayfile-state.json") now := time.Date(2026, 8, 23, 12, 0, 0, 0, time.UTC) diff --git a/openapi/relayfile-v1.openapi.yaml b/openapi/relayfile-v1.openapi.yaml index eadb4c6f..f83843e6 100644 --- a/openapi/relayfile-v1.openapi.yaml +++ b/openapi/relayfile-v1.openapi.yaml @@ -4121,7 +4121,7 @@ components: pattern: '^(0|rev_[0-9]+)$' eventCursor: type: string - pattern: '^(0|evt_[0-9]+)$' + pattern: '^(0|evt_[0-9]+|upstream:v1:[A-Za-z0-9._:\-]{1,501})$' issuedAt: type: string format: date-time @@ -4219,7 +4219,7 @@ components: pattern: '^(0|rev_[0-9]+)$' eventCursor: type: string - pattern: '^(0|evt_[0-9]+)$' + pattern: '^(0|evt_[0-9]+|upstream:v1:[A-Za-z0-9._:\-]{1,501})$' consumedAt: type: string format: date-time @@ -4262,7 +4262,7 @@ components: pattern: '^(0|rev_[0-9]+)$' eventCursor: type: string - pattern: '^(0|evt_[0-9]+)$' + pattern: '^(0|evt_[0-9]+|upstream:v1:[A-Za-z0-9._:\-]{1,501})$' issuedAt: type: string format: date-time