Compare commits

..

1 Commits

Author SHA1 Message Date
kjh2064 c9b59994b5 PHASE-1-SHADOW-RUN: bind VersionSet resolution to model identity
ci / static (push) Successful in 8s
ci / backend (push) Successful in 1m58s
ci / frontend (push) Successful in 2m47s
ci / publish (push) Has been skipped
2026-08-06 15:49:53 +09:00
6 changed files with 73 additions and 39 deletions
-5
View File
@@ -58,11 +58,6 @@ jobs:
echo ""
ssh -i /tmp/deploy_key.pem -o StrictHostKeyChecking=no kjh2064@178.104.200.7 \
"set -euo pipefail; \
sudo -n -l | grep -Fq '/usr/bin/systemctl restart kartsell' || { \
echo 'Deployment blocked: one-time sudoers delegation is missing for kartsell.' >&2; \
echo 'Expected: kjh2064 ALL=(root) NOPASSWD: /usr/bin/systemctl restart kartsell' >&2; \
exit 77; \
}; \
export KARTSELL_POSTGRES='${{ secrets.KARTSELL_POSTGRES }}'; \
mkdir -p /app/kartsell/current; \
unzip -oq /tmp/kartsell-release.zip -d /app/kartsell/current; \
@@ -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;
@@ -1,32 +0,0 @@
# KArtSell 배포 재기동 권한 계약
## Source
- 운영 호스트 `hz-prod-01`의 실제 sudo 정책 조회 결과
- 기존 `quantengine``taxbaik` 서비스의 특정 `systemctl restart` `NOPASSWD` 위임 패턴
- `.gitea/workflows/deploy.yml`
## Assumption
- 배포 SSH 계정은 `kjh2064`로 유지한다.
- 운영 서비스는 `/etc/systemd/system/kartsell.service`로 유지한다.
- DbMigrator와 artifact 복사는 현재처럼 `kjh2064` 권한으로 수행한다.
## Decision
`kjh2064`에 전체 sudo 권한을 부여하지 않고, 운영자가 한 번만 다음 단일 명령을 `/etc/sudoers.d/kartsell-deploy`에 등록한다.
```sudoers
kjh2064 ALL=(root) NOPASSWD: /usr/bin/systemctl restart kartsell
```
파일 권한은 `0440`이어야 하며 `visudo -cf /etc/sudoers.d/kartsell-deploy` 검증 후 적용한다. 이후 CI는 비대화형 `sudo -n systemctl restart kartsell`만 사용하므로 배포마다 비밀번호 입력이나 sudo 등록이 필요 없다.
## Deployment guard
워크플로우는 artifact 복사와 DbMigrator 실행 전에 `sudo -n -l`로 위임 존재 여부를 검사한다. 위임이 없으면 운영 DB를 변경하지 않고 exit 77로 종료한다.
## Unknown / Decision Required
- 이 파일을 운영 호스트에 설치할 권한은 root 운영자에게만 있다.
- 설치 후 필요한 증거: `visudo -cf` 결과, `sudo -n -l` 결과, 다음 deploy run의 성공 로그, 서비스 active 상태.
@@ -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.
@@ -35,6 +35,7 @@ public sealed record ModelOperationRequest(
public interface IApprovedModelContextReader
{
Task<ApprovedModelContext?> ReadAsync(string scopeKey, DateTimeOffset asOf, CancellationToken cancellationToken);
Task<ApprovedModelContext?> ReadAsync(Guid modelId, string scopeKey, DateTimeOffset asOf, CancellationToken cancellationToken);
}
public interface IModelScheduleRepository
@@ -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<ApprovedModelContext?> 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<Row>(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<ApprovedModelContext?> ReadAsync(
Guid modelId,
string scopeKey,
DateTimeOffset asOf,
CancellationToken cancellationToken)
{
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
var row = await connection.QuerySingleOrDefaultAsync<Row>(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,