feat(scum): remove legacy workflow client surfaces

This commit is contained in:
npc0-hue
2026-08-11 14:31:05 +08:00
parent a58da47c60
commit df48d8b283
22 changed files with 59 additions and 654 deletions
+2 -2
View File
@@ -17,7 +17,7 @@ All routes use JSON request and response bodies. Collection routes support `GET`
| Server runtime distribution | n/a | `GET /api/v1/server-instances/{id}/runtime/actions`, `POST /api/v1/server-instances/{id}/run/generate`, `POST /api/v1/server-instances/{id}/run/download`, `POST /api/v1/server-instances/{id}/run/key/reset`, `POST /api/v1/server-instances/{id}/run/update`, `GET /api/v1/server-instances/{id}/run/update`, `POST /api/v1/server-instances/{id}/client-managers/generate`, `POST /api/v1/server-instances/{id}/client-managers/download`, `POST /api/v1/server-instances/{id}/client-managers/key/reset`, `GET /api/v1/server-instances/{id}/dependencies`, `POST /api/v1/server-instances/{id}/dependencies/check`, `POST /api/v1/server-instances/{id}/dependencies/install` | `ServerRuntimeActionsResponse`, `RunDistributionGenerateRequest`, `RunDistributionResponse`, `RunUpdateRequest`, `RunUpdateJobResponse`/`RunUpdateJobListResponse`, `ClientManagerBuildRequest`, `ClientManagerDistributionResponse`, `ClientManagerDownloadRequest`, `ComponentKeyResetRequest`, `ComponentKeyResponse`, `DependencyCatalogResponse`, `DependencyJobRequest` |
| Metrics | `GET /api/v1/metrics/platform`, `GET /api/v1/metrics/server-instances` | n/a | `PlatformResourceUsageResponse`, `ServerMetricsResponse`, `ServerMetricsListResponse` |
| File operations | `POST /api/v1/file-operations/dispatch` | n/a | `FileOperationDispatchRequest`, `FileOperationDispatchResponse` |
| SCUM projections and workflows | n/a | `GET /api/v1/server-instances/{id}/scum/players`, `GET .../scum/squads`, `GET .../scum/squad-members`, `GET .../scum/vehicles`, `GET .../scum/flags`, `GET .../scum/positions`, `GET/POST .../scum/operations`, `POST .../scum/operations/{operationId}/approve`, `GET/POST .../scum/workflows`, `GET .../scum/workflow-steps` | `SCUM*Response`, `SCUMOperationRequestBody`, `SCUMWorkflowCreateRequest`, safe operation/workflow summaries |
| SCUM local resources | n/a | `GET /api/v1/server-instances/{id}/scum/players`, `GET .../scum/squads`, `GET .../scum/squad-members`, `GET .../scum/vehicles`, `GET .../scum/flags`, `GET .../scum/positions`; removed legacy SCUM execution routes return `404` and dispatch no job | `SCUM*Response`, `ErrorResponse` |
| Server administrators | `GET /api/v1/server-instances/{id}/administrators/candidates`, `POST /api/v1/server-instances/{id}/administrators` | `DELETE /api/v1/server-instances/{id}/administrators/{userId}` | `ServerMemberRequest`, `ServerMemberResponse`, `ServerMemberListResponse`, `ServerInstanceResponse` |
| Run endpoints | `GET /api/v1/run/endpoints`, `POST /api/v1/run/endpoints` | `GET /api/v1/run/endpoints/{id}` | `RunEndpointCreateRequest`, `RunEndpointResponse`, `RunEndpointListResponse` |
| Jobs | `GET /api/v1/jobs`, `POST /api/v1/jobs` | `GET /api/v1/jobs/{id}` | `JobCreateRequest`, `JobResponse`, `JobListResponse` |
@@ -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 projections, typed operation/workflow requests, approval status, confirmation status, blocker reasons, and audit-safe summaries. They 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. 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.
+10 -67
View File
@@ -87,27 +87,9 @@ func (h *coreHandlers) serverSCUMPositions(w http.ResponseWriter, r *http.Reques
}
func (h *coreHandlers) serverSCUMOperations(w http.ResponseWriter, r *http.Request) {
serverID := r.PathValue("id")
switch r.Method {
case http.MethodGet:
items, err := h.core.ListSCUMOperationsForSession(bearerToken(r), scumOperationFilterFromRequest(r, serverID))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.SCUMOperationsFromDomain(items))
case http.MethodPost:
request, err := decodeJSON[dto.SCUMOperationRequestBody](r)
if err != nil {
writeDecodeError(w, err)
return
}
operation, err := h.core.RequestSCUMOperationForSession(bearerToken(r), serverID, dto.SCUMOperationRequestBodyToDomain(request))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusCreated, dto.SCUMOperationFromDomain(operation))
case http.MethodGet, http.MethodPost:
writeRemovedSCUMEndpoint(w)
default:
writeMethodNotAllowed(w, "GET, POST")
}
@@ -118,36 +100,13 @@ func (h *coreHandlers) serverSCUMOperationApprove(w http.ResponseWriter, r *http
writeMethodNotAllowed(w, http.MethodPost)
return
}
operation, err := h.core.ApproveSCUMOperationForSession(bearerToken(r), r.PathValue("operationId"))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.SCUMOperationFromDomain(operation))
writeRemovedSCUMEndpoint(w)
}
func (h *coreHandlers) serverSCUMWorkflows(w http.ResponseWriter, r *http.Request) {
serverID := r.PathValue("id")
switch r.Method {
case http.MethodGet:
items, err := h.core.ListSCUMWorkflowsForSession(bearerToken(r), scumWorkflowFilterFromRequest(r, serverID))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.SCUMWorkflowsFromDomain(items))
case http.MethodPost:
request, err := decodeJSON[dto.SCUMWorkflowCreateRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
workflow, err := h.core.CreateSCUMWorkflowForSession(bearerToken(r), serverID, dto.SCUMWorkflowCreateRequestToDomain(request))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusCreated, dto.SCUMWorkflowFromDomain(workflow))
case http.MethodGet, http.MethodPost:
writeRemovedSCUMEndpoint(w)
default:
writeMethodNotAllowed(w, "GET, POST")
}
@@ -158,12 +117,11 @@ func (h *coreHandlers) serverSCUMWorkflowSteps(w http.ResponseWriter, r *http.Re
writeMethodNotAllowed(w, http.MethodGet)
return
}
items, err := h.core.ListSCUMWorkflowStepsForSession(bearerToken(r), scumWorkflowStepFilterFromRequest(r, r.PathValue("id")))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.SCUMWorkflowStepsFromDomain(items))
writeRemovedSCUMEndpoint(w)
}
func writeRemovedSCUMEndpoint(w http.ResponseWriter) {
writeAPIError(w, http.StatusNotFound, errorCodeNotFound, "legacy SCUM endpoint removed; use the local SCUM management APIs", nil)
}
func scumProjectionFilterFromRequest(r *http.Request, serverID string) domain.SCUMProjectionFilter {
@@ -171,21 +129,6 @@ func scumProjectionFilterFromRequest(r *http.Request, serverID string) domain.SC
return domain.SCUMProjectionFilter{ServerInstanceID: serverID, GamePlayerID: query.Get("gamePlayerId"), GamePlayerRecordID: query.Get("gamePlayerRecordId"), UserProfileID: query.Get("userProfileId"), SteamID: query.Get("steamId"), SquadID: query.Get("squadId"), VehicleID: query.Get("vehicleId"), FlagID: query.Get("flagId"), SubjectType: domain.SCUMProjectionSubject(query.Get("subjectType")), QueryKey: query.Get("queryKey"), Freshness: domain.SCUMProjectionFreshness(query.Get("freshness")), Search: query.Get("search"), Limit: boundedQueryLimit(query.Get("limit"), 200)}
}
func scumOperationFilterFromRequest(r *http.Request, serverID string) domain.SCUMOperationRequestFilter {
query := r.URL.Query()
return domain.SCUMOperationRequestFilter{ServerInstanceID: serverID, TemplateKey: query.Get("templateKey"), PlayerID: query.Get("playerId"), RequesterID: query.Get("requesterId"), Status: domain.SCUMWorkflowStepStatus(query.Get("status")), IdempotencyKey: query.Get("idempotencyKey"), Limit: boundedQueryLimit(query.Get("limit"), 100)}
}
func scumWorkflowFilterFromRequest(r *http.Request, serverID string) domain.SCUMWorkflowInstanceFilter {
query := r.URL.Query()
return domain.SCUMWorkflowInstanceFilter{ServerInstanceID: serverID, TemplateKey: query.Get("templateKey"), RequestedBy: query.Get("requestedBy"), Status: domain.SCUMWorkflowStatus(query.Get("status")), IdempotencyKey: query.Get("idempotencyKey"), Limit: boundedQueryLimit(query.Get("limit"), 100)}
}
func scumWorkflowStepFilterFromRequest(r *http.Request, serverID string) domain.SCUMWorkflowStepFilter {
query := r.URL.Query()
return domain.SCUMWorkflowStepFilter{ServerInstanceID: serverID, WorkflowID: query.Get("workflowId"), StepKey: query.Get("stepKey"), Status: domain.SCUMWorkflowStepStatus(query.Get("status")), Limit: boundedQueryLimit(query.Get("limit"), 200)}
}
func boundedQueryLimit(raw string, fallback int) int {
if raw == "" {
return fallback
+13 -55
View File
@@ -13,17 +13,14 @@ import (
"browser.local/platform/service"
)
func TestSCUMProjectionOperationAndWorkflowAPIsExposeSafeTypedSurfaces(t *testing.T) {
func TestSCUMResourceAPIsExposeLocalRowsAndRemovedLegacyEndpoints(t *testing.T) {
store := repo.NewMemoryStore()
core := service.NewCoreService(store)
if _, err := core.CreateUser(domain.User{ID: "scum-api-owner", DisplayName: "SCUM API Owner", Email: "scum-api-owner@example.test", Status: domain.UserStatusActive, Roles: []string{"server-owner"}, PasswordHash: "secret-password"}); err != nil {
t.Fatalf("create owner: %v", err)
}
plugin := validGamePluginRequest().ToDomain()
plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.game-client.command", "server.game-client.read")
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProtectedRCON)
plugin.RuntimeProfiles.TransportProfiles = []domain.RuntimeTransportProfile{{Key: "scum-management", Kind: "rcon", TargetKey: "scum-management", Capabilities: []string{domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProtectedRCON}}}
plugin.GameClientBridge.OperationTemplates = []domain.GameClientBridgeOperationTemplateDeclaration{{Key: "player.fame.set", Title: "Set fame", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, Kind: domain.GameClientBridgeOperationKindRCON, TransportKey: "scum-management", TargetKey: "scum-management", PayloadSchemaRef: "schemas/bridge/player-fame-set.payload.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 2048, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: true, RequiresConfirmation: true}}}
plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.game-client.read")
plugin.GameClientBridge.Retention = domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000}
if _, err := core.CreateGamePlugin(plugin); err != nil {
t.Fatalf("create plugin: %v", err)
@@ -49,23 +46,7 @@ func TestSCUMProjectionOperationAndWorkflowAPIsExposeSafeTypedSurfaces(t *testin
if players.Count != 1 || players.Items[0].GamePlayerID != "steam-api" || players.Items[0].Position.X != 1 {
t.Fatalf("unexpected SCUM players response: %+v", players)
}
operation := postJSONWithAuth[dto.SCUMOperationResponse](t, router, "/api/v1/server-instances/server-scum-api/scum/operations", dto.SCUMOperationRequestBody{TemplateKey: "player.fame.set", PlayerID: "steam-api", Payload: map[string]any{"fame": 12}, Reason: "api typed op", IdempotencyKey: "api-fame-1"}, auth.SessionID)
if operation.Status != string(domain.SCUMWorkflowStepWaiting) || operation.TemplateKey != "player.fame.set" {
t.Fatalf("unexpected SCUM operation response: %+v", operation)
}
operations := getJSONWithAuth[dto.SCUMOperationListResponse](t, router, "/api/v1/server-instances/server-scum-api/scum/operations", auth.SessionID)
if operations.Count != 1 || operations.Items[0].ID != operation.ID {
t.Fatalf("unexpected SCUM operation list: %+v", operations)
}
workflow := postJSONWithAuth[dto.SCUMWorkflowResponse](t, router, "/api/v1/server-instances/server-scum-api/scum/workflows", dto.SCUMWorkflowCreateRequest{TemplateKey: "scum.world-refresh", IdempotencyKey: "api-world-1"}, auth.SessionID)
if workflow.Status != string(domain.SCUMWorkflowQueued) || workflow.TemplateKey != "scum.world-refresh" {
t.Fatalf("unexpected SCUM workflow response: %+v", workflow)
}
steps := getJSONWithAuth[dto.SCUMWorkflowStepListResponse](t, router, "/api/v1/server-instances/server-scum-api/scum/workflow-steps?workflowId="+workflow.ID, auth.SessionID)
if steps.Count == 0 {
t.Fatalf("expected workflow steps: %+v", steps)
}
body, err := json.Marshal([]any{players, operation, operations, workflow, steps})
body, err := json.Marshal(players)
if err != nil {
t.Fatalf("marshal responses: %v", err)
}
@@ -82,10 +63,20 @@ func TestSCUMProjectionOperationAndWorkflowAPIsExposeSafeTypedSurfaces(t *testin
{http.MethodGet, "/api/v1/server-instances/server-scum-api/config"},
{http.MethodPost, "/api/v1/server-instances/server-scum-api/config/diff"},
{http.MethodPost, "/api/v1/server-instances/server-scum-api/config/approve"},
{http.MethodGet, "/api/v1/server-instances/server-scum-api/scum/operations"},
{http.MethodPost, "/api/v1/server-instances/server-scum-api/scum/operations"},
{http.MethodPost, "/api/v1/server-instances/server-scum-api/scum/operations/op-1/approve"},
{http.MethodGet, "/api/v1/server-instances/server-scum-api/scum/workflows"},
{http.MethodPost, "/api/v1/server-instances/server-scum-api/scum/workflows"},
{http.MethodGet, "/api/v1/server-instances/server-scum-api/scum/workflow-steps?workflowId=workflow-1"},
} {
recorder := requestWithAuth(t, router, legacy.method, legacy.path, `{}`, auth.SessionID)
assertStatus(t, recorder, http.StatusNotFound)
}
jobs, err := core.ListJobsForSession(auth.SessionID, domain.JobFilter{ServerInstanceID: "server-scum-api"})
if err != nil || len(jobs) != 0 {
t.Fatalf("removed SCUM endpoints must not dispatch jobs, got jobs=%+v err=%v", jobs, err)
}
}
func TestSCUMAPIsEnforceServerAuthorization(t *testing.T) {
@@ -113,36 +104,3 @@ func TestSCUMAPIsEnforceServerAuthorization(t *testing.T) {
router := NewAuthorizedRouterWithCore(core)
assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/server-scum-authz/scum/players", "", auth.SessionID), http.StatusForbidden, errorCodeForbidden)
}
func TestSCUMAPIsRequirePlatformAdminForDBMutationApproval(t *testing.T) {
store := repo.NewMemoryStore()
core := service.NewCoreService(store)
if _, err := core.CreateUser(domain.User{ID: "scum-api-owner", DisplayName: "SCUM API Owner", Email: "scum-api-owner-mutation@example.test", Status: domain.UserStatusActive, Roles: []string{"server-owner"}, PasswordHash: "secret-password"}); err != nil {
t.Fatal(err)
}
plugin := validGamePluginRequest().ToDomain()
plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.game-client.read", "server.game-client.maintenance")
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunProtectedSQL)
plugin.RuntimeProfiles.TransportProfiles = []domain.RuntimeTransportProfile{{Key: "scum-database", Kind: "sqlite", TargetKey: "scum-database", Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunProtectedSQL}}}
plugin.GameClientBridge.QueryTemplates = []domain.GameClientBridgeQueryTemplateDeclaration{{Key: "scum.player.profile", Title: "Read player profile", Permission: "server.game-client.read", Engine: "sqlite", TransportKey: "scum-database", TargetKey: "scum-database", ParameterSchemaRef: "schemas/bridge/queries/scum-player-profile.parameters.schema.json", ResultSchemaRef: "schemas/bridge/queries/scum-player-profile.result.schema.json", MaxRows: 10, TimeoutSeconds: 15}}
plugin.GameClientBridge.OperationTemplates = []domain.GameClientBridgeOperationTemplateDeclaration{{Key: "player.attribute.855.set", Title: "Set attribute 855", Permission: "server.game-client.maintenance", ApprovalLevel: domain.GameClientBridgeApprovalLevelPlatformAdmin, Kind: domain.GameClientBridgeOperationKindSQLiteMutation, TransportKey: "scum-database", TargetKey: "scum-database", PayloadSchemaRef: "schemas/bridge/player-attribute-855-set.payload.schema.json", ResultSchemaRef: "schemas/bridge/player-attribute-855-set.result.schema.json", ConfirmationSchemaRef: "schemas/bridge/player-attribute-855-set.confirmation.schema.json", TimeoutSeconds: 120, MaxPayloadBytes: 4096, MaxRowsAffected: 1, Mutation: domain.GameClientBridgeOperationMutationDeclaration{FieldKey: "855", TableKey: "prisoner", IdentityKey: "user_profile_id", ValueKey: "value", ConfirmationQueryKey: "scum.player.profile", AllowedValueType: "integer", MinValue: 0, MaxValue: 100000}, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: true, RequiresOfflinePlayer: true, RequiresMaintenanceWindow: true, RequiresBeforeValue: true, RequiresConfirmation: true, BackupRequired: true}}}
plugin.GameClientBridge.Retention = domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000}
if _, err := core.CreateGamePlugin(plugin); err != nil {
t.Fatal(err)
}
endpoint := validRunEndpointRequest().ToDomain()
endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunProtectedSQL)
if _, err := core.CreateRunEndpoint(endpoint); err != nil {
t.Fatal(err)
}
if _, err := core.CreateServerInstance(domain.ServerInstance{ID: "server-scum-mutation-authz", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "SCUM Mutation Authz", OwnerUserID: "scum-api-owner", State: domain.ServerInstanceStateStopped}); err != nil {
t.Fatal(err)
}
auth, err := core.LoginUser(domain.UserLogin{Account: "scum-api-owner-mutation@example.test", Password: "secret-password"})
if err != nil {
t.Fatal(err)
}
router := NewAuthorizedRouterWithCore(core)
operation := postJSONWithAuth[dto.SCUMOperationResponse](t, router, "/api/v1/server-instances/server-scum-mutation-authz/scum/operations", dto.SCUMOperationRequestBody{TemplateKey: "player.attribute.855.set", PlayerID: "steam-api", Payload: map[string]any{"fieldKey": "855", "before": 10, "after": 12, "safetyWindow": "maintenance-2026-08-10", "backupRef": "backup://scum/1"}, Reason: "api typed db op", IdempotencyKey: "api-855-1"}, auth.SessionID)
assertErrorResponse(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-scum-mutation-authz/scum/operations/"+operation.ID+"/approve", map[string]string{}, auth.SessionID), http.StatusForbidden, errorCodeForbidden)
}
-243
View File
@@ -1,243 +0,0 @@
package dto
import (
"time"
"browser.local/platform/domain"
)
type SCUMSafeSummaryBody struct {
Title string `json:"title,omitempty"`
Message string `json:"message,omitempty"`
Details map[string]string `json:"details,omitempty"`
}
type SCUMDataObservationResponse struct {
ID string `json:"id"`
ServerInstanceID string `json:"serverInstanceId"`
PluginID string `json:"pluginId"`
Source string `json:"source"`
QueryKey string `json:"queryKey,omitempty"`
SubjectType string `json:"subjectType,omitempty"`
SubjectID string `json:"subjectId,omitempty"`
Sequence uint64 `json:"sequence"`
Checksum string `json:"checksum,omitempty"`
Status string `json:"status"`
ErrorCode string `json:"errorCode,omitempty"`
SafeSummary SCUMSafeSummaryBody `json:"safeSummary,omitempty"`
ObservedAt time.Time `json:"observedAt"`
ReceivedAt time.Time `json:"receivedAt"`
}
type SCUMProjectionFreshnessBody struct {
Status string `json:"status"`
ObservationID string `json:"observationId,omitempty"`
Source string `json:"source,omitempty"`
QueryKey string `json:"queryKey,omitempty"`
Sequence uint64 `json:"sequence,omitempty"`
Checksum string `json:"checksum,omitempty"`
StaleReason string `json:"staleReason,omitempty"`
ObservedAt time.Time `json:"observedAt,omitempty"`
ReceivedAt time.Time `json:"receivedAt,omitempty"`
}
type SCUMMutationGuardBody struct {
FieldKey string `json:"fieldKey,omitempty"`
Before any `json:"before,omitempty"`
After any `json:"after,omitempty"`
MaxRowsAffected int `json:"maxRowsAffected,omitempty"`
SafetyWindow string `json:"safetyWindow,omitempty"`
BackupRef string `json:"backupRef,omitempty"`
RequiresOfflinePlayer bool `json:"requiresOfflinePlayer,omitempty"`
RequiresMaintenance bool `json:"requiresMaintenance,omitempty"`
RequiresBackup bool `json:"requiresBackup,omitempty"`
}
type SCUMOperationConfirmationBody struct {
Status string `json:"status,omitempty"`
ObservationID string `json:"observationId,omitempty"`
ConfirmedFields map[string]any `json:"confirmedFields,omitempty"`
AffectedRows int `json:"affectedRows,omitempty"`
MutationChecksum string `json:"mutationChecksum,omitempty"`
Checksum string `json:"checksum,omitempty"`
ObservedAt time.Time `json:"observedAt,omitempty"`
SafeSummary SCUMSafeSummaryBody `json:"safeSummary,omitempty"`
}
type SCUMOperationRequestBody struct {
TemplateKey string `json:"templateKey"`
PlayerID string `json:"playerId,omitempty"`
Payload map[string]any `json:"payload,omitempty"`
Guard SCUMMutationGuardBody `json:"guard,omitempty"`
Reason string `json:"reason"`
IdempotencyKey string `json:"idempotencyKey"`
}
type SCUMWorkflowCreateRequest struct {
TemplateKey string `json:"templateKey"`
IdempotencyKey string `json:"idempotencyKey"`
Input map[string]any `json:"input,omitempty"`
}
type SCUMOperationResponse struct {
ID string `json:"id"`
ServerInstanceID string `json:"serverInstanceId"`
PluginID string `json:"pluginId"`
TemplateKey string `json:"templateKey"`
PlayerID string `json:"playerId,omitempty"`
RequesterID string `json:"requesterId,omitempty"`
ApproverID string `json:"approverId,omitempty"`
ApprovalLevel string `json:"approvalLevel"`
Payload map[string]any `json:"payload,omitempty"`
Guard SCUMMutationGuardBody `json:"guard,omitempty"`
Confirmation SCUMOperationConfirmationBody `json:"confirmation,omitempty"`
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
RunJobID string `json:"runJobId,omitempty"`
SafeSummary SCUMSafeSummaryBody `json:"safeSummary,omitempty"`
AuditReferences []string `json:"auditReferences,omitempty"`
CreatedAt time.Time `json:"createdAt"`
ApprovedAt time.Time `json:"approvedAt,omitempty"`
CompletedAt time.Time `json:"completedAt,omitempty"`
UpdatedAt time.Time `json:"updatedAt"`
}
type SCUMOperationListResponse struct {
Items []SCUMOperationResponse `json:"items"`
Count int `json:"count"`
}
type SCUMWorkflowResponse struct {
ID string `json:"id"`
ServerInstanceID string `json:"serverInstanceId"`
PluginID string `json:"pluginId"`
TemplateKey string `json:"templateKey"`
RequestedBy string `json:"requestedBy,omitempty"`
IdempotencyKey string `json:"idempotencyKey,omitempty"`
Status string `json:"status"`
CurrentStepKey string `json:"currentStepKey,omitempty"`
Input map[string]any `json:"input,omitempty"`
SafeSummary SCUMSafeSummaryBody `json:"safeSummary,omitempty"`
BlockerReason string `json:"blockerReason,omitempty"`
AuditReferences []string `json:"auditReferences,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
CompletedAt time.Time `json:"completedAt,omitempty"`
}
type SCUMWorkflowListResponse struct {
Items []SCUMWorkflowResponse `json:"items"`
Count int `json:"count"`
}
type SCUMWorkflowStepResponse struct {
ID string `json:"id"`
WorkflowID string `json:"workflowId"`
ServerInstanceID string `json:"serverInstanceId"`
StepKey string `json:"stepKey"`
DependsOn []string `json:"dependsOn,omitempty"`
Status string `json:"status"`
OperationKey string `json:"operationKey,omitempty"`
QueryTemplateKey string `json:"queryTemplateKey,omitempty"`
Capability string `json:"capability,omitempty"`
TargetKey string `json:"targetKey,omitempty"`
JobID string `json:"jobId,omitempty"`
Attempt int `json:"attempt,omitempty"`
MaxAttempts int `json:"maxAttempts,omitempty"`
MutatesState bool `json:"mutatesState,omitempty"`
Confirmation SCUMOperationConfirmationBody `json:"confirmation,omitempty"`
SafeSummary SCUMSafeSummaryBody `json:"safeSummary,omitempty"`
BlockerReason string `json:"blockerReason,omitempty"`
AuditReferences []string `json:"auditReferences,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
CompletedAt time.Time `json:"completedAt,omitempty"`
}
type SCUMWorkflowStepListResponse struct {
Items []SCUMWorkflowStepResponse `json:"items"`
Count int `json:"count"`
}
func SCUMSafeSummaryFromDomain(value domain.SCUMSafeSummary) SCUMSafeSummaryBody {
value = domain.CopySCUMSafeSummary(value)
return SCUMSafeSummaryBody{Title: value.Title, Message: value.Message, Details: value.Details}
}
func scumSafeSummaryToDomain(value SCUMSafeSummaryBody) domain.SCUMSafeSummary {
return domain.SCUMSafeSummary{Title: value.Title, Message: value.Message, Details: domain.CopyStringMap(value.Details)}
}
func SCUMDataObservationFromDomain(value domain.SCUMDataObservation) SCUMDataObservationResponse {
value = domain.CopySCUMDataObservation(value)
return SCUMDataObservationResponse{ID: value.ID, ServerInstanceID: value.ServerInstanceID, PluginID: value.PluginID, Source: value.Source, QueryKey: value.QueryKey, SubjectType: value.SubjectType, SubjectID: value.SubjectID, Sequence: value.Sequence, Checksum: value.Checksum, Status: string(value.Status), ErrorCode: value.ErrorCode, SafeSummary: SCUMSafeSummaryFromDomain(value.SafeSummary), ObservedAt: value.ObservedAt, ReceivedAt: value.ReceivedAt}
}
func SCUMProjectionFreshnessFromDomain(value domain.SCUMProjectionFreshnessState) SCUMProjectionFreshnessBody {
value = domain.CopySCUMProjectionFreshnessState(value)
return SCUMProjectionFreshnessBody{Status: string(value.Status), ObservationID: value.ObservationID, Source: value.Source, QueryKey: value.QueryKey, Sequence: value.Sequence, Checksum: value.Checksum, StaleReason: value.StaleReason, ObservedAt: value.ObservedAt, ReceivedAt: value.ReceivedAt}
}
func SCUMOperationRequestBodyToDomain(request SCUMOperationRequestBody) domain.SCUMOperationRequest {
return domain.SCUMOperationRequest{TemplateKey: request.TemplateKey, PlayerID: request.PlayerID, Payload: domain.CopyGameClientBridgePayload(request.Payload), Guard: scumMutationGuardToDomain(request.Guard), Reason: request.Reason, IdempotencyKey: request.IdempotencyKey}
}
func SCUMWorkflowCreateRequestToDomain(request SCUMWorkflowCreateRequest) domain.SCUMWorkflowInstance {
return domain.SCUMWorkflowInstance{TemplateKey: request.TemplateKey, IdempotencyKey: request.IdempotencyKey, Input: domain.CopyGameClientBridgePayload(request.Input)}
}
func SCUMOperationFromDomain(value domain.SCUMOperationRequest) SCUMOperationResponse {
value = domain.CopySCUMOperationRequest(value)
return SCUMOperationResponse{ID: value.ID, ServerInstanceID: value.ServerInstanceID, PluginID: value.PluginID, TemplateKey: value.TemplateKey, PlayerID: value.PlayerID, RequesterID: value.RequesterID, ApproverID: value.ApproverID, ApprovalLevel: string(value.ApprovalLevel), Payload: value.Payload, Guard: scumMutationGuardFromDomain(value.Guard), Confirmation: scumOperationConfirmationFromDomain(value.Confirmation), Status: string(value.Status), Reason: value.Reason, RunJobID: value.RunJobID, SafeSummary: SCUMSafeSummaryFromDomain(value.SafeSummary), AuditReferences: value.AuditReferences, CreatedAt: value.CreatedAt, ApprovedAt: value.ApprovedAt, CompletedAt: value.CompletedAt, UpdatedAt: value.UpdatedAt}
}
func SCUMOperationsFromDomain(values []domain.SCUMOperationRequest) SCUMOperationListResponse {
items := make([]SCUMOperationResponse, len(values))
for index, value := range values {
items[index] = SCUMOperationFromDomain(value)
}
return SCUMOperationListResponse{Items: items, Count: len(items)}
}
func SCUMWorkflowFromDomain(value domain.SCUMWorkflowInstance) SCUMWorkflowResponse {
value = domain.CopySCUMWorkflowInstance(value)
return SCUMWorkflowResponse{ID: value.ID, ServerInstanceID: value.ServerInstanceID, PluginID: value.PluginID, TemplateKey: value.TemplateKey, RequestedBy: value.RequestedBy, IdempotencyKey: value.IdempotencyKey, Status: string(value.Status), CurrentStepKey: value.CurrentStepKey, Input: value.Input, SafeSummary: SCUMSafeSummaryFromDomain(value.SafeSummary), BlockerReason: value.BlockerReason, AuditReferences: value.AuditReferences, CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt, CompletedAt: value.CompletedAt}
}
func SCUMWorkflowsFromDomain(values []domain.SCUMWorkflowInstance) SCUMWorkflowListResponse {
items := make([]SCUMWorkflowResponse, len(values))
for index, value := range values {
items[index] = SCUMWorkflowFromDomain(value)
}
return SCUMWorkflowListResponse{Items: items, Count: len(items)}
}
func SCUMWorkflowStepFromDomain(value domain.SCUMWorkflowStep) SCUMWorkflowStepResponse {
value = domain.CopySCUMWorkflowStep(value)
return SCUMWorkflowStepResponse{ID: value.ID, WorkflowID: value.WorkflowID, ServerInstanceID: value.ServerInstanceID, StepKey: value.StepKey, DependsOn: value.DependsOn, Status: string(value.Status), OperationKey: value.OperationKey, QueryTemplateKey: value.QueryTemplateKey, Capability: value.Capability, TargetKey: value.TargetKey, JobID: value.JobID, Attempt: value.Attempt, MaxAttempts: value.MaxAttempts, MutatesState: value.MutatesState, Confirmation: scumOperationConfirmationFromDomain(value.Confirmation), SafeSummary: SCUMSafeSummaryFromDomain(value.SafeSummary), BlockerReason: value.BlockerReason, AuditReferences: value.AuditReferences, CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt, CompletedAt: value.CompletedAt}
}
func SCUMWorkflowStepsFromDomain(values []domain.SCUMWorkflowStep) SCUMWorkflowStepListResponse {
items := make([]SCUMWorkflowStepResponse, len(values))
for index, value := range values {
items[index] = SCUMWorkflowStepFromDomain(value)
}
return SCUMWorkflowStepListResponse{Items: items, Count: len(items)}
}
func scumMutationGuardFromDomain(value domain.SCUMMutationGuard) SCUMMutationGuardBody {
return SCUMMutationGuardBody{FieldKey: value.FieldKey, Before: value.Before, After: value.After, MaxRowsAffected: value.MaxRowsAffected, SafetyWindow: value.SafetyWindow, BackupRef: value.BackupRef, RequiresOfflinePlayer: value.RequiresOfflinePlayer, RequiresMaintenance: value.RequiresMaintenance, RequiresBackup: value.RequiresBackup}
}
func scumMutationGuardToDomain(value SCUMMutationGuardBody) domain.SCUMMutationGuard {
return domain.SCUMMutationGuard{FieldKey: value.FieldKey, Before: value.Before, After: value.After, MaxRowsAffected: value.MaxRowsAffected, SafetyWindow: value.SafetyWindow, BackupRef: value.BackupRef, RequiresOfflinePlayer: value.RequiresOfflinePlayer, RequiresMaintenance: value.RequiresMaintenance, RequiresBackup: value.RequiresBackup}
}
func scumOperationConfirmationFromDomain(value domain.SCUMOperationConfirmation) SCUMOperationConfirmationBody {
value = domain.CopySCUMOperationConfirmation(value)
return SCUMOperationConfirmationBody{Status: value.Status, ObservationID: value.ObservationID, ConfirmedFields: value.ConfirmedFields, AffectedRows: value.AffectedRows, MutationChecksum: value.MutationChecksum, Checksum: value.Checksum, ObservedAt: value.ObservedAt, SafeSummary: SCUMSafeSummaryFromDomain(value.SafeSummary)}
}
func scumOperationConfirmationToDomain(value SCUMOperationConfirmationBody) domain.SCUMOperationConfirmation {
return domain.SCUMOperationConfirmation{Status: value.Status, ObservationID: value.ObservationID, ConfirmedFields: domain.CopyGameClientBridgePayload(value.ConfirmedFields), AffectedRows: value.AffectedRows, MutationChecksum: value.MutationChecksum, Checksum: value.Checksum, ObservedAt: value.ObservedAt, SafeSummary: scumSafeSummaryToDomain(value.SafeSummary)}
}
+6 -6
View File
@@ -431,12 +431,12 @@ func (svc *CoreService) applySCUMSQLiteMutationApprovalGate(operation domain.SCU
state, err := svc.latestSCUMPlayerLiveState(operation.ServerInstanceID, operation.PlayerID)
if err != nil {
if err == repo.ErrNotFound {
return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "等待真实玩家投影", "需要先从当前服务的登录日志或 SCUM.db 读取玩家数据。")
return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "等待当前玩家数据", "需要先从当前服务的登录日志或 SCUM.db 读取玩家数据。")
}
return domain.SCUMOperationRequest{}, false, err
}
if state.Freshness.Status != domain.SCUMProjectionFresh {
return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "等待新鲜投影", "玩家投影不是 fresh,需先刷新 SCUM.db/readback。")
return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "等待当前数据", "玩家数据还未通过当前服务读回确认。")
}
if template.Safety.RequiresOfflinePlayer && state.Online {
return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "等待玩家离线", "DB-only 玩家字段修改必须等玩家离线或进入维护窗口。")
@@ -449,10 +449,10 @@ func (svc *CoreService) applySCUMSQLiteMutationApprovalGate(operation domain.SCU
}
current, ok := scumCurrentMutationFieldValue(state, operation.Guard.FieldKey)
if !ok {
return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "等待字段读回", "当前投影没有该 DB-only 字段,需先执行确认查询。")
return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "等待字段读回", "当前本地数据没有该 DB-only 字段,需先完成确认查询。")
}
if !scumScalarEqual(current, operation.Guard.Before) {
return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepBlocked, "before value 已过期", "当前投影值与审批时 before guard 不一致,已阻止写入。")
return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepBlocked, "before value 已过期", "当前字段值与提交时 before guard 不一致,已阻止写入。")
}
return domain.CopySCUMOperationRequest(operation), true, nil
}
@@ -624,7 +624,7 @@ func scumSQLiteMutationSafeSummary(templateKey, playerID string, guard domain.SC
if guard.BackupRef != "" {
details["backupRef"] = guard.BackupRef
}
return domain.SCUMSafeSummary{Title: "Typed SCUM DB mutation", Message: "Run executes this through a declared mutation template with before-value and row-bound guards; raw SQL is not stored.", Details: details}
return domain.SCUMSafeSummary{Title: "Declared SCUM DB mutation", Message: "Run executes this through a declared mutation template with before-value and row-bound guards; raw SQL is not stored.", Details: details}
}
func operationInteger(payload map[string]any, keys ...string) (int64, bool) {
@@ -757,7 +757,7 @@ func operationSafeSummary(templateKey, playerID string, payload map[string]any)
if amount, ok := operationInteger(payload, "fame", "amount", "balance", "value", "normalBalance", "goldBalance"); ok {
details["value"] = fmt.Sprintf("%d", amount)
}
return domain.SCUMSafeSummary{Title: "Typed SCUM operation", Message: "RCON text is generated server-side and is not stored in the operation record.", Details: details}
return domain.SCUMSafeSummary{Title: "Declared SCUM action", Message: "RCON text is generated server-side and is not stored in the local record.", Details: details}
}
func scumOperationTemplate(plugin domain.GamePlugin, key string) (domain.GameClientBridgeOperationTemplateDeclaration, bool) {
+1 -1
View File
@@ -46,7 +46,7 @@ func (svc *CoreService) ApplySCUMObservationResult(result domain.SCUMObservation
if result.Status == domain.SCUMObservationAccepted && !latest.ObservedAt.IsZero() && scumObservationOlder(result, latest) {
result.Status = domain.SCUMObservationStale
result.ErrorCode = "older_observation"
result.SafeSummary = domain.SCUMSafeSummary{Title: "旧观察已忽略", Message: "Run 返回的 SCUM.db 观察早于当前本地投影,未覆盖 last-known-good 数据。"}
result.SafeSummary = domain.SCUMSafeSummary{Title: "旧数据已忽略", Message: "Run 返回的 SCUM.db 数据早于当前本地记录,未覆盖 last-known-good 数据。"}
}
observation := domain.SCUMDataObservation{ID: scumObservationID(result), ServerInstanceID: result.ServerInstanceID, PluginID: result.PluginID, Source: result.Source, QueryKey: result.QueryKey, Sequence: result.Sequence, Checksum: result.Checksum, Status: result.Status, ErrorCode: result.ErrorCode, SafeSummary: result.SafeSummary, ObservedAt: result.ObservedAt, ReceivedAt: result.ReceivedAt}
if err := svc.upsertSCUMObservation(observation); err != nil {
+9 -9
View File
@@ -58,7 +58,7 @@ func (svc *CoreService) CreateSCUMWorkflowForSession(sessionID, serverID string,
return domain.SCUMWorkflowInstance{}, err
}
stamp := svc.now()
workflow := domain.SCUMWorkflowInstance{ID: "scum-workflow-" + fingerprintID(serverID, request.IdempotencyKey), ServerInstanceID: serverID, PluginID: plugin.ID, TemplateKey: template.Key, RequestedBy: user.ID, IdempotencyKey: request.IdempotencyKey, Status: domain.SCUMWorkflowQueued, Input: domain.CopyGameClientBridgePayload(request.Input), SafeSummary: domain.SCUMSafeSummary{Title: template.Title, Message: "SCUM workflow queued with typed steps and safe summaries."}, CreatedAt: stamp, UpdatedAt: stamp}
workflow := domain.SCUMWorkflowInstance{ID: "scum-workflow-" + fingerprintID(serverID, request.IdempotencyKey), ServerInstanceID: serverID, PluginID: plugin.ID, TemplateKey: template.Key, RequestedBy: user.ID, IdempotencyKey: request.IdempotencyKey, Status: domain.SCUMWorkflowQueued, Input: domain.CopyGameClientBridgePayload(request.Input), SafeSummary: domain.SCUMSafeSummary{Title: template.Title, Message: "SCUM background sequence queued with declared steps and safe summaries."}, CreatedAt: stamp, UpdatedAt: stamp}
if err := svc.store.SCUMWorkflowInstances().Create(workflow); err != nil {
return domain.SCUMWorkflowInstance{}, err
}
@@ -199,7 +199,7 @@ func (svc *CoreService) RetrySCUMWorkflowStep(stepID string) (domain.SCUMWorkflo
return domain.SCUMWorkflowStep{}, validationError("SCUM workflow step retry limit reached")
}
if step.MutatesState && step.Status == domain.SCUMWorkflowStepUnknown && step.Confirmation.Status != "confirmed" {
step.SafeSummary = domain.SCUMSafeSummary{Title: "确认后才能重试", Message: "State-changing SCUM step is unknown; workflow must run confirmation/readback before retry to avoid duplicate effects."}
step.SafeSummary = domain.SCUMSafeSummary{Title: "确认后才能重试", Message: "State-changing SCUM step is unknown; confirmation/readback must complete before retry to avoid duplicate effects."}
step.UpdatedAt = svc.now()
if err := svc.store.SCUMWorkflowSteps().Update(step); err != nil {
return domain.SCUMWorkflowStep{}, err
@@ -380,14 +380,14 @@ func scumWorkflowTemplates() map[string]scumWorkflowTemplateDefinition {
protectedSQL := domain.JobCapabilityRemoteRunProtectedSQL
rcon := domain.JobCapabilityRemoteRunRCONCommand
return map[string]scumWorkflowTemplateDefinition{
"scum.bootstrap-real-data": {Key: "scum.bootstrap-real-data", Title: "Bootstrap SCUM real data", Steps: []scumWorkflowStepDefinition{{Key: "verify-run-binding", Capability: read, TargetKey: "scum-database", Summary: "Verify run binding and SCUM.db query capability."}, {Key: "schema-probe", DependsOn: []string{"verify-run-binding"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.schema.probe", Summary: "Probe SCUM.db schema before projection refresh."}, {Key: "login-cursor", DependsOn: []string{"schema-probe"}, Capability: logs, TargetKey: "scum-login", Summary: "Initialize login log observation cursor."}}},
"scum.bootstrap-real-data": {Key: "scum.bootstrap-real-data", Title: "Bootstrap SCUM data", Steps: []scumWorkflowStepDefinition{{Key: "verify-run-binding", Capability: read, TargetKey: "scum-database", Summary: "Verify run binding and SCUM.db query capability."}, {Key: "schema-probe", DependsOn: []string{"verify-run-binding"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.schema.probe", Summary: "Probe SCUM.db schema before local sync."}, {Key: "login-cursor", DependsOn: []string{"schema-probe"}, Capability: logs, TargetKey: "scum-login", Summary: "Initialize login log cursor."}}},
"scum.player-refresh": {Key: "scum.player-refresh", Title: "Refresh SCUM player", Steps: []scumWorkflowStepDefinition{{Key: "login-evidence", Capability: logs, TargetKey: "scum-login", Summary: "Sync login/logout evidence."}, {Key: "player-profile", DependsOn: []string{"login-evidence"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.player.profile", Summary: "Read player profile/economy facts."}, {Key: "position-read", DependsOn: []string{"player-profile"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.positions", Summary: "Read current player coordinates."}}},
"scum.world-refresh": {Key: "scum.world-refresh", Title: "Refresh SCUM world", Steps: []scumWorkflowStepDefinition{{Key: "squad-read", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.squads", MaxAttempts: 2, Summary: "Refresh squads."}, {Key: "vehicle-read", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.vehicles", MaxAttempts: 2, Summary: "Refresh vehicles."}, {Key: "flag-read", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.flags", MaxAttempts: 2, Summary: "Refresh flags."}, {Key: "position-read", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.positions", MaxAttempts: 2, Summary: "Refresh map positions."}}},
"scum.player-correction": {Key: "scum.player-correction", Title: "SCUM player correction", Steps: []scumWorkflowStepDefinition{{Key: "safety-check", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.player.profile", Summary: "Verify current projection, before value, offline state, and backup evidence."}, {Key: "apply-operation", DependsOn: []string{"safety-check"}, Capability: protectedSQL, TargetKey: "scum-database", OperationKey: "player.attribute.855.set", MutatesState: true, Summary: "Apply the approved typed operation through Run."}, {Key: "confirmation-read", DependsOn: []string{"apply-operation"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.player.profile", Summary: "Confirm the requested value by readback."}}},
"scum.gift-delivery": {Key: "scum.gift-delivery", Title: "SCUM gift delivery", Steps: []scumWorkflowStepDefinition{{Key: "eligibility-check", Summary: "Evaluate gift eligibility and idempotency."}, {Key: "deliver-reward", DependsOn: []string{"eligibility-check"}, Capability: rcon, TargetKey: "scum-management", OperationKey: "reward.deliver", MutatesState: true, MaxAttempts: 2, Summary: "Deliver approved reward through typed operation."}, {Key: "notify-player", DependsOn: []string{"deliver-reward"}, Capability: rcon, TargetKey: "scum-management", OperationKey: "player.notify", MutatesState: true, Summary: "Notify the player after delivery."}, {Key: "confirmation-read", DependsOn: []string{"notify-player"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.player.profile", Summary: "Confirm grant state/readback before marking delivered."}}},
"scum.territory-audit": {Key: "scum.territory-audit", Title: "SCUM territory audit", Steps: []scumWorkflowStepDefinition{{Key: "squad-roster", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.squad-members", Summary: "Refresh squad rosters."}, {Key: "flag-ownership", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.flags", Summary: "Refresh flag ownership."}, {Key: "risk-signal", DependsOn: []string{"squad-roster", "flag-ownership"}, Summary: "Project stale owner/member risk signals."}}},
"scum.vehicle-audit": {Key: "scum.vehicle-audit", Title: "SCUM vehicle audit", Steps: []scumWorkflowStepDefinition{{Key: "vehicle-read", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.vehicles", Summary: "Refresh vehicle inventory."}, {Key: "vehicle-map", DependsOn: []string{"vehicle-read"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.positions", Summary: "Refresh vehicle map overlays."}}},
"scum.ai-assist": {Key: "scum.ai-assist", Title: "SCUM AI assist", Steps: []scumWorkflowStepDefinition{{Key: "collect-allowed-fields", Summary: "Collect plugin-declared config fields and workflow inputs."}, {Key: "draft-review", DependsOn: []string{"collect-allowed-fields"}, Summary: "Create a reviewable typed diff or workflow draft."}, {Key: "approved-dispatch", DependsOn: []string{"draft-review"}, MutatesState: true, Summary: "Dispatch only after human approval through typed paths."}}},
"scum.product-cleanup": {Key: "scum.product-cleanup", Title: "SCUM product cleanup", Steps: []scumWorkflowStepDefinition{{Key: "remove-raw-routes", Summary: "Remove raw logs, terminal, config, and operation-history product routes."}, {Key: "publish-safe-status", DependsOn: []string{"remove-raw-routes"}, Summary: "Route users to safe workflow/status surfaces."}}},
"scum.player-correction": {Key: "scum.player-correction", Title: "SCUM player correction", Steps: []scumWorkflowStepDefinition{{Key: "safety-check", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.player.profile", Summary: "Verify current local data, before value, offline state, and backup evidence."}, {Key: "apply-operation", DependsOn: []string{"safety-check"}, Capability: protectedSQL, TargetKey: "scum-database", OperationKey: "player.attribute.855.set", MutatesState: true, Summary: "Apply the approved declared action through Run."}, {Key: "confirmation-read", DependsOn: []string{"apply-operation"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.player.profile", Summary: "Confirm the requested value by readback."}}},
"scum.gift-delivery": {Key: "scum.gift-delivery", Title: "SCUM gift delivery", Steps: []scumWorkflowStepDefinition{{Key: "eligibility-check", Summary: "Evaluate gift eligibility and idempotency."}, {Key: "deliver-reward", DependsOn: []string{"eligibility-check"}, Capability: rcon, TargetKey: "scum-management", OperationKey: "reward.deliver", MutatesState: true, MaxAttempts: 2, Summary: "Deliver approved reward through the declared action."}, {Key: "notify-player", DependsOn: []string{"deliver-reward"}, Capability: rcon, TargetKey: "scum-management", OperationKey: "player.notify", MutatesState: true, Summary: "Notify the player after delivery."}, {Key: "confirmation-read", DependsOn: []string{"notify-player"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.player.profile", Summary: "Confirm grant state/readback before marking delivered."}}},
"scum.territory-audit": {Key: "scum.territory-audit", Title: "SCUM territory check", Steps: []scumWorkflowStepDefinition{{Key: "squad-roster", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.squad-members", Summary: "Refresh squad rosters."}, {Key: "flag-ownership", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.flags", Summary: "Refresh flag ownership."}, {Key: "risk-signal", DependsOn: []string{"squad-roster", "flag-ownership"}, Summary: "Compare owner/member consistency signals."}}},
"scum.vehicle-audit": {Key: "scum.vehicle-audit", Title: "SCUM vehicle check", Steps: []scumWorkflowStepDefinition{{Key: "vehicle-read", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.vehicles", Summary: "Refresh vehicle inventory."}, {Key: "vehicle-map", DependsOn: []string{"vehicle-read"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.positions", Summary: "Refresh vehicle map overlays."}}},
"scum.ai-assist": {Key: "scum.ai-assist", Title: "SCUM AI assist", Steps: []scumWorkflowStepDefinition{{Key: "collect-allowed-fields", Summary: "Collect plugin-declared config fields and player draft inputs."}, {Key: "draft-review", DependsOn: []string{"collect-allowed-fields"}, Summary: "Create a reviewable config diff or named-field draft."}, {Key: "approved-dispatch", DependsOn: []string{"draft-review"}, MutatesState: true, Summary: "Dispatch only after human confirmation through declared paths."}}},
"scum.product-cleanup": {Key: "scum.product-cleanup", Title: "SCUM product cleanup", Steps: []scumWorkflowStepDefinition{{Key: "remove-raw-routes", Summary: "Remove raw logs, terminal, config, and operation-history product routes."}, {Key: "publish-safe-status", DependsOn: []string{"remove-raw-routes"}, Summary: "Route users to safe local status surfaces."}}},
}
}