feat(scum): gate live data on schema evidence

This commit is contained in:
npc0-hue
2026-08-11 13:28:36 +08:00
parent e5d26e5420
commit a58da47c60
18 changed files with 1265 additions and 3 deletions
+5
View File
@@ -633,6 +633,7 @@ type GamePluginManifest struct {
RemoteAccess GamePluginRemoteAccess
RuntimeProfiles GamePluginRuntimeProfiles
GameClientBridge GameClientBridgeManifest
SCUMLiveData SCUMLiveDataManifest
MapTrajectories *GameMapTrajectoryDeclaration
}
@@ -673,6 +674,7 @@ type GamePlugin struct {
RemoteAccess GamePluginRemoteAccess
RuntimeProfiles GamePluginRuntimeProfiles
GameClientBridge GameClientBridgeManifest
SCUMLiveData SCUMLiveDataManifest
MapTrajectories *GameMapTrajectoryDeclaration
ValidationViolations []string
Status GamePluginStatus
@@ -1078,6 +1080,7 @@ const (
JobCapabilityRemoteRunProcessStart = "remote.run.process.start"
JobCapabilityRemoteRunProcessStop = "remote.run.process.stop"
JobCapabilityRemoteRunDBMySQLQuery = "remote.run.db.mysql.query"
JobCapabilityRemoteRunDBSQLiteProbe = "remote.run.db.sqlite.probe"
JobCapabilityRemoteRunDBSQLiteQuery = "remote.run.db.sqlite.query"
JobCapabilityRemoteRunLogsTransfer = "remote.run.logs.transfer"
JobCapabilityRemoteRunRCONCommand = "remote.run.rcon.command"
@@ -1694,6 +1697,7 @@ func CopyGamePlugin(plugin GamePlugin) GamePlugin {
plugin.RemoteAccess = CopyGamePluginRemoteAccess(plugin.RemoteAccess)
plugin.RuntimeProfiles = CopyGamePluginRuntimeProfiles(plugin.RuntimeProfiles)
plugin.GameClientBridge = CopyGameClientBridgeManifest(plugin.GameClientBridge)
plugin.SCUMLiveData = CopySCUMLiveDataManifest(plugin.SCUMLiveData)
if plugin.MapTrajectories != nil {
value := CopyGameMapTrajectoryDeclaration(*plugin.MapTrajectories)
plugin.MapTrajectories = &value
@@ -1764,6 +1768,7 @@ func CopyGamePluginManifest(manifest GamePluginManifest) GamePluginManifest {
manifest.RemoteAccess = CopyGamePluginRemoteAccess(manifest.RemoteAccess)
manifest.RuntimeProfiles = CopyGamePluginRuntimeProfiles(manifest.RuntimeProfiles)
manifest.GameClientBridge = CopyGameClientBridgeManifest(manifest.GameClientBridge)
manifest.SCUMLiveData = CopySCUMLiveDataManifest(manifest.SCUMLiveData)
if manifest.MapTrajectories != nil {
value := CopyGameMapTrajectoryDeclaration(*manifest.MapTrajectories)
manifest.MapTrajectories = &value
+295
View File
@@ -0,0 +1,295 @@
package domain
import "time"
type SCUMDataCapability string
const (
SCUMDataCapabilitySchemaProbe SCUMDataCapability = "schema-probe"
SCUMDataCapabilityPlayerRead SCUMDataCapability = "players.read"
SCUMDataCapabilityPlayerDetailRead SCUMDataCapability = "player-details.read"
SCUMDataCapabilitySquadRead SCUMDataCapability = "squads.read"
SCUMDataCapabilitySquadMemberRead SCUMDataCapability = "squad-members.read"
SCUMDataCapabilityVehicleRead SCUMDataCapability = "vehicles.read"
SCUMDataCapabilityFlagRead SCUMDataCapability = "flags.read"
SCUMDataCapabilityPositionRead SCUMDataCapability = "positions.read"
SCUMDataCapabilityProfileXMLWrite SCUMDataCapability = "profile-xml.write"
SCUMDataCapabilityEconomyCommand SCUMDataCapability = "economy-command.write"
SCUMDataCapabilityGiftCommand SCUMDataCapability = "gift-command.write"
)
type SCUMCapabilityEvidenceStatus string
const (
SCUMCapabilityEvidenceMissing SCUMCapabilityEvidenceStatus = "missing"
SCUMCapabilityEvidenceCompatible SCUMCapabilityEvidenceStatus = "compatible"
SCUMCapabilityEvidenceIncompatible SCUMCapabilityEvidenceStatus = "incompatible"
SCUMCapabilityEvidenceFailed SCUMCapabilityEvidenceStatus = "failed"
)
type SCUMCapabilityGateState string
const (
SCUMCapabilityGateEnabled SCUMCapabilityGateState = "enabled"
SCUMCapabilityGateDisabled SCUMCapabilityGateState = "disabled"
)
type SCUMSafeErrorCode string
const (
SCUMSafeErrorNone SCUMSafeErrorCode = "none"
SCUMSafeErrorProbeExecutorAbsent SCUMSafeErrorCode = "probe_executor_absent"
SCUMSafeErrorProbeMissing SCUMSafeErrorCode = "probe_missing"
SCUMSafeErrorProbeFailed SCUMSafeErrorCode = "probe_failed"
SCUMSafeErrorSchemaIncompatible SCUMSafeErrorCode = "schema_incompatible"
SCUMSafeErrorBindingMismatch SCUMSafeErrorCode = "binding_mismatch"
SCUMSafeErrorAdapterMismatch SCUMSafeErrorCode = "adapter_mismatch"
SCUMSafeErrorFingerprintMismatch SCUMSafeErrorCode = "fingerprint_mismatch"
SCUMSafeErrorDigestMismatch SCUMSafeErrorCode = "digest_mismatch"
SCUMSafeErrorEvidenceExpired SCUMSafeErrorCode = "evidence_expired"
SCUMSafeErrorInvalidProbePayload SCUMSafeErrorCode = "invalid_probe_payload"
)
type SCUMSafeError struct {
Code SCUMSafeErrorCode
Message string
Retryable bool
}
type SCUMBindingIdentity struct {
ServerInstanceID string
RunBindingID string
RunEndpointID string
PluginID string
PluginVersion string
AdapterVersion string
GameVersion string
DatabaseIdentity string
}
type SCUMSchemaProbeBounds struct {
MaxObjects int
MaxColumnsPerObject int
MaxIndexesPerObject int
MaxForeignKeys int
MaxCardinalityReads int
MaxSampleRows int
TimeoutMS int
MaxResultBytes int
}
func DefaultSCUMSchemaProbeBounds() SCUMSchemaProbeBounds {
return SCUMSchemaProbeBounds{MaxObjects: 256, MaxColumnsPerObject: 128, MaxIndexesPerObject: 64, MaxForeignKeys: 64, MaxCardinalityReads: 64, MaxSampleRows: 3, TimeoutMS: 5000, MaxResultBytes: 512 * 1024}
}
type SCUMSchemaProbeRequest struct {
RequestID string
JobID string
Binding SCUMBindingIdentity
Bounds SCUMSchemaProbeBounds
RequestedAt time.Time
}
type SCUMSchemaProbeDeclaration struct {
Capability string
TargetKey string
Bounds SCUMSchemaProbeBounds
}
type SCUMLiveDataCapabilityGateDeclaration struct {
Capability SCUMDataCapability
Gate SCUMCapabilityGateState
AdapterVersion string
RequiredSchemaFingerprint string
RequiredAssetDigests []string
EvidenceStatus SCUMCapabilityEvidenceStatus
SafeReason string
}
type SCUMLiveDataManifest struct {
SchemaVersion string
Probe SCUMSchemaProbeDeclaration
CapabilityGates []SCUMLiveDataCapabilityGateDeclaration
}
type SCUMSchemaColumnEvidence struct {
NameFingerprint string
DeclaredType string
Nullable *bool
PrimaryKey bool
Ordinal int
}
type SCUMSchemaIndexEvidence struct {
NameFingerprint string
Unique bool
ColumnHashes []string
}
type SCUMSchemaForeignKeyEvidence struct {
FromColumnHash string
ToObjectHash string
ToColumnHash string
}
type SCUMSchemaObjectEvidence struct {
ObjectHash string
Kind string
NameFingerprint string
DeclaredColumns []SCUMSchemaColumnEvidence
Indexes []SCUMSchemaIndexEvidence
ForeignKeys []SCUMSchemaForeignKeyEvidence
ApproximateRows *int64
SampleFingerprints []string
}
type SCUMSchemaProbeResult struct {
RequestID string
JobID string
Binding SCUMBindingIdentity
Status SCUMCapabilityEvidenceStatus
SchemaFingerprint string
ObservedAt time.Time
ResultDigest string
Objects []SCUMSchemaObjectEvidence
SafeError SCUMSafeError
Limits SCUMSchemaProbeBounds
}
type SCUMCapabilityRequirement struct {
Capability SCUMDataCapability
AdapterVersion string
SchemaFingerprint string
AssetDigests []string
}
type SCUMCapabilityEvidence struct {
Capability SCUMDataCapability
Status SCUMCapabilityEvidenceStatus
Binding SCUMBindingIdentity
AdapterVersion string
SchemaFingerprint string
ProbeResultDigest string
AssetDigests []string
ObservedAt time.Time
ExpiresAt time.Time
SafeError SCUMSafeError
}
type SCUMCapabilityGate struct {
Capability SCUMDataCapability
State SCUMCapabilityGateState
Enabled bool
ReasonCode SCUMSafeErrorCode
Reason string
Evidence SCUMCapabilityEvidence
}
func EvaluateSCUMCapabilityGate(requirement SCUMCapabilityRequirement, evidence SCUMCapabilityEvidence, active SCUMBindingIdentity, probeExecutorAvailable bool, now time.Time) SCUMCapabilityGate {
gate := SCUMCapabilityGate{Capability: requirement.Capability, State: SCUMCapabilityGateDisabled, ReasonCode: SCUMSafeErrorProbeMissing, Reason: "current-service evidence is required before this SCUM capability can run"}
if !probeExecutorAvailable {
gate.ReasonCode = SCUMSafeErrorProbeExecutorAbsent
gate.Reason = "bound Run does not expose the generic SQLite schema-probe executor"
return gate
}
if evidence.Status == SCUMCapabilityEvidenceMissing || evidence.Capability == "" {
return gate
}
gate.Evidence = CopySCUMCapabilityEvidence(evidence)
if evidence.Status == SCUMCapabilityEvidenceFailed {
gate.ReasonCode = SCUMSafeErrorProbeFailed
gate.Reason = safeReason(evidence.SafeError.Message, "last schema probe failed")
return gate
}
if evidence.Status == SCUMCapabilityEvidenceIncompatible {
gate.ReasonCode = SCUMSafeErrorSchemaIncompatible
gate.Reason = safeReason(evidence.SafeError.Message, "current schema is incompatible with the plugin adapter")
return gate
}
if evidence.Capability != requirement.Capability {
gate.ReasonCode = SCUMSafeErrorSchemaIncompatible
gate.Reason = "capability evidence does not match the requested SCUM capability"
return gate
}
if !sameSCUMBinding(evidence.Binding, active) {
gate.ReasonCode = SCUMSafeErrorBindingMismatch
gate.Reason = "evidence belongs to a different server, Run binding, plugin, adapter, game, or database identity"
return gate
}
if evidence.AdapterVersion != requirement.AdapterVersion {
gate.ReasonCode = SCUMSafeErrorAdapterMismatch
gate.Reason = "evidence adapter version does not match the plugin requirement"
return gate
}
if evidence.SchemaFingerprint == "" || evidence.SchemaFingerprint != requirement.SchemaFingerprint {
gate.ReasonCode = SCUMSafeErrorFingerprintMismatch
gate.Reason = "schema fingerprint does not match the plugin requirement"
return gate
}
if !containsAllStrings(evidence.AssetDigests, requirement.AssetDigests) {
gate.ReasonCode = SCUMSafeErrorDigestMismatch
gate.Reason = "packaged asset digest does not match the compatible evidence"
return gate
}
if !evidence.ExpiresAt.IsZero() && !now.IsZero() && !now.Before(evidence.ExpiresAt) {
gate.ReasonCode = SCUMSafeErrorEvidenceExpired
gate.Reason = "current-service evidence has expired and must be probed again"
return gate
}
gate.State = SCUMCapabilityGateEnabled
gate.Enabled = true
gate.ReasonCode = SCUMSafeErrorNone
gate.Reason = "current-service evidence matches the versioned plugin adapter"
return gate
}
func CopySCUMCapabilityEvidence(value SCUMCapabilityEvidence) SCUMCapabilityEvidence {
value.AssetDigests = append([]string(nil), value.AssetDigests...)
return value
}
func CopySCUMSchemaProbeResult(value SCUMSchemaProbeResult) SCUMSchemaProbeResult {
value.Objects = append([]SCUMSchemaObjectEvidence(nil), value.Objects...)
for index := range value.Objects {
value.Objects[index].DeclaredColumns = append([]SCUMSchemaColumnEvidence(nil), value.Objects[index].DeclaredColumns...)
value.Objects[index].Indexes = append([]SCUMSchemaIndexEvidence(nil), value.Objects[index].Indexes...)
value.Objects[index].ForeignKeys = append([]SCUMSchemaForeignKeyEvidence(nil), value.Objects[index].ForeignKeys...)
value.Objects[index].SampleFingerprints = append([]string(nil), value.Objects[index].SampleFingerprints...)
for idx := range value.Objects[index].Indexes {
value.Objects[index].Indexes[idx].ColumnHashes = append([]string(nil), value.Objects[index].Indexes[idx].ColumnHashes...)
}
}
return value
}
func CopySCUMLiveDataManifest(value SCUMLiveDataManifest) SCUMLiveDataManifest {
value.CapabilityGates = append([]SCUMLiveDataCapabilityGateDeclaration(nil), value.CapabilityGates...)
for index := range value.CapabilityGates {
value.CapabilityGates[index].RequiredAssetDigests = append([]string(nil), value.CapabilityGates[index].RequiredAssetDigests...)
}
return value
}
func sameSCUMBinding(a SCUMBindingIdentity, b SCUMBindingIdentity) bool {
return a.ServerInstanceID == b.ServerInstanceID && a.RunBindingID == b.RunBindingID && a.RunEndpointID == b.RunEndpointID && a.PluginID == b.PluginID && a.PluginVersion == b.PluginVersion && a.AdapterVersion == b.AdapterVersion && a.GameVersion == b.GameVersion && a.DatabaseIdentity == b.DatabaseIdentity
}
func containsAllStrings(values []string, required []string) bool {
set := map[string]struct{}{}
for _, value := range values {
set[value] = struct{}{}
}
for _, value := range required {
if _, ok := set[value]; !ok {
return false
}
}
return true
}
func safeReason(value string, fallback string) string {
if value == "" {
return fallback
}
return value
}
+78
View File
@@ -0,0 +1,78 @@
package domain
import (
"testing"
"time"
)
func TestSCUMCapabilityGateDefaultsClosedWithoutProbeEvidence(t *testing.T) {
active := scumGateBinding()
requirement := SCUMCapabilityRequirement{Capability: SCUMDataCapabilityPlayerRead, AdapterVersion: "adapter-1", SchemaFingerprint: "schema-1", AssetDigests: []string{"sha256:query"}}
gate := EvaluateSCUMCapabilityGate(requirement, SCUMCapabilityEvidence{}, active, true, time.Now())
if gate.Enabled || gate.State != SCUMCapabilityGateDisabled || gate.ReasonCode != SCUMSafeErrorProbeMissing {
t.Fatalf("expected closed gate without evidence, got %#v", gate)
}
}
func TestSCUMCapabilityGateDefaultsClosedWhenProbeExecutorUnavailable(t *testing.T) {
active := scumGateBinding()
requirement := SCUMCapabilityRequirement{Capability: SCUMDataCapabilityPlayerRead, AdapterVersion: "adapter-1", SchemaFingerprint: "schema-1"}
evidence := SCUMCapabilityEvidence{Capability: SCUMDataCapabilityPlayerRead, Status: SCUMCapabilityEvidenceCompatible, Binding: active, AdapterVersion: "adapter-1", SchemaFingerprint: "schema-1"}
gate := EvaluateSCUMCapabilityGate(requirement, evidence, active, false, time.Now())
if gate.Enabled || gate.ReasonCode != SCUMSafeErrorProbeExecutorAbsent {
t.Fatalf("expected executor gate failure, got %#v", gate)
}
}
func TestSCUMCapabilityGateEnablesOnlyMatchingEvidence(t *testing.T) {
now := time.Date(2026, 8, 11, 12, 0, 0, 0, time.UTC)
active := scumGateBinding()
requirement := SCUMCapabilityRequirement{Capability: SCUMDataCapabilityVehicleRead, AdapterVersion: "adapter-1", SchemaFingerprint: "schema-1", AssetDigests: []string{"sha256:vehicle-query"}}
evidence := SCUMCapabilityEvidence{Capability: SCUMDataCapabilityVehicleRead, Status: SCUMCapabilityEvidenceCompatible, Binding: active, AdapterVersion: "adapter-1", SchemaFingerprint: "schema-1", AssetDigests: []string{"sha256:vehicle-query", "sha256:result-schema"}, ExpiresAt: now.Add(time.Hour)}
gate := EvaluateSCUMCapabilityGate(requirement, evidence, active, true, now)
if !gate.Enabled || gate.State != SCUMCapabilityGateEnabled || gate.ReasonCode != SCUMSafeErrorNone {
t.Fatalf("expected enabled gate, got %#v", gate)
}
}
func TestSCUMCapabilityGateRejectsMismatchedCurrentServiceEvidence(t *testing.T) {
now := time.Date(2026, 8, 11, 12, 0, 0, 0, time.UTC)
active := scumGateBinding()
requirement := SCUMCapabilityRequirement{Capability: SCUMDataCapabilityPositionRead, AdapterVersion: "adapter-1", SchemaFingerprint: "schema-1", AssetDigests: []string{"sha256:positions"}}
evidence := SCUMCapabilityEvidence{Capability: SCUMDataCapabilityPositionRead, Status: SCUMCapabilityEvidenceCompatible, Binding: active, AdapterVersion: "adapter-1", SchemaFingerprint: "schema-1", AssetDigests: []string{"sha256:positions"}, ExpiresAt: now.Add(time.Hour)}
changedBinding := evidence
changedBinding.Binding.DatabaseIdentity = "db-other"
if gate := EvaluateSCUMCapabilityGate(requirement, changedBinding, active, true, now); gate.Enabled || gate.ReasonCode != SCUMSafeErrorBindingMismatch {
t.Fatalf("expected binding mismatch, got %#v", gate)
}
changedFingerprint := evidence
changedFingerprint.SchemaFingerprint = "schema-other"
if gate := EvaluateSCUMCapabilityGate(requirement, changedFingerprint, active, true, now); gate.Enabled || gate.ReasonCode != SCUMSafeErrorFingerprintMismatch {
t.Fatalf("expected fingerprint mismatch, got %#v", gate)
}
changedDigest := evidence
changedDigest.AssetDigests = []string{"sha256:different"}
if gate := EvaluateSCUMCapabilityGate(requirement, changedDigest, active, true, now); gate.Enabled || gate.ReasonCode != SCUMSafeErrorDigestMismatch {
t.Fatalf("expected digest mismatch, got %#v", gate)
}
}
func TestDefaultSCUMSchemaProbeBoundsAreBoundedAndDiagnosticOnly(t *testing.T) {
bounds := DefaultSCUMSchemaProbeBounds()
if bounds.MaxObjects <= 0 || bounds.MaxSampleRows > 3 || bounds.TimeoutMS > 5000 || bounds.MaxResultBytes > 512*1024 {
t.Fatalf("unexpected unsafe default probe bounds: %#v", bounds)
}
}
func scumGateBinding() SCUMBindingIdentity {
return SCUMBindingIdentity{ServerInstanceID: "server-1", RunBindingID: "binding-1", RunEndpointID: "run-1", PluginID: "game.scum", PluginVersion: "0.1.6", AdapterVersion: "adapter-1", GameVersion: "scum-1", DatabaseIdentity: "db-current"}
}