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

  1. Ensure the identity inputs (Type, SessionID, Source, RepositoryState, ContentHash) and immutable fields are deterministic functions of the same underlying data
  2. If the object genuinely changed, change its ContentHash (i.e. its Data) so it gets a new ID rather than colliding
  3. 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

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


AI-assisted analysis of JuliusBrussee/caveman@27d5a3981a (2026-08-15). Data as JSON: /api/errors/c6dc427203de5b7c. Report an issue: GitHub.