Add SCUM capability negotiation gate

This commit is contained in:
npc0-hue
2026-08-13 15:10:57 +08:00
parent 4f55c71de5
commit d831e4ade9
12 changed files with 350 additions and 2 deletions
+1
View File
@@ -124,6 +124,7 @@ type Core interface {
ListBackupsForSession(string, domain.BackupFilter) ([]domain.BackupRecord, error)
ListRemoteAdapterDeclarationsForSession(string, string) ([]domain.RemoteAdapterDeclaration, error)
RequestRemoteAdapterForSession(string, domain.RemoteAdapterRequest) (domain.RemoteAdapterResult, error)
NegotiateSCUMCapabilitiesForSession(string, string) (domain.SCUMCapabilityNegotiation, error)
RequestSCUMSchemaProbeForSession(string, string, string) (domain.SCUMSchemaProbeRequest, domain.RemoteAdapterResult, error)
GetServerConfigForSession(string, string) (domain.ServerConfig, error)
GetDeclaredFileReadSnapshotForSession(string, string, string) (domain.DeclaredFileReadSnapshot, error)
+135
View File
@@ -17,6 +17,141 @@ const (
scumParsedLogBatchExecutionKind = "log.parsed-events"
)
func (svc *CoreService) NegotiateSCUMCapabilitiesForSession(sessionID, serverInstanceID string) (domain.SCUMCapabilityNegotiation, error) {
instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID)
if err != nil {
return domain.SCUMCapabilityNegotiation{}, err
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
return domain.SCUMCapabilityNegotiation{}, err
}
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
if err != nil {
return domain.SCUMCapabilityNegotiation{}, err
}
adapterVersion := scumSchemaProbeAdapterVersion(plugin.SCUMLiveData)
active := domain.SCUMBindingIdentity{ServerInstanceID: instance.ID, RunEndpointID: instance.RunEndpointID, PluginID: plugin.ID, PluginVersion: plugin.Version, AdapterVersion: adapterVersion, DatabaseIdentity: scumSchemaProbeDatabaseIdentity(plugin.SCUMLiveData.Probe.TargetKey)}
binding, bindingErr := svc.runtimeBindingForServer(instance.ID)
if bindingErr == nil {
binding, bindingErr = normalizeRuntimeBinding(plugin, binding)
}
if bindingErr == nil {
active.RunBindingID = binding.ID
}
probeExecutorAvailable := scumRunCapabilityAvailable(endpoint, domain.SCUMDataCapabilitySchemaProbe)
negotiation := domain.SCUMCapabilityNegotiation{ServerInstanceID: instance.ID, RunEndpointID: instance.RunEndpointID, RunBindingID: active.RunBindingID, PluginID: plugin.ID, PluginVersion: plugin.Version, AdapterVersion: adapterVersion, GameVersion: active.GameVersion, DatabaseIdentity: active.DatabaseIdentity, ProbeExecutorAvailable: probeExecutorAvailable, EvaluatedAt: svc.now()}
evidenceByCapability := svc.latestSCUMCapabilityEvidenceByCapability(instance.ID)
for _, declaration := range plugin.SCUMLiveData.CapabilityGates {
gate := domain.SCUMCapabilityGate{Capability: declaration.Capability, State: domain.SCUMCapabilityGateDisabled, ReasonCode: domain.SCUMSafeErrorProbeMissing, Reason: safeSCUMGateReason(declaration.SafeReason, "current-service evidence is required before this SCUM capability can run")}
if requiredRunCapability := scumRequiredRunCapabilityForDataCapability(declaration.Capability); requiredRunCapability != "" && !containsString(endpoint.Capabilities, requiredRunCapability) {
gate.ReasonCode = domain.SCUMSafeErrorProbeExecutorAbsent
gate.Reason = "bound Run does not expose the generic executor required for this SCUM capability"
negotiation.Gates = append(negotiation.Gates, gate)
continue
}
if bindingErr != nil || binding.Status != domain.RuntimeBindingStatusComplete || binding.PluginVersion != plugin.Version {
gate.ReasonCode = domain.SCUMSafeErrorBindingMismatch
gate.Reason = "active runtime binding is missing, incomplete, or stale for this plugin version"
negotiation.Gates = append(negotiation.Gates, gate)
continue
}
if declaration.Gate != domain.SCUMCapabilityGateEnabled {
gate.ReasonCode = scumReasonCodeForEvidenceStatus(declaration.EvidenceStatus)
negotiation.Gates = append(negotiation.Gates, gate)
continue
}
requirement := domain.SCUMCapabilityRequirement{Capability: declaration.Capability, AdapterVersion: declaration.AdapterVersion, SchemaFingerprint: declaration.RequiredSchemaFingerprint, AssetDigests: domain.CopyStringSlice(declaration.RequiredAssetDigests)}
gate = domain.EvaluateSCUMCapabilityGate(requirement, evidenceByCapability[declaration.Capability], active, probeExecutorAvailable, negotiation.EvaluatedAt)
negotiation.Gates = append(negotiation.Gates, gate)
}
return negotiation, nil
}
func (svc *CoreService) latestSCUMCapabilityEvidenceByCapability(serverInstanceID string) map[domain.SCUMDataCapability]domain.SCUMCapabilityEvidence {
jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: serverInstanceID})
if err != nil {
return map[domain.SCUMDataCapability]domain.SCUMCapabilityEvidence{}
}
evidence := map[domain.SCUMDataCapability]domain.SCUMCapabilityEvidence{}
for _, job := range jobs {
for _, candidate := range scumCapabilityEvidenceFromJob(job) {
current, exists := evidence[candidate.Capability]
if !exists || current.ObservedAt.Before(candidate.ObservedAt) {
evidence[candidate.Capability] = candidate
}
}
}
return evidence
}
func scumCapabilityEvidenceFromJob(job domain.Job) []domain.SCUMCapabilityEvidence {
var evidence []domain.SCUMCapabilityEvidence
if result := job.ExecutionResult.SQLiteSchemaProbe; result != nil {
status := domain.SCUMCapabilityEvidenceFailed
if result.Status == domain.SCUMSchemaProbeStatusSucceeded || result.Status == domain.SCUMCapabilityEvidenceCompatible {
status = domain.SCUMCapabilityEvidenceCompatible
} else if result.Status == domain.SCUMCapabilityEvidenceIncompatible {
status = domain.SCUMCapabilityEvidenceIncompatible
}
evidence = append(evidence, domain.SCUMCapabilityEvidence{Capability: domain.SCUMDataCapabilitySchemaProbe, Status: status, Binding: result.Binding, AdapterVersion: result.Binding.AdapterVersion, SchemaFingerprint: result.SchemaFingerprint, ProbeResultDigest: result.ResultDigest, ObservedAt: result.ObservedAt, SafeError: result.SafeError})
}
if result := job.ExecutionResult.SQLiteTemplate; result != nil {
evidence = append(evidence, domain.SCUMCapabilityEvidence{Capability: result.Capability, Status: scumTerminalEvidenceStatus(result.Status), Binding: result.Binding, AdapterVersion: result.AdapterVersion, SchemaFingerprint: result.SchemaFingerprint, ProbeResultDigest: result.ResultDigest, AssetDigests: []string{result.AssetDigest}, ObservedAt: result.ObservedAt, SafeError: result.SafeError})
}
if result := job.ExecutionResult.RCONTemplate; result != nil {
evidence = append(evidence, domain.SCUMCapabilityEvidence{Capability: result.Capability, Status: scumTerminalEvidenceStatus(result.Status), Binding: result.Binding, AdapterVersion: result.AdapterVersion, SchemaFingerprint: result.SchemaFingerprint, ProbeResultDigest: result.ResultDigest, AssetDigests: []string{result.AssetDigest}, ObservedAt: result.ObservedAt, SafeError: result.SafeError})
}
if result := job.ExecutionResult.GuardedMutation; result != nil {
evidence = append(evidence, domain.SCUMCapabilityEvidence{Capability: result.Capability, Status: scumTerminalEvidenceStatus(result.Status), Binding: result.Binding, AdapterVersion: result.AdapterVersion, SchemaFingerprint: result.SchemaFingerprint, ProbeResultDigest: result.ResultDigest, AssetDigests: []string{result.AssetDigest}, ObservedAt: result.ObservedAt, SafeError: result.SafeError})
}
return evidence
}
func scumTerminalEvidenceStatus(status domain.SCUMTerminalResultStatus) domain.SCUMCapabilityEvidenceStatus {
if status == domain.SCUMTerminalResultSucceeded {
return domain.SCUMCapabilityEvidenceCompatible
}
return domain.SCUMCapabilityEvidenceFailed
}
func scumRunCapabilityAvailable(endpoint domain.RunEndpoint, capability domain.SCUMDataCapability) bool {
return containsString(endpoint.Capabilities, scumRequiredRunCapabilityForDataCapability(capability))
}
func scumRequiredRunCapabilityForDataCapability(capability domain.SCUMDataCapability) string {
switch capability {
case domain.SCUMDataCapabilitySchemaProbe:
return domain.JobCapabilityRemoteRunDBSQLiteProbe
case domain.SCUMDataCapabilityPlayerRead, domain.SCUMDataCapabilityPlayerDetailRead, domain.SCUMDataCapabilitySquadRead, domain.SCUMDataCapabilitySquadMemberRead, domain.SCUMDataCapabilityVehicleRead, domain.SCUMDataCapabilityFlagRead, domain.SCUMDataCapabilityPositionRead:
return domain.JobCapabilityRemoteRunDBSQLiteQuery
case domain.SCUMDataCapabilityEconomyCommand, domain.SCUMDataCapabilityGiftCommand:
return domain.JobCapabilityRemoteRunProtectedRCON
case domain.SCUMDataCapabilityProfileXMLWrite:
return domain.JobCapabilityRemoteRunProtectedSQL
default:
return ""
}
}
func scumReasonCodeForEvidenceStatus(status domain.SCUMCapabilityEvidenceStatus) domain.SCUMSafeErrorCode {
switch status {
case domain.SCUMCapabilityEvidenceFailed:
return domain.SCUMSafeErrorProbeFailed
case domain.SCUMCapabilityEvidenceIncompatible:
return domain.SCUMSafeErrorSchemaIncompatible
default:
return domain.SCUMSafeErrorProbeMissing
}
}
func safeSCUMGateReason(value, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return value
}
func (svc *CoreService) RequestSCUMSchemaProbeForSession(sessionID, serverInstanceID, idempotencyKey string) (domain.SCUMSchemaProbeRequest, domain.RemoteAdapterResult, error) {
idempotencyKey = strings.TrimSpace(idempotencyKey)
if idempotencyKey == "" {
@@ -0,0 +1,121 @@
package service
import (
"strings"
"testing"
"time"
"browser.local/platform/domain"
"browser.local/platform/repo"
)
func TestSCUMCapabilityNegotiationEvaluatesActiveBindingEvidenceIndependently(t *testing.T) {
store := repo.NewMemoryStore()
svc := NewCoreService(store)
now := time.Date(2026, 8, 13, 7, 0, 0, 0, time.UTC)
svc.now = func() time.Time { return now }
sessionID := seedSCUMCapabilityNegotiationFixture(t, svc, store)
binding := scumCapabilityNegotiationBinding()
schema := scumNegotiationDigest("a")
playerAsset := scumNegotiationDigest("b")
if err := store.Jobs().Create(domain.Job{ID: "job-scum-probe", ServerInstanceID: binding.ServerInstanceID, RunEndpointID: binding.RunEndpointID, State: domain.JobStateSucceeded, ExecutionResult: domain.JobExecutionResult{Kind: scumSchemaProbeExecutionKind, SQLiteSchemaProbe: &domain.SCUMSchemaProbeResult{RequestID: "job-scum-probe", JobID: "job-scum-probe", Binding: binding, Status: domain.SCUMSchemaProbeStatusSucceeded, SourceFingerprint: scumNegotiationDigest("c"), SchemaFingerprint: schema, ObservedAt: now.Add(-2 * time.Minute), ResultDigest: scumNegotiationDigest("d"), Limits: domain.DefaultSCUMSchemaProbeBounds(), SafeError: domain.SCUMSafeError{Code: domain.SCUMSafeErrorNone}}}}); err != nil {
t.Fatalf("create probe evidence job: %v", err)
}
if err := store.Jobs().Create(domain.Job{ID: "job-scum-player-query", ServerInstanceID: binding.ServerInstanceID, RunEndpointID: binding.RunEndpointID, State: domain.JobStateSucceeded, ExecutionResult: domain.JobExecutionResult{Kind: scumSQLiteTemplateExecutionKind, SQLiteTemplate: &domain.SCUMSQLiteTemplateResult{RequestID: "job-scum-player-query", JobID: "job-scum-player-query", Binding: binding, Status: domain.SCUMTerminalResultSucceeded, Capability: domain.SCUMDataCapabilityPlayerRead, TargetKey: "scum-database", TemplateKey: "players-read", AdapterVersion: binding.AdapterVersion, SchemaFingerprint: schema, AssetDigest: playerAsset, ParameterDigest: scumNegotiationDigest("e"), SourceFingerprint: scumNegotiationDigest("f"), ObservedAt: now.Add(-time.Minute), ResultDigest: scumNegotiationDigest("1"), RowCount: 0, Limits: domain.DefaultSCUMSQLiteTemplateBounds(), SafeError: domain.SCUMSafeError{Code: domain.SCUMSafeErrorNone}}}}); err != nil {
t.Fatalf("create player evidence job: %v", err)
}
negotiation, err := svc.NegotiateSCUMCapabilitiesForSession(sessionID, binding.ServerInstanceID)
if err != nil {
t.Fatalf("negotiate capabilities: %v", err)
}
gates := map[domain.SCUMDataCapability]domain.SCUMCapabilityGate{}
for _, gate := range negotiation.Gates {
gates[gate.Capability] = gate
}
if !negotiation.ProbeExecutorAvailable || negotiation.RunBindingID != binding.RunBindingID || negotiation.DatabaseIdentity != binding.DatabaseIdentity {
t.Fatalf("unexpected negotiation identity: %+v", negotiation)
}
if gate := gates[domain.SCUMDataCapabilitySchemaProbe]; !gate.Enabled || gate.ReasonCode != domain.SCUMSafeErrorNone {
t.Fatalf("schema probe should be enabled from accepted probe evidence, got %+v", gate)
}
if gate := gates[domain.SCUMDataCapabilityPlayerRead]; !gate.Enabled || gate.ReasonCode != domain.SCUMSafeErrorNone {
t.Fatalf("players.read should be enabled from matching template evidence, got %+v", gate)
}
if gate := gates[domain.SCUMDataCapabilitySquadRead]; gate.Enabled || gate.ReasonCode != domain.SCUMSafeErrorProbeMissing {
t.Fatalf("squads.read should remain independently disabled without template evidence, got %+v", gate)
}
if gate := gates[domain.SCUMDataCapabilityEconomyCommand]; gate.Enabled || gate.ReasonCode != domain.SCUMSafeErrorProbeExecutorAbsent {
t.Fatalf("economy-command.write should remain disabled when Run lacks protected RCON, got %+v", gate)
}
}
func TestSCUMCapabilityNegotiationRejectsEvidenceFromAnotherBinding(t *testing.T) {
store := repo.NewMemoryStore()
svc := NewCoreService(store)
now := time.Date(2026, 8, 13, 7, 30, 0, 0, time.UTC)
svc.now = func() time.Time { return now }
sessionID := seedSCUMCapabilityNegotiationFixture(t, svc, store)
binding := scumCapabilityNegotiationBinding()
stale := binding
stale.RunBindingID = "runtime-binding-old"
if err := store.Jobs().Create(domain.Job{ID: "job-stale-player-query", ServerInstanceID: binding.ServerInstanceID, RunEndpointID: binding.RunEndpointID, State: domain.JobStateSucceeded, ExecutionResult: domain.JobExecutionResult{Kind: scumSQLiteTemplateExecutionKind, SQLiteTemplate: &domain.SCUMSQLiteTemplateResult{RequestID: "job-stale-player-query", JobID: "job-stale-player-query", Binding: stale, Status: domain.SCUMTerminalResultSucceeded, Capability: domain.SCUMDataCapabilityPlayerRead, TargetKey: "scum-database", TemplateKey: "players-read", AdapterVersion: binding.AdapterVersion, SchemaFingerprint: scumNegotiationDigest("a"), AssetDigest: scumNegotiationDigest("b"), ParameterDigest: scumNegotiationDigest("e"), SourceFingerprint: scumNegotiationDigest("f"), ObservedAt: now, ResultDigest: scumNegotiationDigest("1"), RowCount: 0, Limits: domain.DefaultSCUMSQLiteTemplateBounds(), SafeError: domain.SCUMSafeError{Code: domain.SCUMSafeErrorNone}}}}); err != nil {
t.Fatalf("create stale evidence job: %v", err)
}
negotiation, err := svc.NegotiateSCUMCapabilitiesForSession(sessionID, binding.ServerInstanceID)
if err != nil {
t.Fatalf("negotiate capabilities: %v", err)
}
for _, gate := range negotiation.Gates {
if gate.Capability == domain.SCUMDataCapabilityPlayerRead {
if gate.Enabled || gate.ReasonCode != domain.SCUMSafeErrorBindingMismatch {
t.Fatalf("players.read should reject stale binding evidence, got %+v", gate)
}
return
}
}
t.Fatal("players.read gate missing")
}
func seedSCUMCapabilityNegotiationFixture(t *testing.T, svc *CoreService, store *repo.MemoryStore) string {
t.Helper()
if _, err := svc.CreateUser(domain.User{ID: "scum-negotiation-owner", DisplayName: "SCUM Negotiation Owner", Email: "scum-negotiation@example.test", Status: domain.UserStatusActive, Roles: []string{"server-owner"}, PasswordHash: "secret-password"}); err != nil {
t.Fatalf("create user: %v", err)
}
auth, err := svc.LoginUser(domain.UserLogin{Account: "scum-negotiation@example.test", Password: "secret-password"})
if err != nil {
t.Fatalf("login: %v", err)
}
plugin := domain.GamePlugin{ID: "game.scum", Version: "1.0.0", Status: domain.GamePluginStatusInstalled, SCUMLiveData: domain.SCUMLiveDataManifest{SchemaVersion: "1", Probe: domain.SCUMSchemaProbeDeclaration{Capability: domain.JobCapabilityRemoteRunDBSQLiteProbe, TargetKey: "scum-database", Bounds: domain.DefaultSCUMSchemaProbeBounds()}, CapabilityGates: []domain.SCUMLiveDataCapabilityGateDeclaration{
{Capability: domain.SCUMDataCapabilitySchemaProbe, Gate: domain.SCUMCapabilityGateEnabled, AdapterVersion: "adapter-1", RequiredSchemaFingerprint: scumNegotiationDigest("a"), EvidenceStatus: domain.SCUMCapabilityEvidenceCompatible, SafeReason: "schema probe evidence is compatible"},
{Capability: domain.SCUMDataCapabilityPlayerRead, Gate: domain.SCUMCapabilityGateEnabled, AdapterVersion: "adapter-1", RequiredSchemaFingerprint: scumNegotiationDigest("a"), RequiredAssetDigests: []string{scumNegotiationDigest("b")}, EvidenceStatus: domain.SCUMCapabilityEvidenceCompatible, SafeReason: "players query evidence is compatible"},
{Capability: domain.SCUMDataCapabilitySquadRead, Gate: domain.SCUMCapabilityGateEnabled, AdapterVersion: "adapter-1", RequiredSchemaFingerprint: scumNegotiationDigest("a"), RequiredAssetDigests: []string{scumNegotiationDigest("2")}, EvidenceStatus: domain.SCUMCapabilityEvidenceCompatible, SafeReason: "squad query evidence is compatible"},
{Capability: domain.SCUMDataCapabilityEconomyCommand, Gate: domain.SCUMCapabilityGateEnabled, AdapterVersion: "adapter-1", RequiredSchemaFingerprint: scumNegotiationDigest("a"), RequiredAssetDigests: []string{scumNegotiationDigest("3")}, EvidenceStatus: domain.SCUMCapabilityEvidenceCompatible, SafeReason: "economy command evidence is compatible"},
{Capability: domain.SCUMDataCapabilityGiftCommand, Gate: domain.SCUMCapabilityGateDisabled, AdapterVersion: "adapter-1", RequiredSchemaFingerprint: scumNegotiationDigest("a"), EvidenceStatus: domain.SCUMCapabilityEvidenceMissing, SafeReason: "gift command evidence is missing"},
{Capability: domain.SCUMDataCapabilityProfileXMLWrite, Gate: domain.SCUMCapabilityGateDisabled, AdapterVersion: "adapter-1", RequiredSchemaFingerprint: scumNegotiationDigest("a"), EvidenceStatus: domain.SCUMCapabilityEvidenceMissing, SafeReason: "XML mutation evidence is missing"},
}}, RuntimeProfiles: domain.GamePluginRuntimeProfiles{LifecycleProfiles: []domain.RuntimeLifecycleProfile{{Key: "local", Mode: "local-process"}}}}
if err := store.GamePlugins().Create(plugin); err != nil {
t.Fatalf("create plugin: %v", err)
}
endpoint := domain.RunEndpoint{ID: "run-scum-negotiation", DisplayName: "Run SCUM Negotiation", Version: "0.1.0", Status: domain.RunEndpointStatusOnline, Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteProbe, domain.JobCapabilityRemoteRunDBSQLiteQuery}}
if err := store.RunEndpoints().Create(endpoint); err != nil {
t.Fatalf("create endpoint: %v", err)
}
if err := store.ServerInstances().Create(domain.ServerInstance{ID: "server-scum-negotiation", PluginID: plugin.ID, PluginVersion: plugin.Version, RunEndpointID: endpoint.ID, Name: "SCUM Negotiation", OwnerUserID: "scum-negotiation-owner", State: domain.ServerInstanceStateRunning}); err != nil {
t.Fatalf("create server: %v", err)
}
if _, err := svc.UpdateServerRuntimeBindingForSession(auth.SessionID, "server-scum-negotiation", domain.RuntimeBindingUpdate{ProfileKey: "local", Bindings: map[string]string{}}); err != nil {
t.Fatalf("create runtime binding: %v", err)
}
return auth.SessionID
}
func scumCapabilityNegotiationBinding() domain.SCUMBindingIdentity {
return domain.SCUMBindingIdentity{ServerInstanceID: "server-scum-negotiation", RunBindingID: "runtime-binding-server-scum-negotiation", RunEndpointID: "run-scum-negotiation", PluginID: "game.scum", PluginVersion: "1.0.0", AdapterVersion: "adapter-1", DatabaseIdentity: "scum-database"}
}
func scumNegotiationDigest(char string) string {
return "sha256:" + strings.Repeat(char, 64)
}