JuliusBrussee/caveman · error
ccr: typed object id collision
Error message
ccr: typed object id collision
What it means
PutObject derives an object's ID from (Type, SessionID, Source, RepositoryState, ContentHash); the SQLite insert is an upsert that is a no-op when the ID already exists. If a row with that ID exists but sameImmutableObject finds the stored row differs from the incoming object in fields PutObject promises never to change, the write is refused as an ID collision — evidence of two different payloads mapping to the same identity.
Source
Thrown at engine/ccr/store_sqlite.go:542
obj.Lifecycle, string(deps), obj.OriginalByteLength, obj.StoredByteLength, obj.Data,
)
if err != nil {
if isFull(err) {
return "", fmt.Errorf("ccr typed object put: %w", ErrBudgetExceeded)
}
return "", fmt.Errorf("ccr typed object put: %w", err)
}
inserted, err := result.RowsAffected()
if err != nil {
return "", fmt.Errorf("ccr typed object put rows: %w", err)
}
if inserted == 0 {
existing, err := s.GetObject(obj.ID)
if err != nil {
return "", fmt.Errorf("ccr typed object collision lookup: %w", err)
}
if !sameImmutableObject(existing, obj) {
return "", errors.New("ccr: typed object id collision")
}
}
return obj.ID, nil
}
func scanObject(scanner interface{ Scan(...any) error }) (Object, error) {
var obj Object
var created, deps string
if err := scanner.Scan(
&obj.ID, &obj.Type, &obj.ContentHash, &obj.Source, &created, &obj.RepositoryState,
&obj.SessionID, &obj.TransformVersion, &obj.Currentness, &obj.Lifecycle, &deps,
&obj.OriginalByteLength, &obj.StoredByteLength, &obj.Data,
); err != nil {
return Object{}, err
}
parsed, err := time.Parse(time.RFC3339Nano, created)
if err != nil {
return Object{}, fmt.Errorf("ccr typed object created_at: %w", err)View on GitHub (pinned to 27d5a3981a)
Solutions
- Ensure the identity inputs (Type, SessionID, Source, RepositoryState, ContentHash) and immutable fields are deterministic functions of the same underlying data
- If the object genuinely changed, change its ContentHash (i.e. its Data) so it gets a new ID rather than colliding
- Inspect the existing row with GetObject(obj.ID) and diff it against your incoming object to find the mismatched field
Example fix
// before obj.Data = append(obj.Data, extra...) // Data mutated but identity reused via stale ContentHash store.PutObject(obj) // after obj.Data = append(obj.Data, extra...) obj.ContentHash = "" // force re-derivation from the new Data -> new ID store.PutObject(obj)
Defensive patterns
Strategy: validation
Validate before calling
existing, err := store.GetObject(obj.ID)
if err == nil && !sameImmutable(existing, obj) {
obj.ContentHash = "" // derive fresh identity from current Data
} Try / catch
id, err := store.PutObject(obj)
if err != nil && strings.Contains(err.Error(), "id collision") {
existing, gerr := store.GetObject(obj.ID)
// diff existing vs obj, then fix identity inputs or content
} Prevention
- Derive all identity and immutable fields from one deterministic source
- Clear ContentHash whenever Data changes
- Never edit CCR rows out-of-band
When it happens
Trigger: Putting an object with the same Type/Session/Source/RepositoryState/ContentHash but different Lifecycle, Currentness, Dependencies, or byte lengths than the row already in SQLite; a ContentHash computed over different bytes colliding with an existing row's identity inputs; two writers racing with slightly different metadata for the 'same' object.
Common situations: Version skew between components that add or mutate metadata fields between writes; re-putting an object after mutating its Data but forgetting that ContentHash (part of identity) stayed stale; a copied DB where rows were edited.
Related errors
- ccr: typed object id collision
- ccr: recovery handle not found
- ccr: storage budget exceeded
- ccr: typed object session_id is required
- ccr: typed object content_hash does not match data
AI-assisted analysis of JuliusBrussee/caveman@27d5a3981a (2026-08-15).
Data as JSON: /api/errors/c6dc427203de5b7c.
Report an issue: GitHub.