Contract: Batch Event Ingest API
Feature: 039-cli-2x-readiness Version: 2.0.0 Date: 2026-02-12 Branch: 2.x Purpose: Enable the SaaS team to validate their batch endpoint against CLI payloads without consulting CLI source code.
Table of Contents
- Authentication Flow
- Event Envelope Field Reference
- Batch Request/Response Format
- Event Types and Payload Schemas
- Lane Vocabulary (Canonical 7-Lane)
- Error Categorization
- Fixture Data
- Tracker Snapshot Publish Payload (Feature 048)
1. Authentication Flow
The CLI uses a JWT-based authentication flow with credential persistence.
1.1 Login (Obtain Tokens)
POST /api/v1/token/
Content-Type: application/json
{
"username": "user@example.com",
"password": "s3cret"
}
Success Response (HTTP 200):
{
"access": "<jwt_access_token>",
"refresh": "<jwt_refresh_token>",
"access_lifetime": 900,
"refresh_lifetime": 604800,
"team_slug": "my-team"
}
| Response Field | Type | Required | Description |
|---|---|---|---|
access |
string | Yes | JWT access token |
refresh |
string | Yes | JWT refresh token |
access_lifetime |
integer | No | Access token lifetime in seconds (default: 900 = 15 min) |
refresh_lifetime |
integer | No | Refresh token lifetime in seconds (default: 604800 = 7 days) |
team_slug |
string | No | Team identifier for the authenticated user |
Error Response (HTTP 401):
{
"error": "Invalid username or password"
}
1.2 Token Refresh
POST /api/v1/token/refresh/
Content-Type: application/json
{
"refresh": "<jwt_refresh_token>"
}
Success Response (HTTP 200):
{
"access": "<new_jwt_access_token>",
"refresh": "<new_jwt_refresh_token>",
"access_lifetime": 900,
"refresh_lifetime": 604800,
"team_slug": "my-team"
}
Error Response (HTTP 401): The CLI clears stored credentials and prompts the user to re-authenticate.
1.3 Authorization Header
All authenticated requests (including batch ingest) include:
Authorization: Bearer <jwt_access_token>
1.4 Automatic Token Refresh Behavior
The CLI checks the access token expiry before each request:
- If the access token is valid, use it directly.
- If the access token is expired but the refresh token is valid, silently refresh via
/api/v1/token/refresh/. - If the refresh also fails (401), clear credentials and return
None(caller handles re-auth prompting).
1.5 Credential Storage
Credentials are stored on the user's machine at ~/.spec-kitty/credentials in TOML format with chmod 600 permissions.
[tokens]
access = "<jwt_access_token>"
refresh = "<jwt_refresh_token>"
access_expires_at = "2026-02-12T10:15:00"
refresh_expires_at = "2026-02-19T10:00:00"
[user]
username = "user@example.com"
team_slug = "my-team"
[server]
url = "https://team.spec-kitty.ai"
A file lock (~/.spec-kitty/credentials.lock) prevents concurrent access.
2. Event Envelope Field Reference
This historical batch-endpoint contract describes the retired sync transport. The external spec-kitty-events package's Pydantic Event model remains the schema reference; src/specify_cli/sync/emitter.py was deleted with that transport and is not a live CLI source.
2.1 Core Fields (Pydantic Event model)
| Field | Type | Required | Constraints | Example |
|---|---|---|---|---|
event_id |
string | Yes | Exactly 26 chars, ULID format (Crockford Base32: [0-9A-HJKMNP-TV-Z]{26}) |
"01JMBY1234567890ABCDEFGH" |
event_type |
string | Yes | One of the 8 known types (see Section 4) | "WPStatusChanged" |
aggregate_id |
string | Yes | Min 1 char. Entity this event modifies. | "WP01" or "039-cli-2x-readiness" |
payload |
object | Yes | Event-specific data (see Section 4 for per-type schemas) | {"wp_id": "WP01", ...} |
timestamp |
string | Yes | ISO 8601 with timezone (wall-clock, not used for ordering) | "2026-02-12T10:00:00+00:00" |
node_id |
string | Yes | Min 1 char. Stable machine identifier (12-char hex) | "a1b2c3d4e5f6" |
lamport_clock |
integer | Yes | >= 0, monotonically increasing per node | 42 |
causation_id |
string | No | 26 chars ULID if present, null for root events |
"01JMBY1234567890ABCDEFGI" |
2.2 Extended Fields (Added by EventEmitter)
These fields are added by the CLI's EventEmitter._emit() method and are present on every event in the batch payload:
| Field | Type | Required | Constraints | Example |
|---|---|---|---|---|
aggregate_type |
string | Yes | One of: "WorkPackage", "Feature" |
"WorkPackage" |
team_slug |
string | Yes | Non-empty string. "local" if team unavailable. |
"my-team" |
project_uuid |
string | Yes* | UUID v4 format (xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx). If missing, event is queued locally only and never sent to batch endpoint. |
"550e8400-e29b-41d4-a716-446655440000" |
project_slug |
string | No | Kebab-case project name, may be null |
"spec-kitty" |
git_branch |
string | No | Current git branch, may be null |
"039-cli-2x-readiness-WP07" |
head_commit_sha |
string | No | Full 40-char SHA, may be null |
"0cf3f906f4f979a000cf04c78688a397d69b6a37" |
repo_slug |
string | No | owner/repo format, may be null |
"priivacy/spec-kitty" |
*project_uuid is required for events to reach the batch endpoint. Events without project_uuid are queued locally but never transmitted.
2.3 ULID Format Specification
Both event_id and causation_id use ULID (Universally Unique Lexicographically Sortable Identifier):
- Length: exactly 26 characters
- Encoding: Crockford Base32 (
0123456789ABCDEFGHJKMNPQRSTVWXYZ) - Structure: 10-char timestamp (48-bit millisecond epoch) + 16-char randomness (80-bit)
- Regex:
^[0-9A-HJKMNP-TV-Z]{26}$ - ULIDs are sortable by creation time when compared lexicographically
2.4 UUID v4 Format
The project_uuid field uses UUID v4 (random):
- Format:
xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxxwhereyis one of[89ab] - Example:
"550e8400-e29b-41d4-a716-446655440000"
3. Batch Request/Response Format
3.1 Batch Request
POST /api/v1/events/batch/
Authorization: Bearer <jwt_access_token>
Content-Type: application/json
Content-Encoding: gzip
Body (before gzip compression):
{
"events": [
{ ... event 1 ... },
{ ... event 2 ... }
]
}
| Parameter | Value |
|---|---|
| URL | POST {server_url}/api/v1/events/batch/ (trailing slash required) |
| Max batch size | 1000 events |
| Compression | Body is gzip-compressed (Content-Encoding: gzip) |
| Ordering | Events are drained from the offline queue in FIFO order: timestamp ASC, id ASC |
| Timeout | 60 seconds |
The CLI constructs the payload as follows (from batch.py):
payload = json.dumps({"events": events}).encode("utf-8")
compressed = gzip.compress(payload)
3.2 Success Response (HTTP 200)
{
"results": [
{"event_id": "01JMBY1234567890ABCDEFGH", "status": "success"},
{"event_id": "01JMBY1234567890ABCDEFGI", "status": "duplicate"},
{"event_id": "01JMBY1234567890ABCDEFGJ", "status": "rejected", "error": "Invalid payload: missing field 'wp_id'"}
]
}
Per-event status values:
| Status | Meaning | CLI Behavior |
|---|---|---|
success |
Event accepted and stored | Remove from offline queue |
duplicate |
Event with this event_id already exists |
Remove from offline queue (treated as success) |
rejected |
Event failed server-side validation | Retain in queue, increment retry_count |
Event journal extension (#2124/#2131): the CLI behavior above describes the
current destructive offline event queue. After
event-sync-retention-delivery-01KVYWRG lands, event payload rows MUST NOT be
deleted on success or duplicate; those outcomes update the delivery ledger
for the resolved target. This extension applies to event payload delivery only.
body_upload_queue / body_upload_failure_log remain separate non-event queue
surfaces and are not converted into event-journal rows by that mission.
Additive note (
event-sync-retention-delivery-01KVYWRG, #2124/#2146 — strictly additive, NFR-006/C-006). The wire protocol in this section is unchanged: the request body, the per-eventstatusvocabulary (success/duplicate/rejected), and every fixture below stay exactly as specified. What changes is only the local CLI behavior for event payload rows while the (since-retired) local receiver drove delivery (the canonical source of this mapping wassrc/specify_cli/delivery/receivers.py, removed with the delivery subsystem on this line — spec-kitty#5 / PR #114; the table is retained as historical wire-contract documentation):
Per-event / batch outcome Local event-row effect after this mission successDelivery ledger UPDATE for the resolved target (records delivered); the local event-journal row is retained, never deleted. duplicateSame as success— ledger UPDATE, row retained (idempotent re-delivery, NFR-003).rejectedLedger rejection state for the target; payload retained and inspectable; eligible for a later attempt. oversized / permanent per-event failure Ledger terminal-failed state; excluded from future automatic selection but never deleted (FR-015). batch-level transient failure (401 / 403 / 408 / 429 / 5xx / timeout) Updates attempt metadata only (no per-event content verdict); never poisons per-event retry counts. Row deletion now happens only via explicit
sync gc/sync archive, which preserve delivery history/provenance. This extension covers event payload delivery only.body_upload_queueandbody_upload_failure_logremain owned bysync/queue.py, are not event-journal rows, and no status field in this contract may imply otherwise (event-sync-delivery contract §6).
Per-event result fields:
| Field | Type | Present When | Description |
|---|---|---|---|
event_id |
string | Always | The ULID of the event this result refers to |
status |
string | Always | One of: "success", "duplicate", "rejected" |
error |
string | status == "rejected" |
Human-readable validation failure reason |
error_message |
string | status == "rejected" |
Alternative field name (CLI checks both error_message and error) |
Note: The CLI accepts both error_message and error fields for the rejection reason. The SaaS should provide at least one.
3.3 Validation Error Response (HTTP 400)
{
"error": "Batch processing failed",
"details": "Transaction rolled back: 3 events failed schema validation"
}
| Field | Type | Required | Description |
|---|---|---|---|
error |
string | Yes | Top-level error message |
details |
string or list | Yes | Per-event failure reasons. Can be a JSON string containing a list, a JSON list directly, or a plain description string. |
Structured details format (preferred -- enables per-event error categorization):
{
"error": "Batch processing failed",
"details": [
{"event_id": "01JMBY...", "error": "Missing required field: wp_id"},
{"event_id": "01JMBY...", "reason": "Invalid event_type: FooBar"}
]
}
The CLI parses details as follows:
- If
detailsis a list: treat each item as{"event_id": ..., "error"|"reason": ...} - If
detailsis a JSON string encoding a list: parse and treat as above - If
detailsis a plain string: apply the top-levelerrorto all events in the batch
3.4 Authentication Error Response (HTTP 401)
{
"error": "Token expired or invalid"
}
CLI behavior: Prints "Batch sync failed: Authentication failed (401)", marks all events as rejected with error_category: "auth_expired", and increments retry count.
3.5 Permission Error Response (HTTP 403)
{
"error": "Insufficient permissions for team 'my-team' on project 'my-project'"
}
3.6 Server Error Responses (HTTP 5xx)
Any non-200/400/401 status code is treated as a server error. All events are marked rejected with error_category: "server_error".
4. Event Types and Payload Schemas
The retired CLI transport emitted 8 event types. Its _PAYLOAD_RULES implementation was deleted with src/specify_cli/sync/emitter.py; this section is retained as wire-contract history.
4.1 WPStatusChanged
Emitted when a work package changes lane (status).
| aggregate_type | aggregate_id format |
|---|---|
WorkPackage |
"WP01" (the wp_id) |
Payload fields:
| Field | Type | Required | Constraints | Example |
|---|---|---|---|---|
wp_id |
string | Yes | Pattern: ^WP\d{2}$ |
"WP01" |
from_lane |
string | Yes | One of the 7 canonical lanes (see below) | "planned" |
to_lane |
string | Yes | One of the 7 canonical lanes (see below) | "in_progress" |
actor |
string | No | String if present (actor identity) | "claude-agent" |
feature_slug |
string | No | Nullable string | "039-cli-2x-readiness" |
Canonical 7-lane vocabulary (accepted values for from_lane and to_lane):
planned, claimed, in_progress, for_review, done, blocked, canceled
Lane values are passed through directly from the canonical status model — no collapse or mapping is applied.
4.2 WPCreated
Emitted when a new work package is created.
| aggregate_type | aggregate_id format |
|---|---|
WorkPackage |
"WP01" (the wp_id) |
Payload fields:
| Field | Type | Required | Constraints | Example |
|---|---|---|---|---|
wp_id |
string | Yes | Pattern: ^WP\d{2}$ |
"WP03" |
title |
string | Yes | Min 1 char | "Implement batch sync" |
feature_slug |
string | Yes | Min 1 char | "039-cli-2x-readiness" |
dependencies |
list[string] | No | Each item matches ^WP\d{2}$ |
["WP01", "WP02"] |
4.3 WPAssigned
Emitted when a work package is assigned to an agent.
| aggregate_type | aggregate_id format |
|---|---|
WorkPackage |
"WP01" (the wp_id) |
Payload fields:
| Field | Type | Required | Constraints | Example |
|---|---|---|---|---|
wp_id |
string | Yes | Pattern: ^WP\d{2}$ |
"WP07" |
agent_id |
string | Yes | Min 1 char | "wp07-agent" |
phase |
string | Yes | One of: "implementation", "review" |
"implementation" |
retry_count |
integer | No | >= 0 | 0 |
4.4 FeatureCreated
Emitted when a new feature is created.
| aggregate_type | aggregate_id format |
|---|---|
Feature |
"039-cli-2x-readiness" (feature_slug) |
Payload fields:
| Field | Type | Required | Constraints | Example |
|---|---|---|---|---|
feature_slug |
string | Yes | Pattern: ^\d{3}-[a-z0-9-]+$ |
"039-cli-2x-readiness" |
feature_number |
string | Yes | Pattern: ^\d{3}$ |
"039" |
target_branch |
string | Yes | Min 1 char | "main" |
wp_count |
integer | Yes | >= 0 | 9 |
created_at |
string | No | ISO 8601 datetime string | "2026-02-12T10:00:00+00:00" |
4.5 FeatureCompleted
Emitted when all work packages in a feature reach terminal state.
| aggregate_type | aggregate_id format |
|---|---|
Feature |
"039-cli-2x-readiness" (feature_slug) |
Payload fields:
| Field | Type | Required | Constraints | Example |
|---|---|---|---|---|
feature_slug |
string | Yes | Min 1 char | "039-cli-2x-readiness" |
total_wps |
integer | Yes | >= 0 | 9 |
completed_at |
string | No | ISO 8601 datetime string | "2026-02-12T18:00:00+00:00" |
total_duration |
string | No | Nullable string | "8h 30m" |
4.6 HistoryAdded
Emitted when a history entry is added to a work package.
| aggregate_type | aggregate_id format |
|---|---|
WorkPackage |
"WP01" (the wp_id) |
Payload fields:
| Field | Type | Required | Constraints | Example |
|---|---|---|---|---|
wp_id |
string | Yes | Pattern: ^WP\d{2}$ |
"WP07" |
entry_type |
string | Yes | One of: "note", "review", "error", "comment" |
"note" |
entry_content |
string | Yes | Min 1 char | "Completed contract document" |
author |
string | No | String if present | "wp07-agent" |
4.7 ErrorLogged
Emitted when an error is logged during workflow execution.
| aggregate_type | aggregate_id format |
|---|---|
WorkPackage (if wp_id present) or Feature |
"WP01" or "error" |
Payload fields:
| Field | Type | Required | Constraints | Example |
|---|---|---|---|---|
error_type |
string | Yes | One of: "validation", "runtime", "network", "auth", "unknown" |
"validation" |
error_message |
string | Yes | Min 1 char | "Missing required field: wp_id" |
wp_id |
string | No | Nullable string | "WP03" |
stack_trace |
string | No | Nullable string | "Traceback ..." |
agent_id |
string | No | Nullable string | "wp07-agent" |
4.8 DependencyResolved
Emitted when a work package dependency is resolved.
| aggregate_type | aggregate_id format |
|---|---|
WorkPackage |
"WP04" (the dependent wp_id) |
Payload fields:
| Field | Type | Required | Constraints | Example |
|---|---|---|---|---|
wp_id |
string | Yes | Pattern: ^WP\d{2}$ |
"WP04" |
dependency_wp_id |
string | Yes | Pattern: ^WP\d{2}$ |
"WP02" |
resolution_type |
string | Yes | One of: "completed", "skipped", "merged" |
"completed" |
5. Lane Vocabulary (Canonical 7-Lane)
The CLI emits WPStatusChanged events using the full canonical 7-lane vocabulary directly. No lane collapse or mapping is applied — _SYNC_LANE_MAP has been removed.
5.1 Canonical Lanes
| Lane | Description |
|---|---|
planned |
Work not yet started |
claimed |
Claimed by an agent but not yet in progress |
in_progress |
Active work underway |
for_review |
Submitted for review |
done |
Complete (terminal) |
blocked |
Blocked by dependency or issue |
canceled |
Canceled (terminal) |
All 7 values are valid for both from_lane and to_lane in WPStatusChanged payloads.
5.2 SaaS Acceptance Requirements
The SaaS batch endpoint MUST accept all 7 canonical lane values in WPStatusChanged payload fields from_lane and to_lane.
Unknown lane values MUST be rejected with a descriptive error.
5.3 Alias Resolution (CLI-Side Only)
The CLI resolves the user-facing alias doing to in_progress at input boundaries (e.g., move-task --to doing). This resolution happens before event emission — the alias doing never appears in emitted events.
6. Error Categorization
The retired CLI transport categorized batch errors using keyword matching. Its src/specify_cli/sync/batch.py implementation was deleted; these categories remain historical wire-contract documentation.
6.1 Categories
| Category | Keywords | CLI Action Suggestion |
|---|---|---|
schema_mismatch |
invalid, schema, field, missing, type |
Inspect the invalid event with --report <file.json> |
auth_expired |
token, expired, unauthorized, 401 |
Run 'spec-kitty auth login' to refresh credentials |
server_error |
internal, 500, timeout, unavailable |
Retry later or check server status |
unknown |
(no keywords match) | Inspect the failure report for details: --report <file.json> |
The CLI applies categorize_error() to the error string from each rejected event result or the top-level error message from HTTP 400 responses.
6.2 Failure Report Format
The CLI can generate a JSON failure report after batch sync:
{
"generated_at": "2026-02-12T10:00:00+00:00",
"summary": {
"total_events": 100,
"synced": 90,
"duplicates": 3,
"failed": 7,
"categories": {
"schema_mismatch": 5,
"auth_expired": 1,
"unknown": 1
}
},
"failures": [
{
"event_id": "01JMBY1234567890ABCDEFGH",
"error": "Missing required field: wp_id",
"category": "schema_mismatch"
}
]
}
7. Fixture Data
The following fixtures are complete request/response examples. Each fixture can be used as-is for integration testing. The JSON is shown uncompressed for readability; in production the request body is gzip-compressed.
All fixture event data validates against the Pydantic Event model. See tests/contract/test_handoff_fixtures.py for the automated contract test.
Fixture 1: Single WPStatusChanged (Happy Path)
Request Body:
{
"events": [
{
"event_id": "01JMBY7K8N3QRVX2DPFG5HWT4E",
"event_type": "WPStatusChanged",
"aggregate_id": "WP01",
"aggregate_type": "WorkPackage",
"payload": {
"wp_id": "WP01",
"from_lane": "planned",
"to_lane": "in_progress",
"actor": "claude-agent",
"feature_slug": "039-cli-2x-readiness"
},
"timestamp": "2026-02-12T10:00:00+00:00",
"node_id": "a1b2c3d4e5f6",
"lamport_clock": 1,
"causation_id": null,
"team_slug": "priivacy",
"project_uuid": "550e8400-e29b-41d4-a716-446655440000",
"project_slug": "spec-kitty",
"git_branch": "039-cli-2x-readiness-WP01",
"head_commit_sha": "0cf3f906f4f979a000cf04c78688a397d69b6a37",
"repo_slug": "priivacy/spec-kitty"
}
]
}
Expected Response (HTTP 200):
{
"results": [
{
"event_id": "01JMBY7K8N3QRVX2DPFG5HWT4E",
"status": "success"
}
]
}
Key fields to verify: event_id is 26-char ULID, from_lane and to_lane are canonical 7-lane values, project_uuid is valid UUID v4.
Fixture 2: Mixed Batch (3 Event Types)
Request Body:
{
"events": [
{
"event_id": "01JMBYA1B2C3D4E5F6G7H8J9KA",
"event_type": "WPStatusChanged",
"aggregate_id": "WP02",
"aggregate_type": "WorkPackage",
"payload": {
"wp_id": "WP02",
"from_lane": "in_progress",
"to_lane": "for_review",
"actor": "wp02-agent",
"feature_slug": "039-cli-2x-readiness"
},
"timestamp": "2026-02-12T11:00:00+00:00",
"node_id": "a1b2c3d4e5f6",
"lamport_clock": 10,
"causation_id": null,
"team_slug": "priivacy",
"project_uuid": "550e8400-e29b-41d4-a716-446655440000",
"project_slug": "spec-kitty",
"git_branch": "039-cli-2x-readiness-WP02",
"head_commit_sha": "1af4b906f4f979a000cf04c78688a397d69b6a38",
"repo_slug": "priivacy/spec-kitty"
},
{
"event_id": "01JMBYA1B2C3D4E5F6G7H8J9KB",
"event_type": "WPCreated",
"aggregate_id": "WP10",
"aggregate_type": "WorkPackage",
"payload": {
"wp_id": "WP10",
"title": "End-to-end integration test suite",
"feature_slug": "039-cli-2x-readiness",
"dependencies": ["WP02", "WP03"]
},
"timestamp": "2026-02-12T11:01:00+00:00",
"node_id": "a1b2c3d4e5f6",
"lamport_clock": 11,
"causation_id": null,
"team_slug": "priivacy",
"project_uuid": "550e8400-e29b-41d4-a716-446655440000",
"project_slug": "spec-kitty",
"git_branch": "main",
"head_commit_sha": "2bf5c917f5f989b111df15d89799b498e7ac7b49",
"repo_slug": "priivacy/spec-kitty"
},
{
"event_id": "01JMBYA1B2C3D4E5F6G7H8J9KC",
"event_type": "FeatureCreated",
"aggregate_id": "040-next-feature",
"aggregate_type": "Feature",
"payload": {
"feature_slug": "040-next-feature",
"feature_number": "040",
"target_branch": "main",
"wp_count": 5,
"created_at": "2026-02-12T11:02:00+00:00"
},
"timestamp": "2026-02-12T11:02:00+00:00",
"node_id": "a1b2c3d4e5f6",
"lamport_clock": 12,
"causation_id": null,
"team_slug": "priivacy",
"project_uuid": "550e8400-e29b-41d4-a716-446655440000",
"project_slug": "spec-kitty",
"git_branch": "main",
"head_commit_sha": "2bf5c917f5f989b111df15d89799b498e7ac7b49",
"repo_slug": "priivacy/spec-kitty"
}
]
}
Expected Response (HTTP 200):
{
"results": [
{"event_id": "01JMBYA1B2C3D4E5F6G7H8J9KA", "status": "success"},
{"event_id": "01JMBYA1B2C3D4E5F6G7H8J9KB", "status": "success"},
{"event_id": "01JMBYA1B2C3D4E5F6G7H8J9KC", "status": "success"}
]
}
Key fields to verify: Three different event types in one batch, dependencies is an array of WP IDs, feature_number matches the pattern ^\d{3}$.
Fixture 3: Duplicate Event
Request Body (same event_id as Fixture 1, sent a second time):
{
"events": [
{
"event_id": "01JMBY7K8N3QRVX2DPFG5HWT4E",
"event_type": "WPStatusChanged",
"aggregate_id": "WP01",
"aggregate_type": "WorkPackage",
"payload": {
"wp_id": "WP01",
"from_lane": "planned",
"to_lane": "in_progress",
"actor": "claude-agent",
"feature_slug": "039-cli-2x-readiness"
},
"timestamp": "2026-02-12T10:00:00+00:00",
"node_id": "a1b2c3d4e5f6",
"lamport_clock": 1,
"causation_id": null,
"team_slug": "priivacy",
"project_uuid": "550e8400-e29b-41d4-a716-446655440000",
"project_slug": "spec-kitty",
"git_branch": "039-cli-2x-readiness-WP01",
"head_commit_sha": "0cf3f906f4f979a000cf04c78688a397d69b6a37",
"repo_slug": "priivacy/spec-kitty"
}
]
}
Expected Response (HTTP 200):
{
"results": [
{
"event_id": "01JMBY7K8N3QRVX2DPFG5HWT4E",
"status": "duplicate"
}
]
}
Key point: The SaaS MUST detect the duplicate by event_id and return "duplicate" status (not an error). The CLI treats duplicates as successful -- the event is removed from the offline queue.
Fixture 4: Rejected Event (Missing Required Field)
Request Body (missing error_type in ErrorLogged payload):
{
"events": [
{
"event_id": "01JMBYB3C4D5E6F7G8H9J0KABM",
"event_type": "ErrorLogged",
"aggregate_id": "error",
"aggregate_type": "Feature",
"payload": {
"error_message": "Something went wrong"
},
"timestamp": "2026-02-12T12:00:00+00:00",
"node_id": "a1b2c3d4e5f6",
"lamport_clock": 20,
"causation_id": null,
"team_slug": "priivacy",
"project_uuid": "550e8400-e29b-41d4-a716-446655440000",
"project_slug": "spec-kitty",
"git_branch": "main",
"head_commit_sha": null,
"repo_slug": "priivacy/spec-kitty"
}
]
}
Expected Response (HTTP 200 with per-event rejection):
{
"results": [
{
"event_id": "01JMBYB3C4D5E6F7G8H9J0KABM",
"status": "rejected",
"error": "Invalid payload for ErrorLogged: missing required field 'error_type'"
}
]
}
Key point: The SaaS should validate payloads per event type and return "rejected" with a descriptive error message. The error should contain keywords like missing or field so the CLI categorizes it as schema_mismatch.
Fixture 5: HTTP 400 Error Response
Request Body (entire batch fails validation):
{
"events": [
{
"event_id": "01JMBYC4D5E6F7G8H9J0K1WABN",
"event_type": "WPStatusChanged",
"aggregate_id": "WP01",
"aggregate_type": "WorkPackage",
"payload": {
"wp_id": "WP01",
"from_lane": "planned",
"to_lane": "in_progress",
"actor": "agent",
"feature_slug": null
},
"timestamp": "2026-02-12T13:00:00+00:00",
"node_id": "a1b2c3d4e5f6",
"lamport_clock": 30,
"causation_id": null,
"team_slug": "priivacy",
"project_uuid": "550e8400-e29b-41d4-a716-446655440000",
"project_slug": "spec-kitty",
"git_branch": null,
"head_commit_sha": null,
"repo_slug": null
}
]
}
Expected Response (HTTP 400):
{
"error": "Batch validation failed",
"details": [
{
"event_id": "01JMBYC4D5E6F7G8H9J0K1WABN",
"error": "Invalid schema: project_uuid authorization check failed for team 'priivacy'"
}
]
}
Key point: The details field MUST be present for HTTP 400 responses. The CLI depends on it for per-event diagnostics. When details is a structured list, the CLI can categorize each failure independently.
8. Tracker Snapshot Publish Payload (Feature 048)
The CLI publishes tracker snapshots to a separate endpoint from the batch event API:
POST {server_url}/api/v1/connectors/trackers/snapshots/
Authorization: Bearer <token>
Content-Type: application/json
Idempotency-Key: <sha256-hash>
Authorization token resolution: The CLI resolves the bearer token from, in order: (1) an explicit --auth-token parameter, (2) credentials["access_token"], (3) credentials["token"]. If all sources are empty, the Authorization header is omitted entirely. The SaaS should expect any of these token types and must reject unauthenticated requests.
This endpoint is independent of the batch event pipeline (/api/v1/events/batch/). The 15-field event envelope (Section 2) is unchanged by this feature.
8.1 Resource Routing Fields (2.1.0+)
Two new fields enable the SaaS to resolve ServiceResourceMapping records without additional CLI round-trips:
| Field | Type | Required | Description |
|---|---|---|---|
external_resource_type |
string \| null |
Yes | Canonical wire value identifying the resource kind |
external_resource_id |
string \| null |
Yes | Provider-specific resource identifier |
Canonical wire values:
| Value | Provider | Credential Source | Example ID |
|---|---|---|---|
"jira_project" |
Jira | credentials["project_key"] |
"ACME" |
"linear_team" |
Linear | credentials["team_id"] |
"abc-123-def-456" |
null |
Unsupported or missing | — | null |
8.2 Null Semantics
Both fields are always atomically null or atomically populated:
- Provider not in routing map (e.g., unsupported provider) → both
null - Credential key missing or empty string → both
null - Never one
nulland one populated
A null value means "routing unavailable" — the SaaS falls back to (provider, workspace) resolution.
8.3 Idempotency Key
The idempotency key hash includes external_resource_type and external_resource_id, ensuring that rebinding to a different project key or team ID produces a different key even when other state is unchanged.
8.4 Example Payload (Jira with Routing)
{
"provider": "jira",
"workspace": "acme.atlassian.net",
"external_resource_type": "jira_project",
"external_resource_id": "ACME",
"doctrine_mode": "external_authoritative",
"doctrine_field_owners": {},
"project_uuid": "550e8400-e29b-41d4-a716-446655440000",
"project_slug": "spec-kitty",
"issues": [],
"mappings": [],
"checkpoint": {"cursor": null, "updated_since": null}
}
8.5 Full Payload Schema
See tracker-snapshot-publish.md for the complete field reference, null semantics, backward compatibility notes, and additional example payloads (Linear, unsupported provider).
Appendix A: Full Event Lifecycle
1. CLI action triggers event emission (e.g., `spec-kitty agent tasks move-task WP01 --to doing`)
2. EventEmitter._emit() builds event dict with all envelope + extended fields
3. EventEmitter._validate_event() validates:
a. Pydantic Event model (core envelope fields)
b. aggregate_type is valid
c. event_type is one of 8 known types
d. ULID patterns for event_id and causation_id
e. Payload fields match per-event-type rules
4. If project_uuid is missing: queue locally only (no batch send)
5. If authenticated + WebSocket connected: send via WebSocket
6. Otherwise: queue to SQLite offline queue (~/.spec-kitty/queue.db)
7. On the retired background flush:
a. drain_queue(limit=1000) retrieves events FIFO
b. Gzip compress and POST to /api/v1/events/batch/
c. Parse per-event results
d. Remove success/duplicate events from queue
e. Increment retry_count for rejected events
Appendix B: Source File Reference
| Component | File / source |
|---|---|
| Pydantic Event model | External spec-kitty-events dependency, pinned at c93dbfbf5243330349452a31d05bca2ece26ceea (pyproject.toml) |
| Retired sync transport | Deleted with issue #5; this appendix is historical only |
| SaaS fan-out (canonical 7-lane) | src/specify_cli/status/emit.py (_saas_fan_out) |
| Retired public event API | Deleted with issue #5 |