diff --git a/db/migrations/0033_model_registry_identity_contract.sql b/db/migrations/0033_model_registry_identity_contract.sql new file mode 100644 index 00000000..5e60e196 --- /dev/null +++ b/db/migrations/0033_model_registry_identity_contract.sql @@ -0,0 +1,8 @@ +-- PHASE-1-SHADOW-RUN / REQ-EXEC-001 +-- Bind a client-selected model identity to the server-side approved VersionSet. +ALTER TABLE governance.model_version_registry + ADD COLUMN IF NOT EXISTS model_id uuid; + +CREATE INDEX IF NOT EXISTS ix_model_version_registry_model_scope_effective + ON governance.model_version_registry (model_id, scope_key, effective_at desc) + WHERE model_id IS NOT NULL; diff --git a/docs/CURRENT/PHASE-1_VERSIONSET_AUTOMATION_SLICE.md b/docs/CURRENT/PHASE-1_VERSIONSET_AUTOMATION_SLICE.md new file mode 100644 index 00000000..4fea4633 --- /dev/null +++ b/docs/CURRENT/PHASE-1_VERSIONSET_AUTOMATION_SLICE.md @@ -0,0 +1,37 @@ +# Phase 1 VersionSet Automation Slice + +## WBS / Scope + +- WBS: `PHASE-1-SHADOW-RUN` +- Slice: server-side model identity to approved VersionSet resolution +- Requirement: `REQ-EXEC-001` +- Scope: resolve VersionSet by approved `model_id`, `scope_key`, and PIT cutoff before JobRun/enqueue. +- Out of scope: automatic model promotion, threshold mutation, order/KIS submission, and production seed data. + +## Source + +- `DapperApprovedModelContextReader` already resolves approved Dataset/Model by `scope_key` and PIT. +- `InitiateShadowRunHandler` currently generates RunId/IdempotencyKey but does not resolve VersionSet or create JobRun. +- `governance.model_version_registry` has no model identity column, so the endpoint cannot safely bind `modelId` to an approved model version. +- `AGENTS.md` requires server-side PIT evidence and forbids trusting client-supplied evidence. + +## Assumption + +- `model_id` is the stable server-side identity for the requested Shadow model. +- Existing registry rows, if any, remain valid with nullable `model_id` until explicitly backfilled and approved. + +## Unknown + +- Production model registry contains no approved rows today; this Slice does not invent or seed them. +- JobRun persistence is already available but is not yet wired into the ShadowRun handler. + +## Decision Required + +- DBA/Model Owner must approve model registry backfill before any production Shadow enqueue. + +## Acceptance Evidence + +- Migration adds the model identity mapping without modifying prior migrations. +- Reader requires `model_id`, `scope_key`, and PIT cutoff and returns only approved server-side context. +- No context returns no enqueue path. +- Existing automatic order/KIS capabilities remain OFF. diff --git a/src/KArtSell.Modules.ModelOperations/Application/ModelOperationsContracts.cs b/src/KArtSell.Modules.ModelOperations/Application/ModelOperationsContracts.cs index d77acbf0..b1c4f423 100644 --- a/src/KArtSell.Modules.ModelOperations/Application/ModelOperationsContracts.cs +++ b/src/KArtSell.Modules.ModelOperations/Application/ModelOperationsContracts.cs @@ -35,6 +35,7 @@ public sealed record ModelOperationRequest( public interface IApprovedModelContextReader { Task ReadAsync(string scopeKey, DateTimeOffset asOf, CancellationToken cancellationToken); + Task ReadAsync(Guid modelId, string scopeKey, DateTimeOffset asOf, CancellationToken cancellationToken); } public interface IModelScheduleRepository diff --git a/src/KArtSell.Modules.ModelOperations/Infrastructure/DapperApprovedModelContextReader.cs b/src/KArtSell.Modules.ModelOperations/Infrastructure/DapperApprovedModelContextReader.cs index 9cf73992..c095dff3 100644 --- a/src/KArtSell.Modules.ModelOperations/Infrastructure/DapperApprovedModelContextReader.cs +++ b/src/KArtSell.Modules.ModelOperations/Infrastructure/DapperApprovedModelContextReader.cs @@ -7,7 +7,7 @@ namespace KArtSell.Modules.ModelOperations.Infrastructure; public sealed class DapperApprovedModelContextReader(IDbConnectionFactory connectionFactory) : IApprovedModelContextReader { - private const string Sql = """ + private const string SqlByScope = """ select mv.scope_key as ScopeKey, mv.model_version as ModelVersion, mv.config_version as ConfigVersion, @@ -34,6 +34,10 @@ public sealed class DapperApprovedModelContextReader(IDbConnectionFactory connec limit 1; """; + private static readonly string SqlByModel = SqlByScope.Replace( + "where mv.scope_key = @ScopeKey", + "where mv.model_id = @ModelId and mv.scope_key = @ScopeKey"); + public async Task ReadAsync( string scopeKey, DateTimeOffset asOf, @@ -41,7 +45,7 @@ public sealed class DapperApprovedModelContextReader(IDbConnectionFactory connec { await using var connection = await connectionFactory.OpenAsync(cancellationToken); var row = await connection.QuerySingleOrDefaultAsync(new CommandDefinition( - Sql, + SqlByScope, new { ScopeKey = scopeKey, AsOf = asOf }, cancellationToken: cancellationToken)); @@ -55,6 +59,27 @@ public sealed class DapperApprovedModelContextReader(IDbConnectionFactory connec row.EffectiveAt); } + public async Task ReadAsync( + Guid modelId, + string scopeKey, + DateTimeOffset asOf, + CancellationToken cancellationToken) + { + await using var connection = await connectionFactory.OpenAsync(cancellationToken); + var row = await connection.QuerySingleOrDefaultAsync(new CommandDefinition( + SqlByModel, + new { ModelId = modelId, ScopeKey = scopeKey, AsOf = asOf }, + cancellationToken: cancellationToken)); + + return row is null ? null : ToContext(row); + } + + private static ApprovedModelContext ToContext(Row row) => new( + row.ScopeKey, + new VersionSet(row.DatasetId, row.DataHash, row.ModelVersion, row.ConfigVersion, row.CodeSha, row.ContractVersion), + row.LifecycleState, + row.EffectiveAt); + private sealed record Row( string ScopeKey, string DatasetId,