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
@@ -0,0 +1,21 @@
# SCUM Capability Negotiation and Run Acceptance Audit (2026-08-13)
This evidence records a browser-repository audit for task 4.7 and the Platform-side implementation evidence for task 4.8.
## Run acceptance audit
- Local independent Run checkout inspected read-only from the ignored nested checkout: it is on `main...origin/main`, latest commit `8fe6f9b` (`Fix SQLite probe data target mapping`), with only ignored local-debug dirt. The visible history covers schema-probe and data-target materialization work (`6cb6ba3`, `9cc9ab3`, `8fe6f9b`) and does not contain the later generic SQLite-template, typed RCON-template, guarded mutation, or parsed-log-source execution acceptance evidence required by task 4.7.
- Browser-repository handoff evidence files for SQLite template execution, typed RCON template execution, guarded SQLite/XML mutation execution, and log-source tailing were re-read. Each is explicitly a contract handoff only and says it is not Run implementation, deployment, or product acceptance evidence.
- Server-management MCP was used for the remote path: `list_devices`, `test_connection` for `枣庄服务器`, then one bounded read-only PowerShell diagnostic. The diagnostic returned only redacted aggregates: connection OK, `runProcessCount=0`, zero scanned Run roots, and zero marker hits for `sqlite.template-query`, `rcon.template-command`, `sqlite.guarded-mutation`, `log.parsed-events`, duplicate/late terminal markers, and protected RCON/SQL capability markers. It emitted no host paths, command lines, log lines, credentials, sockets, IP/network material, database rows, SQL, XML, RCON text, or player identities.
- Conclusion: task 4.7 remains pending. There is still no independent Run acceptance evidence proving channel priorities, leases, fencing, acknowledgements, idempotency, and late/duplicate terminal-result handling for the new generic capabilities. The schema-probe/data-target evidence recorded earlier remains valid only for the probe path and does not enable DB-backed reads, typed commands, gifts, guarded mutations, or parsed-login ingestion.
## Platform capability negotiation
- Added a read-only Platform capability negotiation path for the active server/plugin/Run endpoint/runtime binding: `GET /api/v1/server-instances/{id}/scum/capabilities`.
- The negotiation evaluates each SCUM manifest gate independently against the bound Run capability list and latest accepted typed terminal evidence for the same server, Run binding, Run endpoint, plugin version, adapter version, database identity, schema fingerprint, and asset digest set.
- The route returns only capability, enabled/disabled state, safe reason code, safe reason, binding identifiers, and evaluation time. It never dispatches Run jobs and never returns terminal rows, SQL, XML, RCON text, host paths, DSNs, sockets, credentials, protected payloads, or raw service content.
- Added regression coverage for compatible schema-probe evidence, compatible `players.read` SQLite-template evidence, missing per-capability evidence, missing protected RCON executor support, stale Run binding rejection, unauthorized access, no forbidden-material leakage, and no dispatch from the capabilities read.
## Verification
- Passed: `go test ./domain ./dto ./service ./api -run 'TestSCUMCapabilityNegotiation|TestSCUMSchemaProbeEndpointQueuesPlatformScheduledDurableJob|TestLegacySCUMEndpointsReturnNotFoundWithoutDispatchingJobs'`.
@@ -138,6 +138,13 @@
- Added focused tests for DTO parsing, validator rejection of raw-line/network/path material, loose max-line bounds, missing parser digest, service lease fencing, and parser digest mismatch rejection. Verification passed: `go test ./dto ./validator ./service -run 'Test(RunJobResultRequestParses(SQLite|TypedRCON|GuardedMutation|ParsedLog)|ValidateSCUM(SQLiteTemplate|TypedRCON|GuardedMutation|ParsedLog)|CoreServiceRunJob(SQLiteTemplate|TypedRCONTemplate|GuardedMutation|ParsedLogBatch)EnvelopeIsFencedToLease)'`.
- Recorded the separately rooted Run handoff prompt in `evidence/run-log-source-tailing-handoff-2026-08-13.md` with positive, directional, and boundary prompts. This is a contract handoff only; no external Run implementation evidence, parsed-login ingestion enablement, or player/session creation acceptance is enabled by this task.
### Run acceptance audit and Platform capability negotiation (2026-08-13)
- Re-audited the browser evidence files, ignored independent Run checkout, and `枣庄服务器` through server-management MCP. The audit found only the prior schema-probe/data-target Run commits and redacted current-service probe evidence; it found no independent Run implementation/deployment/acceptance evidence for generic SQLite-template execution, typed RCON-template execution, guarded SQLite/XML mutation execution, parsed log-source tailing, or their late/duplicate terminal-result behavior. The audit is recorded in `evidence/scum-capability-negotiation-and-run-acceptance-audit-2026-08-13.md`, and task 4.7 remains unchecked.
- Added Platform-side read-only SCUM capability negotiation at `GET /api/v1/server-instances/{id}/scum/capabilities`. It evaluates each manifest gate independently for the active server/plugin/Run endpoint/runtime binding using the bound Run capability list and latest accepted typed terminal evidence for matching binding, adapter, schema fingerprint, database identity, and asset digests.
- The negotiation route returns only safe capability state and reasons, dispatches no Run job, and does not expose terminal rows, SQL, XML, RCON text, host paths, DSNs, sockets, credentials, protected payloads, or raw current-service content.
- Verification passed: `go test ./domain ./dto ./service ./api -run 'TestSCUMCapabilityNegotiation|TestSCUMSchemaProbeEndpointQueuesPlatformScheduledDurableJob|TestLegacySCUMEndpointsReturnNotFoundWithoutDispatchingJobs'`.
- [x] 4.1 Add Platform protocol contracts under `platform/protocol`, API DTOs under `platform/dto`, validation under `platform/validator`, and plugin contracts/assets under `plugins/sdk` and `plugins/schemas`, plus contract documentation/mocks for probes, read-only template execution, typed RCON, guarded SQLite/XML mutation, parsed log events, and terminal result envelopes.
- [x] 4.2 Freeze the generic executor/result contract and hand off a separately authorized Run-repository task for packaged SQLite-template execution with query-only connections, bound parameters, one-statement validation, short busy/operation timeouts, cancellation, and row/result-byte limits.
- [x] 4.3 Require the independent Run task to return typed envelopes containing server/plugin binding, adapter/schema version, template key, asset digest, job identity, observed time, checksum, rows or affected-row count, and stable safe result/error codes.
@@ -145,7 +152,7 @@
- [x] 4.5 Require the independent Run task to implement generic guarded single-row SQLite/XML mutation execution with expected identity/value/checksum guards, a bounded transaction, preserving XML patching, rollback on zero/multiple affected rows, and read-after-write confirmation.
- [x] 4.6 Require the independent Run task to implement or extend generic plugin-declared log-source tailing so cursor persistence, rotation, truncate, restart, partial-line buffering, parser digest fencing, logical event fingerprinting, and replay remain independent of SCUM-specific source paths.
- [ ] 4.7 Verify from the independent Run task's acceptance evidence that control/job/log/artifact priorities, leases, fencing, acknowledgements, idempotency, and late/duplicate terminal-result handling remain intact for the new generic capabilities.
- [ ] 4.8 Add Platform-side capability negotiation so probe, player/squad/vehicle/flag/position reads, typed commands, gifts, and guarded mutations are gated independently for each active Run/plugin/adapter binding.
- [x] 4.8 Add Platform-side capability negotiation so probe, player/squad/vehicle/flag/position reads, typed commands, gifts, and guarded mutations are gated independently for each active Run/plugin/adapter binding.
- [ ] 4.9 After the external Run contract tests/commit/deployment evidence is available, wire immutable assets and digests into generated Run packages and add distribution/contract tests proving Platform sends only template keys, bounded parameters, adapter version, and expected digest.
## 5. Dedicated Platform SCUM Persistence
+1
View File
@@ -103,6 +103,7 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/server-instances/{id}/game-gifts/{catalogId}/revisions", h.serverGameGiftCatalogRevisions)
mux.HandleFunc("/api/v1/server-instances/{id}/game-gift-grants", h.serverGameGiftGrants)
mux.HandleFunc("/api/v1/server-instances/{id}/game-gift-grants/{grantId}/approve", h.serverGameGiftGrantApprove)
mux.HandleFunc("/api/v1/server-instances/{id}/scum/capabilities", h.serverSCUMCapabilities)
mux.HandleFunc("/api/v1/server-instances/{id}/scum/schema-probe", h.serverSCUMSchemaProbe)
mux.HandleFunc("/api/v1/server-instances/{id}/scum/players", h.serverSCUMPlayers)
mux.HandleFunc("/api/v1/server-instances/{id}/scum/squads", h.serverSCUMSquads)
+1 -1
View File
@@ -161,7 +161,7 @@ Server-scoped terminal log streaming (`GET /api/v1/server-instances/{id}/logs/ev
Runtime distribution and client-manager APIs require the current bearer session, server visibility, plugin-declared permissions, complete runtime bindings only for actions that truly depend on external logical bindings, and platform-builder readiness. Run-side lifecycle commands separately require run endpoint capability support and use plugin-declared lifecycle actions without making manual runtime-profile binding a user prerequisite. Responses and audit summaries expose artifact IDs, job IDs, checksums, key generations, fingerprints, status, and redacted `secret://runtime-keys/.../current` refs only. They do not expose raw run keys, client-manager keys, FTP passwords, database DSNs, RCON passwords, host paths, direct sockets, run endpoint private addresses, build workspace paths, or large inline logs.
SCUM product APIs expose only safe local resource rows, capability availability, collected timestamps, and redacted status reasons. Removed legacy SCUM execution routes return `404` and dispatch no job. SCUM APIs never expose SCUM.db SQL text, DB paths, DSNs, RCON command text, raw protected request payloads, run sockets, host paths, or credentials.
SCUM product APIs expose only safe local resource rows, capability availability, collected timestamps, and redacted status reasons. `GET /api/v1/server-instances/{id}/scum/capabilities` returns the Platform-negotiated gate state for the active Run/plugin/adapter binding without dispatching a job. Removed legacy SCUM execution routes return `404` and dispatch no job. SCUM APIs never expose SCUM.db SQL text, DB paths, DSNs, RCON command text, raw protected request payloads, run sockets, host paths, or credentials.
`POST /api/v1/server-instances/workflows/create` requires only the plugin type and server name. A runtime binding may still be maintained internally for advanced logical transports, but browser lifecycle controls must not force operators to choose a runtime profile before start/stop or run-package generation when the plugin deployment/lifecycle declaration is sufficient. Platform builds distributions itself and never needs a registered Run endpoint with `distribution.build` to do so.
+13
View File
@@ -6,6 +6,19 @@ import (
"browser.local/platform/dto"
)
func (h *coreHandlers) serverSCUMCapabilities(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet)
return
}
negotiation, err := h.core.NegotiateSCUMCapabilitiesForSession(bearerToken(r), r.PathValue("id"))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.SCUMCapabilityNegotiationFromDomain(negotiation))
}
func (h *coreHandlers) serverSCUMSchemaProbe(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
+11
View File
@@ -60,6 +60,17 @@ func TestSCUMSchemaProbeEndpointQueuesPlatformScheduledDurableJob(t *testing.T)
router := NewAuthorizedRouterWithCore(core)
unauthorized := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+instance.ID+"/scum/schema-probe", map[string]string{"idempotencyKey": "probe-api-denied"}, "")
assertStatus(t, unauthorized, http.StatusUnauthorized)
capabilities := requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+instance.ID+"/scum/capabilities", ``, auth.SessionID)
assertStatus(t, capabilities, http.StatusOK)
capabilityBody := capabilities.Body.String()
if !strings.Contains(capabilityBody, "schema-probe") || !strings.Contains(capabilityBody, "probeExecutorAvailable") {
t.Fatalf("capability negotiation response missing SCUM gates: %s", capabilityBody)
}
for _, forbidden := range []string{"SCUM.db", "sqlite_master", "SELECT", "C:\\", "secret://", "password"} {
if strings.Contains(capabilityBody, forbidden) {
t.Fatalf("capability negotiation response leaked forbidden material %q: %s", forbidden, capabilityBody)
}
}
recorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+instance.ID+"/scum/schema-probe", map[string]string{"idempotencyKey": "probe-api-current"}, auth.SessionID)
assertStatus(t, recorder, http.StatusAccepted)
body := recorder.Body.String()
+14
View File
@@ -477,6 +477,20 @@ type SCUMCapabilityGate struct {
Evidence SCUMCapabilityEvidence
}
type SCUMCapabilityNegotiation struct {
ServerInstanceID string
RunEndpointID string
RunBindingID string
PluginID string
PluginVersion string
AdapterVersion string
GameVersion string
DatabaseIdentity string
ProbeExecutorAvailable bool
EvaluatedAt time.Time
Gates []SCUMCapabilityGate
}
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 {
+22
View File
@@ -319,6 +319,20 @@ type SCUMCapabilityGateDTO struct {
Reason string `json:"reason"`
}
type SCUMCapabilityNegotiationDTO struct {
ServerInstanceID string `json:"serverInstanceId"`
RunEndpointID string `json:"runEndpointId"`
RunBindingID string `json:"runBindingId,omitempty"`
PluginID string `json:"pluginId"`
PluginVersion string `json:"pluginVersion"`
AdapterVersion string `json:"adapterVersion"`
GameVersion string `json:"gameVersion,omitempty"`
DatabaseIdentity string `json:"databaseIdentity"`
ProbeExecutorAvailable bool `json:"probeExecutorAvailable"`
EvaluatedAt time.Time `json:"evaluatedAt"`
Gates []SCUMCapabilityGateDTO `json:"gates"`
}
func SCUMSchemaProbeRequestToDomain(value SCUMSchemaProbeRequestDTO) domain.SCUMSchemaProbeRequest {
return domain.SCUMSchemaProbeRequest{RequestID: value.RequestID, JobID: value.JobID, Binding: scumBindingIdentityToDomain(value.Binding), Bounds: scumProbeBoundsToDomain(value.Bounds), RequestedAt: value.RequestedAt}
}
@@ -582,6 +596,14 @@ func SCUMCapabilityGateFromDomain(value domain.SCUMCapabilityGate) SCUMCapabilit
return SCUMCapabilityGateDTO{Capability: string(value.Capability), State: string(value.State), Enabled: value.Enabled, ReasonCode: string(value.ReasonCode), Reason: value.Reason}
}
func SCUMCapabilityNegotiationFromDomain(value domain.SCUMCapabilityNegotiation) SCUMCapabilityNegotiationDTO {
gates := make([]SCUMCapabilityGateDTO, len(value.Gates))
for index, gate := range value.Gates {
gates[index] = SCUMCapabilityGateFromDomain(gate)
}
return SCUMCapabilityNegotiationDTO{ServerInstanceID: value.ServerInstanceID, RunEndpointID: value.RunEndpointID, RunBindingID: value.RunBindingID, PluginID: value.PluginID, PluginVersion: value.PluginVersion, AdapterVersion: value.AdapterVersion, GameVersion: value.GameVersion, DatabaseIdentity: value.DatabaseIdentity, ProbeExecutorAvailable: value.ProbeExecutorAvailable, EvaluatedAt: value.EvaluatedAt, Gates: gates}
}
func scumBindingIdentityToDomain(value SCUMBindingIdentityDTO) domain.SCUMBindingIdentity {
return domain.SCUMBindingIdentity{ServerInstanceID: value.ServerInstanceID, RunBindingID: value.RunBindingID, RunEndpointID: value.RunEndpointID, PluginID: value.PluginID, PluginVersion: value.PluginVersion, AdapterVersion: value.AdapterVersion, GameVersion: value.GameVersion, DatabaseIdentity: value.DatabaseIdentity}
}
@@ -15,6 +15,8 @@ Every database-backed SCUM read/write capability is disabled until all of the fo
If any condition fails, APIs and plugin pages receive a safe disabled state such as `probe_missing`, `probe_executor_absent`, `binding_mismatch`, `fingerprint_mismatch`, `digest_mismatch`, `schema_incompatible`, or `evidence_expired`. Disabled states are ordinary availability results, not projection/audit/workflow work items.
Platform evaluates the full capability set through a read-only negotiation step for the active server instance. The negotiation combines the current server/plugin/Run endpoint/runtime binding, the plugin's per-capability requirements, the bound Run capability list, and the latest accepted typed terminal evidence for the same binding. It returns independent gates for schema probe, read-only SQLite-backed player/squad/vehicle/flag/position reads, typed RCON economy/gift commands, and guarded XML mutations. The negotiation route never dispatches a Run job and never treats another Run binding, plugin version, adapter version, schema fingerprint, database identity, or asset digest as compatible evidence.
## Probe request
`SCUMSchemaProbeRequest` is a Platform durable-job payload addressed through the active authenticated Run binding.
+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)
}