fix(matching): reconcile provisional source aliases
CI / build-and-test (push) Canceled after 0s
CI / release-critical-tests (push) Canceled after 0s
CI / csharp-format (push) Canceled after 0s
CI / webui (push) Canceled after 0s
CI / release-manifest (push) Canceled after 0s
CI / apple-contracts (push) Canceled after 0s
CI / compose-contracts (push) Canceled after 0s

This commit is contained in:
joshpatra committed 2026-09-27 18:50:48 -04:00
1 parent 6008ec949a
commit 5584ff4257
7 files changed
+436 -10

No files matched your search

@@ -0,0 +1,221 @@
using allstarr.Core.Capabilities;
using allstarr.Core.Matching;
using allstarr.Core.Storage;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
namespace allstarr.Tests;
public sealed class SourceIdentityReconciliationTests
{
[Theory]
[InlineData("provisional", false, true)]
[InlineData("provisional", true, true)]
[InlineData("raw", false, true)]
[InlineData("isrc", false, false)]
[InlineData("mbid", false, false)]
[InlineData("confirmed", false, false)]
[InlineData("manual", false, false)]
public async Task Reconciliation_IsAtomicIdempotentAndProtectsStrongEvidence(
string evidence, bool alreadyMoved, bool expected)
{
await using var database = await PostgresTestDatabase.CreateAsync();
await using var db = new AllstarrDbContext(database.Options);
var seed = await SeedAsync(db, evidence, alreadyMoved);
var before = seed.Identity.CanonicalRecordingId;
var result = await CanonicalCatalogEvidenceStore.ReconcileSourceIdentityAsync(
db, seed.Actor, seed.Identity, seed.Target, DateTimeOffset.UtcNow, default);
Assert.Equal(expected, result);
await db.SaveChangesAsync();
db.ChangeTracker.Clear();
var identity = await db.ProviderTrackIdentities.SingleAsync();
var alias = await db.CanonicalCatalogAliases.SingleAsync();
Assert.Equal(expected ? seed.Target : before, identity.CanonicalRecordingId);
Assert.Equal(expected ? seed.Target : seed.Origin, alias.CanonicalEntityId);
Assert.Equal(expected ? 1 : 0, await db.AuditEvents.CountAsync(item => item.Action == "source-identity.reconcile"));
Assert.Equal(2, await db.CanonicalRecordings.CountAsync());
if (!expected) return;
var revision = identity.Revision;
Assert.True(await CanonicalCatalogEvidenceStore.ReconcileSourceIdentityAsync(
db, seed.Actor, identity, seed.Target, DateTimeOffset.UtcNow, default));
await db.SaveChangesAsync();
Assert.Equal(revision, identity.Revision);
Assert.Single(await db.CanonicalCatalogAliases.ToListAsync());
Assert.Single(await db.AuditEvents.Where(item => item.Action == "source-identity.reconcile").ToListAsync());
}
[Fact]
public async Task Reconciliation_DeniesForeignTenantWithoutWrites()
{
await using var database = await PostgresTestDatabase.CreateAsync();
await using var db = new AllstarrDbContext(database.Options);
var seed = await SeedAsync(db, "provisional", false);
var foreign = new ProviderActorContext(Guid.CreateVersion7(), ProviderActorKind.User,
Guid.CreateVersion7(), new ProviderBackendPrincipal("jellyfin", "backend", "other"));
await Assert.ThrowsAsync<UnauthorizedAccessException>(() =>
CanonicalCatalogEvidenceStore.ReconcileSourceIdentityAsync(
db, foreign, seed.Identity, seed.Target, DateTimeOffset.UtcNow, default));
Assert.Equal(seed.Origin, seed.Identity.CanonicalRecordingId);
Assert.Empty(await db.AuditEvents.ToArrayAsync());
}
[Theory]
[InlineData("provisional", true, true)]
[InlineData("provisional", true, false)]
[InlineData("isrc", false, true)]
[InlineData("mbid", false, true)]
[InlineData("manual", false, true)]
public async Task Migration_RepairsOnlyUnanchoredSourceAliasesAndNormalizesLegacyHashes(
string evidence, bool repair, bool hasNormalizedAlias)
{
await using var database = await PostgresTestDatabase.CreateAsync(useTemplate: false);
await using var db = new AllstarrDbContext(database.Options);
var migrator = db.Database.GetService<IMigrator>();
await migrator.MigrateAsync("20260927214131_AddAdminOidcLinks");
var seed = await SeedAsync(db, evidence, true);
var current = await db.CanonicalCatalogAliases.SingleAsync();
if (!hasNormalizedAlias)
db.CanonicalCatalogAliases.Remove(current);
var legacyId = Guid.CreateVersion7();
db.CanonicalCatalogAliases.Add(new CanonicalCatalogAliasRecord
{
Id = legacyId,
TenantId = current.TenantId,
EntityKind = current.EntityKind,
CanonicalEntityId = current.CanonicalEntityId,
Namespace = current.Namespace,
ExternalId = current.ExternalId,
ExternalIdHash = current.ExternalId,
CreatedAt = current.CreatedAt,
LastSeenAt = current.LastSeenAt
});
await db.SaveChangesAsync();
await migrator.MigrateAsync();
db.ChangeTracker.Clear();
var alias = Assert.Single(await db.CanonicalCatalogAliases.ToArrayAsync());
Assert.Equal(repair ? seed.Target : seed.Origin, alias.CanonicalEntityId);
Assert.Equal(CanonicalCatalogKeys.Hash(alias.ExternalId), alias.ExternalIdHash);
Assert.Equal(repair ? hasNormalizedAlias ? 2 : 1 : 0,
await db.AuditEvents.CountAsync(item => item.Action == "source-alias.reconcile"));
Assert.Equal(hasNormalizedAlias ? 1 : 0, await db.AuditEvents.CountAsync(item => item.Action == "alias.deduplicate"));
Assert.Equal(seed.Target, (await db.ProviderTrackIdentities.SingleAsync()).CanonicalRecordingId);
Assert.Equal(2, await db.CanonicalRecordings.CountAsync());
await migrator.MigrateAsync();
Assert.Single(await db.CanonicalCatalogAliases.ToArrayAsync());
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task Migration_PreservesConflictingAliasesForReview(bool hasNormalizedHash)
{
await using var database = await PostgresTestDatabase.CreateAsync(useTemplate: false);
await using var db = new AllstarrDbContext(database.Options);
var migrator = db.Database.GetService<IMigrator>();
await migrator.MigrateAsync("20260927214131_AddAdminOidcLinks");
var seed = await SeedAsync(db, "isrc", true);
var current = await db.CanonicalCatalogAliases.SingleAsync();
if (!hasNormalizedHash)
current.ExternalIdHash = CanonicalCatalogKeys.Hash("another-legacy-hash");
db.CanonicalCatalogAliases.Add(new CanonicalCatalogAliasRecord
{
Id = Guid.CreateVersion7(),
TenantId = current.TenantId,
EntityKind = current.EntityKind,
CanonicalEntityId = seed.Target,
Namespace = current.Namespace,
ExternalId = current.ExternalId,
ExternalIdHash = current.ExternalId,
CreatedAt = current.CreatedAt,
LastSeenAt = current.LastSeenAt
});
await db.SaveChangesAsync();
await migrator.MigrateAsync();
db.ChangeTracker.Clear();
Assert.Equal(2, await db.CanonicalCatalogAliases.CountAsync());
Assert.Equal(seed.Origin, (await db.CanonicalCatalogAliases.SingleAsync(item => item.Id == current.Id)).CanonicalEntityId);
Assert.Empty(await db.AuditEvents.ToListAsync());
}
private static async Task<(ProviderActorContext Actor, ProviderTrackIdentityRecord Identity, Guid Origin, Guid Target)>
SeedAsync(AllstarrDbContext db, string evidence, bool alreadyMoved)
{
var now = DateTimeOffset.UtcNow;
var tenant = Guid.CreateVersion7();
var user = Guid.CreateVersion7();
var origin = Guid.CreateVersion7();
var target = Guid.CreateVersion7();
db.Tenants.Add(new TenantRecord { Id = tenant, Slug = $"reconcile-{tenant:N}", Name = "Reconcile", CreatedAt = now });
db.Users.Add(new PlatformUserRecord
{
Id = user,
TenantId = tenant,
DisplayName = "Owner",
Status = PlatformUserStatus.Active,
CreatedAt = now,
UpdatedAt = now
});
db.CanonicalRecordings.AddRange(new CanonicalRecordingRecord
{
Id = origin,
TenantId = tenant,
CreatedByUserId = user,
IsProvisional = evidence != "confirmed",
Isrc = evidence == "isrc" ? "USRC17607839" : null,
MusicBrainzRecordingId = evidence == "mbid" ? Guid.CreateVersion7().ToString() : null,
CreatedAt = now,
UpdatedAt = now
}, new CanonicalRecordingRecord
{
Id = target,
TenantId = tenant,
CreatedByUserId = user,
CreatedAt = now,
UpdatedAt = now
});
var identity = new ProviderTrackIdentityRecord
{
Id = Guid.CreateVersion7(),
TenantId = tenant,
CanonicalRecordingId = alreadyMoved ? target : origin,
ProviderId = "fixture",
ResourceKind = ProviderResourceKind.Track,
CatalogNamespace = "default",
Scope = ProviderIdentityScope.Catalog,
ExternalId = evidence == "raw" ? "source" : CanonicalCatalogKeys.Hash("source"),
ExternalIdHash = CanonicalCatalogKeys.Hash("source"),
Verification = evidence == "manual" ? ProviderIdentityVerification.Pinned : ProviderIdentityVerification.Verified,
VerificationMethod = evidence switch
{
"manual" => "manual-review",
"raw" => "source-snapshot",
_ => "source-snapshot-hash"
},
DecisionVersion = 1,
VerifiedAt = now,
CreatedAt = now,
UpdatedAt = now
};
db.ProviderTrackIdentities.Add(identity);
db.CanonicalCatalogAliases.Add(new CanonicalCatalogAliasRecord
{
Id = Guid.CreateVersion7(),
TenantId = tenant,
EntityKind = CanonicalCatalogEntityKind.Recording,
CanonicalEntityId = origin,
Namespace = CanonicalCatalogKeys.ProviderTrackNamespace("fixture", ProviderResourceKind.Track, "default", ProviderIdentityScope.Catalog, null),
ExternalId = identity.ExternalId,
ExternalIdHash = CanonicalCatalogKeys.Hash(identity.ExternalId),
CreatedAt = now,
LastSeenAt = now
});
await db.SaveChangesAsync();
return (new ProviderActorContext(tenant, ProviderActorKind.User, user,
new ProviderBackendPrincipal("jellyfin", "backend", "owner")), identity, origin, target);
}
}
@@ -902,6 +902,51 @@ public sealed class PlaylistOrchestrationIntegrationTests(ITestOutputHelper outp
Assert.Single(projection.Entries).ProviderRoutes.Select(item => item.ProviderId));
}
[Fact]
public async Task Two_source_tracks_converge_on_existing_provider_without_stale_aliases()
{
_source.Snapshot = Snapshot("shared-provider",
Entry(0, "first", "unindexed-first", "Shared source"),
Entry(1, "second", "unindexed-second", "Shared source"));
var gateway = new Mock<IProtocolProviderGateway>();
gateway.Setup(item => item.GetProviderOrder(ProviderCapabilityKind.Streaming)).Returns(["deezer"]);
gateway.Setup(item => item.SearchPlayableSongsAsync(
It.IsAny<ProtocolExecutionContext>(), It.IsAny<string>(), 60))
.ReturnsAsync([new Song
{
ExternalProvider = "deezer", ExternalId = "shared-provider-track",
Title = "Shared source", Artist = "Artist", Duration = 180
}]);
var matcher = new TrackMatchDecisionEngine();
var matches = new TrackMatchCommandService(
_factory, matcher, new ProviderAccountResolver(_factory, new ProviderPolicyOptions()), new Clock(_now),
new PlaylistPlayableSearchService(gateway.Object, matcher, null!, new IdentityOptions(),
Options.Create(new JellyfinSettings()), NullLogger<PlaylistPlayableSearchService>.Instance));
var service = new PlaylistOrchestrationService(
_factory, _source, new FakeTargetResolver(_target), new PlaylistMaterializationPlanner(), matcher,
matches, new Clock(_now));
await service.RefreshAsync(Context(), _link);
await using var db = await _factory.CreateDbContextAsync();
var provider = await db.ProviderTrackIdentities.SingleAsync(item => item.ProviderId == "deezer");
var sources = await db.ProviderTrackIdentities.Where(item =>
item.VerificationMethod == "source-snapshot-hash").ToArrayAsync();
Assert.Equal(2, sources.Length);
Assert.All(sources, item => Assert.Equal(provider.CanonicalRecordingId, item.CanonicalRecordingId));
var aliases = await db.CanonicalCatalogAliases.ToArrayAsync();
Assert.Equal(3, aliases.Length);
Assert.All(aliases, item => Assert.Equal(provider.CanonicalRecordingId, item.CanonicalEntityId));
Assert.Single(await db.AuditEvents.Where(item => item.Action == "source-identity.reconcile").ToArrayAsync());
Assert.Equal(2, await db.TrackMatches.CountAsync(item => item.State == TrackMatchState.Accepted));
foreach (var source in sources)
await matches.RematchSnapshotAsync(Context(),
(await db.ExternalMetadataSnapshots.SingleAsync(item => item.ExternalIdHash == source.ExternalIdHash)).Id,
"repeated-source-rematch", "test");
Assert.Equal(3, await db.CanonicalCatalogAliases.CountAsync());
Assert.Single(await db.AuditEvents.Where(item => item.Action == "source-identity.reconcile").ToArrayAsync());
}
[Fact]
public async Task Concurrent_external_rematches_coalesce_identity_and_decision_writes()
{
@@ -14,6 +14,6 @@ public sealed class MigrationModelSnapshotTests
using var context = new AllstarrDbContext(options);
Assert.False(context.Database.HasPendingModelChanges());
Assert.Equal("20260927214131_AddAdminOidcLinks", context.Database.GetMigrations().Last());
Assert.Equal("20260927230000_ReconcileSourceIdentityAliases", context.Database.GetMigrations().Last());
}
}
@@ -208,6 +208,76 @@ public sealed class CanonicalCatalogEvidenceStore(
IDbContextFactory<AllstarrDbContext> contextFactory,
DurableStorageState storageState) : ICanonicalCatalogEvidenceStore
{
internal static async Task<bool> ReconcileSourceIdentityAsync(
AllstarrDbContext db,
ProviderActorContext actor,
ProviderTrackIdentityRecord identity,
Guid targetRecordingId,
DateTimeOffset observedAt,
CancellationToken cancellationToken)
{
if (identity.TenantId != actor.TenantId)
throw new UnauthorizedAccessException("The source identity is outside the actor tenant.");
await ValidateActorAndTargetAsync(
db, actor, new(CanonicalCatalogEntityKind.Recording, targetRecordingId), cancellationToken);
var aliasNamespace = CanonicalCatalogKeys.ProviderTrackNamespace(
identity.ProviderId, identity.ResourceKind, identity.CatalogNamespace,
identity.Scope, identity.ProviderAccountId);
var hash = CanonicalCatalogKeys.Hash(identity.ExternalId);
var alias = db.CanonicalCatalogAliases.Local.SingleOrDefault(item =>
item.TenantId == actor.TenantId && item.Namespace == aliasNamespace &&
item.EntityKind == CanonicalCatalogEntityKind.Recording && item.ExternalIdHash == hash) ??
await db.CanonicalCatalogAliases.SingleOrDefaultAsync(item =>
item.TenantId == actor.TenantId && item.Namespace == aliasNamespace &&
item.EntityKind == CanonicalCatalogEntityKind.Recording && item.ExternalIdHash == hash,
cancellationToken);
if (alias != null && alias.ExternalId != identity.ExternalId)
throw new InvalidOperationException("A catalog alias hash collision was detected.");
var previousIds = new[] { identity.CanonicalRecordingId, alias?.CanonicalEntityId }
.OfType<Guid>().Where(id => id != targetRecordingId).Distinct().ToArray();
if (previousIds.Length > 0)
{
if (identity.Verification != ProviderIdentityVerification.Verified ||
identity.VerificationMethod is not ("source-snapshot" or "source-snapshot-hash"))
return false;
foreach (var id in previousIds)
{
var previous = db.CanonicalRecordings.Local.SingleOrDefault(item =>
item.TenantId == actor.TenantId && item.Id == id) ??
await db.CanonicalRecordings.SingleOrDefaultAsync(item =>
item.TenantId == actor.TenantId && item.Id == id, cancellationToken);
if (previous is not { IsProvisional: true, Isrc: null, MusicBrainzRecordingId: null })
return false;
}
db.AuditEvents.Add(new AuditEventRecord
{
Id = Guid.CreateVersion7(),
TenantId = actor.TenantId,
ActorUserId = actor.UserId,
Category = "canonical-catalog",
Action = "source-identity.reconcile",
Outcome = "updated",
CorrelationId = $"catalog:source-identity:{identity.Id:N}",
DetailsJson = JsonSerializer.Serialize(new { identityId = identity.Id, previousIds, targetRecordingId }),
CreatedAt = observedAt
});
identity.CanonicalRecordingId = targetRecordingId;
identity.UpdatedAt = observedAt;
identity.Revision++;
if (alias != null)
{
alias.CanonicalEntityId = targetRecordingId;
alias.LastSeenAt = observedAt;
}
}
await CanonicalCatalogIdentityProjection.ProjectProviderIdentityAsync(
db, actor, identity, observedAt, cancellationToken);
return true;
}
public async Task<CanonicalCatalogEvidenceResult> RecordAsync(
ProviderActorContext actor,
CanonicalCatalogEntityReference target,
@@ -1862,6 +1862,9 @@ public sealed class TrackMatchCommandService(
latestVersion + 1,
clock.UtcNow,
cancellationToken);
if (!canonicalRecordingId.HasValue)
return new(false, TrackMatchCommandFailure.Conflict,
"The source recording has conflicting identity evidence; review the match before merging it.");
}
var input = externalRoutable && canonicalRecordingId.HasValue
? MatchDecisionInput.FromExternalDecision(
@@ -1968,7 +1971,7 @@ public sealed class TrackMatchCommandService(
cancellationToken);
}
private static async Task<Guid> LinkExternalIdentitiesAsync(
private static async Task<Guid?> LinkExternalIdentitiesAsync(
AllstarrDbContext db,
ProviderActorContext actor,
ProviderTrackIdentityRecord source,
@@ -1985,18 +1988,20 @@ public sealed class TrackMatchCommandService(
var canonicalRecordingId = await LinkExternalIdentityAsync(
db, actor, source, selected, source.CanonicalRecordingId, true, verificationMethod,
decisionVersion, now, cancellationToken);
if (!canonicalRecordingId.HasValue)
return null;
foreach (var alternate in routable.Where(song =>
!string.Equals(song.ExternalProvider, selected.ExternalProvider, StringComparison.OrdinalIgnoreCase) ||
!string.Equals(song.ExternalId, selected.ExternalId, StringComparison.Ordinal)))
{
await LinkExternalIdentityAsync(
db, actor, source, alternate, canonicalRecordingId, false, verificationMethod,
db, actor, source, alternate, canonicalRecordingId.Value, false, verificationMethod,
decisionVersion, now, cancellationToken);
}
return canonicalRecordingId;
}
private static async Task<Guid> LinkExternalIdentityAsync(
private static async Task<Guid?> LinkExternalIdentityAsync(
AllstarrDbContext db,
ProviderActorContext actor,
ProviderTrackIdentityRecord source,
@@ -2027,11 +2032,9 @@ public sealed class TrackMatchCommandService(
{
if (primary)
canonicalRecordingId = identity.CanonicalRecordingId;
if (source.CanonicalRecordingId != canonicalRecordingId)
{
source.CanonicalRecordingId = canonicalRecordingId;
source.UpdatedAt = now;
}
if (primary && !await CanonicalCatalogEvidenceStore.ReconcileSourceIdentityAsync(
db, actor, source, canonicalRecordingId, now, cancellationToken))
return null;
if (identity.VerificationMethod == ManualTrackAuthorityPolicy.ReleasedProviderVerificationMethod ||
verificationMethod == "automatic-match" &&
identity.VerificationMethod == "automatic-suggestion")
@@ -2537,6 +2540,8 @@ public sealed class TrackMatchCommandService(
private static bool IsConcurrentMatchWrite(Exception exception)
{
if (exception is DbUpdateConcurrencyException)
return true;
for (var current = exception; current != null; current = current.InnerException)
{
if (current is Npgsql.PostgresException
@@ -0,0 +1,85 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace allstarr.Core.Storage.Migrations;
[DbContext(typeof(AllstarrDbContext))]
[Migration("20260927230000_ReconcileSourceIdentityAliases")]
public sealed class ReconcileSourceIdentityAliases : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
WITH repaired AS (
UPDATE canonical_catalog_aliases alias
SET "CanonicalEntityId" = identity."CanonicalRecordingId",
"LastSeenAt" = GREATEST(alias."LastSeenAt", identity."UpdatedAt")
FROM provider_track_identities identity, canonical_recordings previous, canonical_recordings target
WHERE alias."TenantId" = identity."TenantId"
AND alias."EntityKind" = 'Recording'
AND alias."Namespace" = 'provider:' || encode(sha256(convert_to(
identity."ProviderId" || E'\n' || identity."ResourceKind" || E'\n' ||
identity."CatalogNamespace" || E'\n' || identity."Scope" || E'\n' ||
COALESCE(identity."ProviderAccountId"::text, ''), 'UTF8')), 'hex')
AND alias."ExternalId" = identity."ExternalId"
AND alias."CanonicalEntityId" <> identity."CanonicalRecordingId"
AND identity."Verification" = 'Verified'
AND identity."VerificationMethod" IN ('source-snapshot', 'source-snapshot-hash')
AND previous."TenantId" = alias."TenantId" AND previous."Id" = alias."CanonicalEntityId"
AND previous."IsProvisional" AND previous."Isrc" IS NULL AND previous."MusicBrainzRecordingId" IS NULL
AND target."TenantId" = identity."TenantId" AND target."Id" = identity."CanonicalRecordingId"
RETURNING alias."Id", alias."TenantId", previous."Id" AS previous_id,
identity."CanonicalRecordingId" AS target_id
)
INSERT INTO audit_events
("Id", "TenantId", "Category", "Action", "Outcome", "CorrelationId", "DetailsJson", "CreatedAt")
SELECT gen_random_uuid(), "TenantId", 'canonical-catalog', 'source-alias.reconcile', 'updated',
'migration:20260927230000',
jsonb_build_object('aliasId', "Id", 'previousRecordingId', previous_id, 'targetRecordingId', target_id)::text,
(EXTRACT(EPOCH FROM now()) * 10000000)::bigint + 621355968000000000
FROM repaired;
WITH duplicates AS (
SELECT legacy."Id", current."Id" AS retained_id
FROM canonical_catalog_aliases legacy
JOIN canonical_catalog_aliases current
ON current."TenantId" = legacy."TenantId" AND current."Namespace" = legacy."Namespace"
AND current."EntityKind" = legacy."EntityKind" AND current."ExternalId" = legacy."ExternalId"
AND current."CanonicalEntityId" = legacy."CanonicalEntityId"
AND current."ExternalIdHash" = encode(sha256(convert_to(legacy."ExternalId", 'UTF8')), 'hex')
WHERE legacy."Namespace" LIKE 'provider:%' AND legacy."EntityKind" = 'Recording'
AND legacy."ExternalIdHash" <> current."ExternalIdHash"
), removed AS (
DELETE FROM canonical_catalog_aliases alias USING duplicates
WHERE alias."Id" = duplicates."Id"
RETURNING alias."TenantId", alias."Id", duplicates.retained_id
)
INSERT INTO audit_events
("Id", "TenantId", "Category", "Action", "Outcome", "CorrelationId", "DetailsJson", "CreatedAt")
SELECT gen_random_uuid(), "TenantId", 'canonical-catalog', 'alias.deduplicate', 'updated',
'migration:20260927230000', jsonb_build_object('removedAliasId', "Id", 'retainedAliasId', retained_id)::text,
(EXTRACT(EPOCH FROM now()) * 10000000)::bigint + 621355968000000000
FROM removed;
UPDATE canonical_catalog_aliases alias
SET "ExternalIdHash" = encode(sha256(convert_to(alias."ExternalId", 'UTF8')), 'hex')
WHERE alias."Namespace" LIKE 'provider:%' AND alias."EntityKind" = 'Recording'
AND alias."ExternalIdHash" <> encode(sha256(convert_to(alias."ExternalId", 'UTF8')), 'hex')
AND NOT EXISTS (
SELECT 1 FROM canonical_catalog_aliases collision
WHERE collision."TenantId" = alias."TenantId" AND collision."Namespace" = alias."Namespace"
AND collision."EntityKind" = alias."EntityKind"
AND collision."Id" <> alias."Id"
AND (collision."ExternalIdHash" = encode(sha256(convert_to(alias."ExternalId", 'UTF8')), 'hex')
OR collision."ExternalId" = alias."ExternalId"));
""");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
// Restoring stale pointers would corrupt subsequent matches; rollback uses a verified database backup.
}
}
@@ -340,7 +340,7 @@ Checkpoint as of 2026-09-27; the catalog and alias projection changes are deploy
- **Discovery and refresh:** `MusicBrainzCatalogRefreshQueue` creates seven-day idempotency generations scoped by tenant, user, source, and revision. Recording discovery accepts at most 50 distinct editions. Release refresh accepts one release hierarchy and at most 64 credited artists before atomic ingestion. Both jobs preserve upstream retry delays, separate permanent hierarchy failures from transient failures, and stay outside search and playback requests. The focused lane passes 62/62 tests; a separate PostgreSQL run passes all 21 selected identity, discovery, and ingestion tests.
- **Identity projection:** Creating a recording now projects exact ISRC and MusicBrainz aliases through the shared evidence owner. Both the identity service and matching commands project accepted provider identities transactionally with account- or catalog-scoped namespaces, so the same provider ID cannot leak or collide across account boundaries. Indexed Jellyfin and Subsonic/OpenSubsonic items use a protocol-and-backend-instance namespace; two users seeing the same native item converge only when their canonical assignments agree. Conflicting historical native assignments remain unaliased rather than being silently merged. Concurrent match writers treat an alias insert race as a retryable identity write. Forward migrations backfill provider, signal, and consistent native aliases and mark recordings without an MBID provisional. The isolated PostgreSQL lanes pass all 22 unique selected identity, migration, native-index, manual-selection, automatic-fallback, concurrency, and model-snapshot tests.
- **Snapshot scope:** Snapshot capture validates a supplied provider identity against the tenant, source provider, track hash, catalog, and resolved account. Repeated captures must preserve the identity link and backend principal. PostgreSQL regressions cover foreign identities, both permitted identity scopes, and immutable retries.
- **Next reconciliation work:** Automatic rematching can move a source identity to an existing provider recording while leaving its catalog alias attached to the old recording. Fix this atomically before issuing stable canonical protocol IDs. Preserve historical decisions and manual authority; qualify two source tracks converging on one provider recording, concurrent rematches, and repeated source refresh. Generic evidence ingestion must continue to reject arbitrary alias reassignment.
- **Source reconciliation:** Automatic rematching now moves a provisional source identity and its catalog alias together when it reuses an existing provider recording. A source anchored by an ISRC, MusicBrainz ID, confirmed recording, or manual identity cannot be reassigned by this path. The operation preserves old recordings and historical decisions, records an audit event, and retries concurrent identity writes. A forward migration repairs unanchored stale aliases, removes redundant aliases with identical targets, and normalizes legacy hashes; conflicting targets remain untouched. Isolated PostgreSQL regressions cover converging sources, repeated rematches, protected evidence, and migration replay. Generic evidence ingestion still rejects arbitrary alias reassignment. This is not a general-purpose recording merge.
- **Remaining:** Project remaining legacy source-snapshot and protocol identities into the catalog; add the relationship and image request shapes needed by Stage 3; and reconcile provisional records that begin without an MBID.
| Stage 2 checkpoint measure | Stage start | Current | Interpretation |