Freeze SCUM log-source tailing run contract
This commit is contained in:
@@ -1206,6 +1206,7 @@ type JobExecutionResult struct {
|
||||
SQLiteTemplate *SCUMSQLiteTemplateResult
|
||||
RCONTemplate *SCUMTypedRCONTemplateResult
|
||||
GuardedMutation *SCUMGuardedMutationResult
|
||||
ParsedLogBatch *SCUMParsedLogBatchResult
|
||||
ServerDeploymentEvidence *ServerDeploymentEvidence
|
||||
DeploymentReceipt *ServerDeploymentExecutionReceipt
|
||||
}
|
||||
@@ -2058,6 +2059,7 @@ func CopyJob(job Job) Job {
|
||||
job.ExecutionResult.SQLiteTemplate = CopySCUMSQLiteTemplateResultPtr(job.ExecutionResult.SQLiteTemplate)
|
||||
job.ExecutionResult.RCONTemplate = CopySCUMTypedRCONTemplateResultPtr(job.ExecutionResult.RCONTemplate)
|
||||
job.ExecutionResult.GuardedMutation = CopySCUMGuardedMutationResultPtr(job.ExecutionResult.GuardedMutation)
|
||||
job.ExecutionResult.ParsedLogBatch = CopySCUMParsedLogBatchResultPtr(job.ExecutionResult.ParsedLogBatch)
|
||||
job.ExecutionResult.ServerDeploymentEvidence = CopyServerDeploymentEvidence(job.ExecutionResult.ServerDeploymentEvidence)
|
||||
job.ExecutionResult.DeploymentReceipt = CopyServerDeploymentExecutionReceipt(job.ExecutionResult.DeploymentReceipt)
|
||||
if job.ExecutionInput.Deployment != nil {
|
||||
|
||||
@@ -166,6 +166,44 @@ func DefaultSCUMGuardedMutationBounds() SCUMGuardedMutationBounds {
|
||||
return SCUMGuardedMutationBounds{MaxPayloadBytes: 4096, TimeoutMS: 5000, BusyTimeoutMS: 250, MaxReadbackBytes: 16 * 1024, MaxAffectedRows: 1}
|
||||
}
|
||||
|
||||
type SCUMParsedLogBatchBounds struct {
|
||||
MaxEvents int
|
||||
MaxPayloadBytes int
|
||||
MaxLineBytes int
|
||||
MaxResultBytes int
|
||||
}
|
||||
|
||||
func DefaultSCUMParsedLogBatchBounds() SCUMParsedLogBatchBounds {
|
||||
return SCUMParsedLogBatchBounds{MaxEvents: 256, MaxPayloadBytes: 16 * 1024, MaxLineBytes: 4096, MaxResultBytes: 256 * 1024}
|
||||
}
|
||||
|
||||
type SCUMLogTailState string
|
||||
|
||||
const (
|
||||
SCUMLogTailAdvanced SCUMLogTailState = "advanced"
|
||||
SCUMLogTailRotated SCUMLogTailState = "rotated"
|
||||
SCUMLogTailTruncated SCUMLogTailState = "truncated"
|
||||
SCUMLogTailRestarted SCUMLogTailState = "restarted"
|
||||
SCUMLogTailPartial SCUMLogTailState = "partial-buffered"
|
||||
SCUMLogTailReplayed SCUMLogTailState = "replayed"
|
||||
)
|
||||
|
||||
type SCUMParsedLogCursor struct {
|
||||
SourceIdentityDigest string
|
||||
StreamGeneration string
|
||||
Sequence uint64
|
||||
}
|
||||
|
||||
type SCUMParsedLogEvent struct {
|
||||
EventType string
|
||||
OccurredAt time.Time
|
||||
Cursor SCUMParsedLogCursor
|
||||
LogicalEventDigest string
|
||||
EventDigest string
|
||||
PayloadDigest string
|
||||
Payload map[string]any
|
||||
}
|
||||
|
||||
type SCUMSchemaProbeRequest struct {
|
||||
RequestID string
|
||||
JobID string
|
||||
@@ -384,6 +422,32 @@ type SCUMGuardedMutationResult struct {
|
||||
Limits SCUMGuardedMutationBounds
|
||||
}
|
||||
|
||||
type SCUMParsedLogBatchResult struct {
|
||||
RequestID string
|
||||
JobID string
|
||||
Binding SCUMBindingIdentity
|
||||
Status SCUMTerminalResultStatus
|
||||
SourceKey string
|
||||
StreamKey string
|
||||
ParserKey string
|
||||
ParserVersion string
|
||||
AdapterVersion string
|
||||
AssetDigest string
|
||||
ParserDigest string
|
||||
ObservedAt time.Time
|
||||
ResultDigest string
|
||||
FirstCursor SCUMParsedLogCursor
|
||||
LastCursor SCUMParsedLogCursor
|
||||
TailState SCUMLogTailState
|
||||
PartialLineBuffered bool
|
||||
Replay bool
|
||||
EventCount int
|
||||
Events []SCUMParsedLogEvent
|
||||
SafeSummary string
|
||||
SafeError SCUMSafeError
|
||||
Limits SCUMParsedLogBatchBounds
|
||||
}
|
||||
|
||||
type SCUMCapabilityRequirement struct {
|
||||
Capability SCUMDataCapability
|
||||
AdapterVersion string
|
||||
@@ -528,6 +592,27 @@ func CopySCUMGuardedMutationResultPtr(value *SCUMGuardedMutationResult) *SCUMGua
|
||||
return ©
|
||||
}
|
||||
|
||||
func CopySCUMParsedLogBatchResultPtr(value *SCUMParsedLogBatchResult) *SCUMParsedLogBatchResult {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
copy := *value
|
||||
copy.Events = CopySCUMParsedLogEvents(value.Events)
|
||||
return ©
|
||||
}
|
||||
|
||||
func CopySCUMParsedLogEvents(events []SCUMParsedLogEvent) []SCUMParsedLogEvent {
|
||||
if events == nil {
|
||||
return nil
|
||||
}
|
||||
copy := make([]SCUMParsedLogEvent, len(events))
|
||||
for index, event := range events {
|
||||
copy[index] = event
|
||||
copy[index].Payload = CopySCUMValueMap(event.Payload)
|
||||
}
|
||||
return copy
|
||||
}
|
||||
|
||||
func CopySCUMValueMap(value map[string]any) map[string]any {
|
||||
if value == nil {
|
||||
return nil
|
||||
|
||||
@@ -260,6 +260,7 @@ type RunJobExecutionResultBody struct {
|
||||
SQLiteTemplate *SCUMSQLiteTemplateResultDTO `json:"sqliteTemplate,omitempty"`
|
||||
RCONTemplate *SCUMTypedRCONTemplateResultDTO `json:"rconTemplate,omitempty"`
|
||||
GuardedMutation *SCUMGuardedMutationResultDTO `json:"guardedMutation,omitempty"`
|
||||
ParsedLogBatch *SCUMParsedLogBatchResultDTO `json:"parsedLogBatch,omitempty"`
|
||||
ServerDeploymentEvidence *ServerDeploymentEvidenceBody `json:"serverDeploymentEvidence,omitempty"`
|
||||
DeploymentReceipt *ServerDeploymentExecutionReceiptBody `json:"deploymentReceipt,omitempty"`
|
||||
}
|
||||
@@ -519,7 +520,7 @@ func (request RunJobResultRequest) ToDomain() domain.RunJobResult {
|
||||
Message: request.Message,
|
||||
ErrorCode: request.ErrorCode,
|
||||
Retryable: request.Retryable,
|
||||
ExecutionResult: domain.JobExecutionResult{Kind: request.ExecutionResult.Kind, ProcessState: request.ExecutionResult.ProcessState, ExitClassification: request.ExecutionResult.ExitClassification, ExitCode: request.ExecutionResult.ExitCode, Version: request.ExecutionResult.Version, Checksum: request.ExecutionResult.Checksum, SizeBytes: request.ExecutionResult.SizeBytes, AuditSummary: request.ExecutionResult.AuditSummary, Content: request.ExecutionResult.Content, SQLiteSchemaProbe: SCUMSchemaProbeResultPtrToDomain(request.ExecutionResult.SQLiteSchemaProbe), SQLiteTemplate: SCUMSQLiteTemplateResultPtrToDomain(request.ExecutionResult.SQLiteTemplate), RCONTemplate: SCUMTypedRCONTemplateResultPtrToDomain(request.ExecutionResult.RCONTemplate), GuardedMutation: SCUMGuardedMutationResultPtrToDomain(request.ExecutionResult.GuardedMutation), ServerDeploymentEvidence: serverDeploymentEvidenceToDomain(request.ExecutionResult.ServerDeploymentEvidence), DeploymentReceipt: deploymentReceiptToDomain(request.ExecutionResult.DeploymentReceipt)},
|
||||
ExecutionResult: domain.JobExecutionResult{Kind: request.ExecutionResult.Kind, ProcessState: request.ExecutionResult.ProcessState, ExitClassification: request.ExecutionResult.ExitClassification, ExitCode: request.ExecutionResult.ExitCode, Version: request.ExecutionResult.Version, Checksum: request.ExecutionResult.Checksum, SizeBytes: request.ExecutionResult.SizeBytes, AuditSummary: request.ExecutionResult.AuditSummary, Content: request.ExecutionResult.Content, SQLiteSchemaProbe: SCUMSchemaProbeResultPtrToDomain(request.ExecutionResult.SQLiteSchemaProbe), SQLiteTemplate: SCUMSQLiteTemplateResultPtrToDomain(request.ExecutionResult.SQLiteTemplate), RCONTemplate: SCUMTypedRCONTemplateResultPtrToDomain(request.ExecutionResult.RCONTemplate), GuardedMutation: SCUMGuardedMutationResultPtrToDomain(request.ExecutionResult.GuardedMutation), ParsedLogBatch: SCUMParsedLogBatchResultPtrToDomain(request.ExecutionResult.ParsedLogBatch), ServerDeploymentEvidence: serverDeploymentEvidenceToDomain(request.ExecutionResult.ServerDeploymentEvidence), DeploymentReceipt: deploymentReceiptToDomain(request.ExecutionResult.DeploymentReceipt)},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -211,6 +211,53 @@ func TestRunJobResultRequestParsesGuardedMutationEnvelope(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunJobResultRequestParsesParsedLogBatchEnvelope(t *testing.T) {
|
||||
payload := `{
|
||||
"runEndpointId":"run-local",
|
||||
"sessionToken":"run-session",
|
||||
"jobId":"job-log",
|
||||
"leaseToken":"lease-log",
|
||||
"attempt":1,
|
||||
"state":"succeeded",
|
||||
"progress":{"percent":100,"message":"done"},
|
||||
"executionResult":{
|
||||
"kind":"log.parsed-events",
|
||||
"parsedLogBatch":{
|
||||
"requestId":"request-log",
|
||||
"jobId":"job-log",
|
||||
"binding":{"serverInstanceId":"server-scum","runBindingId":"runtime-binding-server-scum","runEndpointId":"run-local","pluginId":"server.scum","pluginVersion":"1.0.0","adapterVersion":"scum-live-data-v0","gameVersion":"1.0.0","databaseIdentity":"scum-database"},
|
||||
"status":"succeeded",
|
||||
"sourceKey":"scum-login-events",
|
||||
"streamKey":"scum.login",
|
||||
"parserKey":"scum-login-log-login-parser",
|
||||
"parserVersion":"scum-login-log-v1",
|
||||
"adapterVersion":"scum-live-data-v0",
|
||||
"assetDigest":"sha256:` + strings.Repeat("d", 64) + `",
|
||||
"parserDigest":"sha256:` + strings.Repeat("e", 64) + `",
|
||||
"observedAt":"2026-08-13T00:00:00Z",
|
||||
"resultDigest":"sha256:` + strings.Repeat("b", 64) + `",
|
||||
"firstCursor":{"sourceIdentityDigest":"sha256:` + strings.Repeat("1", 64) + `","streamGeneration":"sha256:` + strings.Repeat("2", 64) + `","sequence":7},
|
||||
"lastCursor":{"sourceIdentityDigest":"sha256:` + strings.Repeat("1", 64) + `","streamGeneration":"sha256:` + strings.Repeat("2", 64) + `","sequence":7},
|
||||
"tailState":"rotated",
|
||||
"replay":true,
|
||||
"eventCount":1,
|
||||
"events":[{"eventType":"scum.login","occurredAt":"2026-08-13T00:00:00Z","cursor":{"sourceIdentityDigest":"sha256:` + strings.Repeat("1", 64) + `","streamGeneration":"sha256:` + strings.Repeat("2", 64) + `","sequence":7},"logicalEventDigest":"sha256:` + strings.Repeat("3", 64) + `","eventDigest":"sha256:` + strings.Repeat("4", 64) + `","payloadDigest":"sha256:` + strings.Repeat("5", 64) + `","payload":{"externalPlayerId":"player-redacted","displayName":"Known Player"}}],
|
||||
"safeSummary":"one sanitized login event parsed from declared source",
|
||||
"limits":{"maxEvents":256,"maxPayloadBytes":16384,"maxLineBytes":4096,"maxResultBytes":262144}
|
||||
}
|
||||
}
|
||||
}`
|
||||
var request RunJobResultRequest
|
||||
if err := json.Unmarshal([]byte(payload), &request); err != nil {
|
||||
t.Fatalf("unmarshal Run parsed log result: %v", err)
|
||||
}
|
||||
domainRequest := request.ToDomain()
|
||||
result := domainRequest.ExecutionResult.ParsedLogBatch
|
||||
if result == nil || result.ParserKey != "scum-login-log-login-parser" || result.ParserDigest != "sha256:"+strings.Repeat("e", 64) || result.EventCount != 1 || result.Events[0].LogicalEventDigest != "sha256:"+strings.Repeat("3", 64) {
|
||||
t.Fatalf("parsedLogBatch envelope did not parse: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIProviderFromDomainCopiesModels(t *testing.T) {
|
||||
provider := domain.AIProvider{
|
||||
ID: "ai.openai",
|
||||
|
||||
@@ -51,6 +51,13 @@ type SCUMGuardedMutationBoundsDTO struct {
|
||||
MaxAffectedRows int `json:"maxAffectedRows"`
|
||||
}
|
||||
|
||||
type SCUMParsedLogBatchBoundsDTO struct {
|
||||
MaxEvents int `json:"maxEvents"`
|
||||
MaxPayloadBytes int `json:"maxPayloadBytes"`
|
||||
MaxLineBytes int `json:"maxLineBytes"`
|
||||
MaxResultBytes int `json:"maxResultBytes"`
|
||||
}
|
||||
|
||||
type SCUMSchemaProbeRequestDTO struct {
|
||||
RequestID string `json:"requestId"`
|
||||
JobID string `json:"jobId"`
|
||||
@@ -262,6 +269,48 @@ type SCUMGuardedMutationResultDTO struct {
|
||||
Limits SCUMGuardedMutationBoundsDTO `json:"limits"`
|
||||
}
|
||||
|
||||
type SCUMParsedLogCursorDTO struct {
|
||||
SourceIdentityDigest string `json:"sourceIdentityDigest"`
|
||||
StreamGeneration string `json:"streamGeneration"`
|
||||
Sequence uint64 `json:"sequence"`
|
||||
}
|
||||
|
||||
type SCUMParsedLogEventDTO struct {
|
||||
EventType string `json:"eventType"`
|
||||
OccurredAt time.Time `json:"occurredAt"`
|
||||
Cursor SCUMParsedLogCursorDTO `json:"cursor"`
|
||||
LogicalEventDigest string `json:"logicalEventDigest"`
|
||||
EventDigest string `json:"eventDigest"`
|
||||
PayloadDigest string `json:"payloadDigest"`
|
||||
Payload map[string]any `json:"payload,omitempty"`
|
||||
}
|
||||
|
||||
type SCUMParsedLogBatchResultDTO struct {
|
||||
RequestID string `json:"requestId"`
|
||||
JobID string `json:"jobId"`
|
||||
Binding SCUMBindingIdentityDTO `json:"binding"`
|
||||
Status string `json:"status"`
|
||||
SourceKey string `json:"sourceKey"`
|
||||
StreamKey string `json:"streamKey"`
|
||||
ParserKey string `json:"parserKey"`
|
||||
ParserVersion string `json:"parserVersion"`
|
||||
AdapterVersion string `json:"adapterVersion"`
|
||||
AssetDigest string `json:"assetDigest"`
|
||||
ParserDigest string `json:"parserDigest"`
|
||||
ObservedAt time.Time `json:"observedAt"`
|
||||
ResultDigest string `json:"resultDigest"`
|
||||
FirstCursor SCUMParsedLogCursorDTO `json:"firstCursor"`
|
||||
LastCursor SCUMParsedLogCursorDTO `json:"lastCursor"`
|
||||
TailState string `json:"tailState"`
|
||||
PartialLineBuffered bool `json:"partialLineBuffered,omitempty"`
|
||||
Replay bool `json:"replay,omitempty"`
|
||||
EventCount int `json:"eventCount"`
|
||||
Events []SCUMParsedLogEventDTO `json:"events,omitempty"`
|
||||
SafeSummary string `json:"safeSummary,omitempty"`
|
||||
SafeError SCUMSafeErrorDTO `json:"safeError,omitempty"`
|
||||
Limits SCUMParsedLogBatchBoundsDTO `json:"limits"`
|
||||
}
|
||||
|
||||
type SCUMCapabilityGateDTO struct {
|
||||
Capability string `json:"capability"`
|
||||
State string `json:"state"`
|
||||
@@ -430,6 +479,22 @@ func SCUMGuardedMutationResultPtrToDomain(value *SCUMGuardedMutationResultDTO) *
|
||||
return &result
|
||||
}
|
||||
|
||||
func SCUMParsedLogBatchResultToDomain(value SCUMParsedLogBatchResultDTO) domain.SCUMParsedLogBatchResult {
|
||||
events := make([]domain.SCUMParsedLogEvent, len(value.Events))
|
||||
for index, event := range value.Events {
|
||||
events[index] = domain.SCUMParsedLogEvent{EventType: event.EventType, OccurredAt: event.OccurredAt, Cursor: scumParsedLogCursorToDomain(event.Cursor), LogicalEventDigest: event.LogicalEventDigest, EventDigest: event.EventDigest, PayloadDigest: event.PayloadDigest, Payload: domain.CopySCUMValueMap(event.Payload)}
|
||||
}
|
||||
return domain.SCUMParsedLogBatchResult{RequestID: value.RequestID, JobID: value.JobID, Binding: scumBindingIdentityToDomain(value.Binding), Status: domain.SCUMTerminalResultStatus(value.Status), SourceKey: value.SourceKey, StreamKey: value.StreamKey, ParserKey: value.ParserKey, ParserVersion: value.ParserVersion, AdapterVersion: value.AdapterVersion, AssetDigest: value.AssetDigest, ParserDigest: value.ParserDigest, ObservedAt: value.ObservedAt, ResultDigest: value.ResultDigest, FirstCursor: scumParsedLogCursorToDomain(value.FirstCursor), LastCursor: scumParsedLogCursorToDomain(value.LastCursor), TailState: domain.SCUMLogTailState(value.TailState), PartialLineBuffered: value.PartialLineBuffered, Replay: value.Replay, EventCount: value.EventCount, Events: events, SafeSummary: value.SafeSummary, SafeError: domain.SCUMSafeError{Code: domain.SCUMSafeErrorCode(value.SafeError.Code), Message: value.SafeError.Message, Retryable: value.SafeError.Retryable}, Limits: scumParsedLogBatchBoundsToDomain(value.Limits)}
|
||||
}
|
||||
|
||||
func SCUMParsedLogBatchResultPtrToDomain(value *SCUMParsedLogBatchResultDTO) *domain.SCUMParsedLogBatchResult {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
result := SCUMParsedLogBatchResultToDomain(*value)
|
||||
return &result
|
||||
}
|
||||
|
||||
func SCUMSchemaProbeResultFromDomain(value domain.SCUMSchemaProbeResult) SCUMSchemaProbeResultDTO {
|
||||
value = domain.CopySCUMSchemaProbeResult(value)
|
||||
objects := make([]SCUMSchemaObjectEvidenceDTO, len(value.Objects))
|
||||
@@ -496,6 +561,23 @@ func SCUMGuardedMutationResultPtrFromDomain(value *domain.SCUMGuardedMutationRes
|
||||
return &result
|
||||
}
|
||||
|
||||
func SCUMParsedLogBatchResultFromDomain(value domain.SCUMParsedLogBatchResult) SCUMParsedLogBatchResultDTO {
|
||||
value = *domain.CopySCUMParsedLogBatchResultPtr(&value)
|
||||
events := make([]SCUMParsedLogEventDTO, len(value.Events))
|
||||
for index, event := range value.Events {
|
||||
events[index] = SCUMParsedLogEventDTO{EventType: event.EventType, OccurredAt: event.OccurredAt, Cursor: scumParsedLogCursorFromDomain(event.Cursor), LogicalEventDigest: event.LogicalEventDigest, EventDigest: event.EventDigest, PayloadDigest: event.PayloadDigest, Payload: event.Payload}
|
||||
}
|
||||
return SCUMParsedLogBatchResultDTO{RequestID: value.RequestID, JobID: value.JobID, Binding: scumBindingIdentityFromDomain(value.Binding), Status: string(value.Status), SourceKey: value.SourceKey, StreamKey: value.StreamKey, ParserKey: value.ParserKey, ParserVersion: value.ParserVersion, AdapterVersion: value.AdapterVersion, AssetDigest: value.AssetDigest, ParserDigest: value.ParserDigest, ObservedAt: value.ObservedAt, ResultDigest: value.ResultDigest, FirstCursor: scumParsedLogCursorFromDomain(value.FirstCursor), LastCursor: scumParsedLogCursorFromDomain(value.LastCursor), TailState: string(value.TailState), PartialLineBuffered: value.PartialLineBuffered, Replay: value.Replay, EventCount: value.EventCount, Events: events, SafeSummary: value.SafeSummary, SafeError: SCUMSafeErrorDTO{Code: string(value.SafeError.Code), Message: value.SafeError.Message, Retryable: value.SafeError.Retryable}, Limits: scumParsedLogBatchBoundsFromDomain(value.Limits)}
|
||||
}
|
||||
|
||||
func SCUMParsedLogBatchResultPtrFromDomain(value *domain.SCUMParsedLogBatchResult) *SCUMParsedLogBatchResultDTO {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
result := SCUMParsedLogBatchResultFromDomain(*value)
|
||||
return &result
|
||||
}
|
||||
|
||||
func SCUMCapabilityGateFromDomain(value domain.SCUMCapabilityGate) SCUMCapabilityGateDTO {
|
||||
return SCUMCapabilityGateDTO{Capability: string(value.Capability), State: string(value.State), Enabled: value.Enabled, ReasonCode: string(value.ReasonCode), Reason: value.Reason}
|
||||
}
|
||||
@@ -539,3 +621,19 @@ func scumGuardedMutationBoundsToDomain(value SCUMGuardedMutationBoundsDTO) domai
|
||||
func scumGuardedMutationBoundsFromDomain(value domain.SCUMGuardedMutationBounds) SCUMGuardedMutationBoundsDTO {
|
||||
return SCUMGuardedMutationBoundsDTO{MaxPayloadBytes: value.MaxPayloadBytes, TimeoutMS: value.TimeoutMS, BusyTimeoutMS: value.BusyTimeoutMS, MaxReadbackBytes: value.MaxReadbackBytes, MaxAffectedRows: value.MaxAffectedRows}
|
||||
}
|
||||
|
||||
func scumParsedLogCursorToDomain(value SCUMParsedLogCursorDTO) domain.SCUMParsedLogCursor {
|
||||
return domain.SCUMParsedLogCursor{SourceIdentityDigest: value.SourceIdentityDigest, StreamGeneration: value.StreamGeneration, Sequence: value.Sequence}
|
||||
}
|
||||
|
||||
func scumParsedLogCursorFromDomain(value domain.SCUMParsedLogCursor) SCUMParsedLogCursorDTO {
|
||||
return SCUMParsedLogCursorDTO{SourceIdentityDigest: value.SourceIdentityDigest, StreamGeneration: value.StreamGeneration, Sequence: value.Sequence}
|
||||
}
|
||||
|
||||
func scumParsedLogBatchBoundsToDomain(value SCUMParsedLogBatchBoundsDTO) domain.SCUMParsedLogBatchBounds {
|
||||
return domain.SCUMParsedLogBatchBounds{MaxEvents: value.MaxEvents, MaxPayloadBytes: value.MaxPayloadBytes, MaxLineBytes: value.MaxLineBytes, MaxResultBytes: value.MaxResultBytes}
|
||||
}
|
||||
|
||||
func scumParsedLogBatchBoundsFromDomain(value domain.SCUMParsedLogBatchBounds) SCUMParsedLogBatchBoundsDTO {
|
||||
return SCUMParsedLogBatchBoundsDTO{MaxEvents: value.MaxEvents, MaxPayloadBytes: value.MaxPayloadBytes, MaxLineBytes: value.MaxLineBytes, MaxResultBytes: value.MaxResultBytes}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,8 @@ Named log DTOs:
|
||||
|
||||
Log ingest supports bounded batches, sequence ranges, checksum validation, retry-safe duplicate acknowledgement, latest sequence tracking, cursor query, and browser SSE fan-out from already-ingested platform logs. Log payloads must not carry artifact chunks, host paths, raw credentials, direct sockets, or unbounded inline data.
|
||||
|
||||
Plugin-declared parsed-log batches use the signed job-result channel for typed `log.parsed-events` terminal envelopes when a bounded backfill/replay job is leased, while ordinary log bodies continue to use `/run/logs/batches`. The parsed-log envelope carries only logical source/stream/parser identity, redacted source identity digest, stream generation, sequence cursor, logical event digest, payload digest, sanitized scalar payload, tail state, safe error, and applied limits. It must not carry raw log lines, paths, globs, network identifiers, sockets, credentials, SQL, XML, or game-specific executor branch data.
|
||||
|
||||
Run-assigned Platform jobs use `job.<jobId>.<streamKey>` log stream IDs. Autonomous lifecycle bootstrap is Run-owned machine execution rather than a Platform job, so its durable process logs use `run.<runEndpointId>.<serverInstanceId>.<streamKey>`. Platform may auto-create those streams only after validating the active Run session and the server-to-Run binding. For retry compatibility, legacy spooled `job.autonomous-*.<streamKey>` batches are accepted as Run-owned autonomous streams without creating or completing a Platform job.
|
||||
|
||||
Log ingest is durable and independently retried. Artifact/file transfer backlog must not prevent log batch acknowledgement, duplicate acknowledgement, cursor state updates, or spool cleanup.
|
||||
|
||||
@@ -81,6 +81,21 @@ The request never contains browser command text, raw RCON, SQL, XML, host/databa
|
||||
|
||||
Platform accepts write success only when the envelope matches the leased durable job and the declared confirmation status is conclusive. Missing, mismatched, stale, unsafe, partial, timed-out, cancelled, or schema-invalid confirmations remain failed or unknown outcomes; they must not update local verified facts or trigger automatic redelivery.
|
||||
|
||||
## Parsed log batch result
|
||||
|
||||
`SCUMParsedLogBatchResult` is the terminal envelope for `log.parsed-events` batches produced from plugin-declared log-source tailing or backfill. Required fields are:
|
||||
|
||||
- `requestId`, `jobId`;
|
||||
- `binding`: `serverInstanceId`, `runBindingId`, `runEndpointId`, `pluginId`, `pluginVersion`, `adapterVersion`, `gameVersion`, `databaseIdentity` for active-service fencing;
|
||||
- status (`succeeded`, `failed`, or `cancelled`), logical `sourceKey`, `streamKey`, `parserKey`, `parserVersion`, `adapterVersion`, immutable parser `assetDigest`, `parserDigest`, observed time, and `resultDigest`;
|
||||
- `firstCursor` and `lastCursor` containing `sourceIdentityDigest`, `streamGeneration`, and `sequence`;
|
||||
- `tailState`, limited to `advanced`, `rotated`, `truncated`, `restarted`, `partial-buffered`, or `replayed`, plus `partialLineBuffered` and `replay` flags;
|
||||
- `eventCount`, bounded sanitized events, safe summary, safe error, and limits: `maxEvents`, `maxPayloadBytes`, `maxLineBytes`, and `maxResultBytes`.
|
||||
|
||||
Each parsed event contains only event type, occurrence time, the transport cursor, privacy-safe `logicalEventDigest`, `eventDigest`, `payloadDigest`, and schema-safe scalar payload values. The envelope never contains raw log lines, raw IP/network identifiers, host paths, resolved file names, glob patterns, sockets, credentials, SQL, XML, or unredacted player/network identities.
|
||||
|
||||
Platform accepts a parsed-log success only when the envelope matches the leased job, server/Run binding, declared source/stream key, frozen parser key/version/digest when present, and a single redacted source identity/generation boundary. Replayed logical events are handled by the later ingestion layer through `logicalEventDigest`; transport cursor replay or rotation overlap must not by itself create duplicate players or sessions.
|
||||
|
||||
## Guarded mutation request
|
||||
|
||||
`SCUMGuardedMutationRequest` is the Platform durable-job payload for plugin-owned single-row SQLite/XML mutation templates after the mutation capability is proven, reviewed, and explicitly confirmed. Required fields are:
|
||||
|
||||
@@ -38,6 +38,12 @@ Login/logout ingestion starts from plugin-declared log sources and versioned par
|
||||
|
||||
Run and Platform discard raw IP addresses and other network identifiers before durable storage or logical fingerprinting. Malformed, failed-login, obsolete-binding, duplicate, or out-of-order events must not fabricate players or sessions.
|
||||
|
||||
For plugin-declared file-tail backfill or replay, the leased Run assignment carries the frozen declared `logSource` and may additionally freeze `parserKey`, `parserVersion`, `parserDigest`, and `adapterVersion` as safe scalar execution inputs. The log source contains only package logical `sourceKey`/`targetKey`/`streamKey` metadata, cursor kind, and retention policy. It never contains the resolved host log path, glob, socket, credential, network endpoint, or raw line material.
|
||||
|
||||
The terminal `log.parsed-events` envelope contains request/job identity, server/plugin binding, status, source/stream key, parser key/version, adapter version, immutable parser asset digest, parser digest, observed time, result digest, first/last transport cursors with redacted `sourceIdentityDigest`, stream generation, sequence, tail state (`advanced`, `rotated`, `truncated`, `restarted`, `partial-buffered`, or `replayed`), partial-line and replay flags, event count, bounded sanitized events, safe summary, safe error, and applied limits. Each event carries event type, occurrence time, transport cursor, logical event digest, event digest, payload digest, and schema-safe scalar payload values only.
|
||||
|
||||
Platform accepts the parsed-log envelope only when it matches the leased `logs.backfill` job, active server/Run endpoint, plugin id/version when frozen, declared source/stream key, parser key/version/digest when frozen, and a single source identity/generation boundary. Parser digests, source identity, stream generation, logical event digest, and payload digest are fingerprints; raw log lines, IP/network values, paths, SQL, XML, sockets, credentials, and player identities not already redacted are rejected before local ingestion can use the batch.
|
||||
|
||||
## Typed RCON Template Jobs
|
||||
|
||||
SCUM command writes use only plugin-owned typed command templates. Platform dispatches a template key, adapter version, digest, target identity, idempotency key, validated parameters, and review reason through the durable job channel.
|
||||
|
||||
@@ -764,7 +764,7 @@ func (svc *CoreService) QueueLogBackfillForSession(sessionID string, request dom
|
||||
InputRef: request.CheckpointRef,
|
||||
IdempotencyKey: request.IdempotencyKey,
|
||||
Progress: domain.JobProgress{Percent: 0, Message: "historical log backfill queued"},
|
||||
ExecutionInput: domain.JobExecutionInput{LogSource: &source},
|
||||
ExecutionInput: domain.JobExecutionInput{PluginID: plugin.ID, TargetVersion: plugin.Version, LogSource: &source},
|
||||
})
|
||||
if err != nil {
|
||||
_ = svc.recordAuditEvent(user.ID, "logs.backfill.denied", "server-instance", instance.ID, domain.AuditResultDenied, "log backfill denied: endpoint unsupported or offline")
|
||||
|
||||
@@ -288,6 +288,9 @@ func (svc *CoreService) CompleteRunJob(result domain.RunJobResult) (domain.RunJo
|
||||
}
|
||||
|
||||
func validateExecutionResultForJob(job domain.Job, result domain.RunJobResult) error {
|
||||
if result.ExecutionResult.ParsedLogBatch != nil && job.Capability != domain.JobCapabilityLogsBackfill {
|
||||
return validationError("parsed log batch result is allowed only for logs.backfill jobs")
|
||||
}
|
||||
if definition := job.ExecutionInput.Deployment; definition != nil {
|
||||
receipt := result.ExecutionResult.DeploymentReceipt
|
||||
if result.State == domain.JobStateSucceeded && definition.Mode == domain.ServerDeploymentModeCustom && receipt == nil {
|
||||
@@ -388,6 +391,18 @@ func validateExecutionResultForJob(job domain.Job, result domain.RunJobResult) e
|
||||
return err
|
||||
}
|
||||
}
|
||||
case domain.JobCapabilityLogsBackfill:
|
||||
if result.ExecutionResult.ParsedLogBatch == nil {
|
||||
break
|
||||
}
|
||||
if result.ExecutionResult.Kind != "" && result.ExecutionResult.Kind != scumParsedLogBatchExecutionKind {
|
||||
return validationError("parsed log batch result type is invalid")
|
||||
}
|
||||
if result.State == domain.JobStateSucceeded {
|
||||
if err := validateSCUMParsedLogBatchResultForJob(job, *result.ExecutionResult.ParsedLogBatch); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case domain.JobCapabilityClientManagerDeploy:
|
||||
if result.State == domain.JobStateSucceeded && result.ExecutionResult.Kind != "client-manager.deployed" {
|
||||
return validationError("client-manager deploy result type is invalid")
|
||||
@@ -787,7 +802,11 @@ func terminalFingerprint(result domain.RunJobResult) string {
|
||||
if result.ExecutionResult.GuardedMutation != nil {
|
||||
guardedMutationFingerprint = fmt.Sprintf("%s|%s|%s|%d|%s", result.ExecutionResult.GuardedMutation.ResultDigest, result.ExecutionResult.GuardedMutation.AssetDigest, result.ExecutionResult.GuardedMutation.PatchDigest, result.ExecutionResult.GuardedMutation.AffectedRows, result.ExecutionResult.GuardedMutation.ReadbackStatus)
|
||||
}
|
||||
return fmt.Sprintf("%s|%d|%s|%s|%s|%s|%t|%s|%s|%s|%s|%s|%s", result.State, result.Progress.Percent, result.ResultRef, result.Message, result.ErrorCode, result.Progress.Message, result.Retryable, result.ExecutionResult.Kind, result.ExecutionResult.Checksum, schemaProbeFingerprint, sqliteTemplateFingerprint, rconTemplateFingerprint, guardedMutationFingerprint)
|
||||
parsedLogBatchFingerprint := ""
|
||||
if result.ExecutionResult.ParsedLogBatch != nil {
|
||||
parsedLogBatchFingerprint = fmt.Sprintf("%s|%s|%s|%s|%d|%s", result.ExecutionResult.ParsedLogBatch.ResultDigest, result.ExecutionResult.ParsedLogBatch.AssetDigest, result.ExecutionResult.ParsedLogBatch.ParserDigest, result.ExecutionResult.ParsedLogBatch.FirstCursor.StreamGeneration, result.ExecutionResult.ParsedLogBatch.EventCount, result.ExecutionResult.ParsedLogBatch.TailState)
|
||||
}
|
||||
return fmt.Sprintf("%s|%d|%s|%s|%s|%s|%t|%s|%s|%s|%s|%s|%s|%s", result.State, result.Progress.Percent, result.ResultRef, result.Message, result.ErrorCode, result.Progress.Message, result.Retryable, result.ExecutionResult.Kind, result.ExecutionResult.Checksum, schemaProbeFingerprint, sqliteTemplateFingerprint, rconTemplateFingerprint, guardedMutationFingerprint, parsedLogBatchFingerprint)
|
||||
}
|
||||
|
||||
func terminalMessage(result domain.RunJobResult) string {
|
||||
|
||||
@@ -320,6 +320,40 @@ func TestCoreServiceRunJobGuardedMutationEnvelopeIsFencedToLease(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceRunJobParsedLogBatchEnvelopeIsFencedToLease(t *testing.T) {
|
||||
svc, sessionToken := newRegisteredRunJobService(t)
|
||||
binding := scumTemplateTestBinding()
|
||||
createSCUMTemplateServerFixture(t, svc, binding)
|
||||
source := &domain.RuntimeLogSource{Key: "scum-login-events", Kind: "file.tail", TargetKey: "logs/login", StreamKey: "scum.login", CursorKind: "fingerprint", RetentionDays: 90}
|
||||
job, err := svc.CreateJob(domain.Job{ID: "job-log", ServerInstanceID: binding.ServerInstanceID, RunEndpointID: binding.RunEndpointID, Capability: domain.JobCapabilityLogsBackfill, TargetKey: "logs/scum-login-events", InputRef: "artifact://logs/checkpoint/1", IdempotencyKey: "idem-log", ExecutionInput: domain.JobExecutionInput{PluginID: binding.PluginID, TargetVersion: binding.PluginVersion, LogSource: source, Inputs: map[string]string{"parserKey": "scum-login-log-login-parser", "parserVersion": "scum-login-log-v1", "parserDigest": scumTemplateTestHash(), "adapterVersion": binding.AdapterVersion}}})
|
||||
if err != nil {
|
||||
t.Fatalf("create parsed log backfill job: %v", err)
|
||||
}
|
||||
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: binding.RunEndpointID, SessionToken: sessionToken, Capabilities: []string{domain.JobCapabilityLogsBackfill}, Capacity: domain.RunCapacity{MaxJobs: 4}})
|
||||
if err != nil || !claim.HasJob || claim.Job.JobID != job.ID || claim.Job.ExecutionInput.LogSource == nil {
|
||||
t.Fatalf("claim parsed log job: claim=%+v err=%v", claim, err)
|
||||
}
|
||||
result := scumParsedLogBatchTestResult(binding, job.ID, *source)
|
||||
if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: binding.RunEndpointID, SessionToken: sessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100, Message: "log parse complete"}, Message: "log parse complete", ExecutionResult: domain.JobExecutionResult{Kind: scumParsedLogBatchExecutionKind, ParsedLogBatch: &result, AuditSummary: "redacted parsed log batch"}}); err != nil {
|
||||
t.Fatalf("complete matching parsed log job: %v", err)
|
||||
}
|
||||
|
||||
badJob, err := svc.CreateJob(domain.Job{ID: "job-log-bad", ServerInstanceID: binding.ServerInstanceID, RunEndpointID: binding.RunEndpointID, Capability: domain.JobCapabilityLogsBackfill, TargetKey: "logs/scum-login-events", InputRef: "artifact://logs/checkpoint/2", IdempotencyKey: "idem-log-bad", ExecutionInput: domain.JobExecutionInput{PluginID: binding.PluginID, TargetVersion: binding.PluginVersion, LogSource: source, Inputs: map[string]string{"parserKey": "scum-login-log-login-parser", "parserVersion": "scum-login-log-v1", "parserDigest": scumTemplateTestHash(), "adapterVersion": binding.AdapterVersion}}})
|
||||
if err != nil {
|
||||
t.Fatalf("create bad parsed log job: %v", err)
|
||||
}
|
||||
badClaim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: binding.RunEndpointID, SessionToken: sessionToken, Capabilities: []string{domain.JobCapabilityLogsBackfill}, Capacity: domain.RunCapacity{MaxJobs: 4}})
|
||||
if err != nil || !badClaim.HasJob || badClaim.Job.JobID != badJob.ID {
|
||||
t.Fatalf("claim bad parsed log job: claim=%+v err=%v", badClaim, err)
|
||||
}
|
||||
badResult := scumParsedLogBatchTestResult(binding, badJob.ID, *source)
|
||||
badResult.ParserDigest = "sha256:" + strings.Repeat("b", 64)
|
||||
_, err = svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: binding.RunEndpointID, SessionToken: sessionToken, JobID: badClaim.Job.JobID, LeaseToken: badClaim.Job.LeaseToken, Attempt: badClaim.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100, Message: "log parse complete"}, Message: "log parse complete", ExecutionResult: domain.JobExecutionResult{Kind: scumParsedLogBatchExecutionKind, ParsedLogBatch: &badResult, AuditSummary: "redacted parsed log batch"}})
|
||||
if err == nil || !strings.Contains(err.Error(), "parser identity") {
|
||||
t.Fatalf("expected parser-fenced parsed log result rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceRunJobReconcile(t *testing.T) {
|
||||
svc, sessionToken := newRegisteredRunJobService(t)
|
||||
createQueuedRunJob(t, svc, "job-1", "idem-1")
|
||||
@@ -350,7 +384,7 @@ func newRegisteredRunJobService(t *testing.T) (*CoreService, string) {
|
||||
t.Helper()
|
||||
svc := newTestCoreService()
|
||||
helloRequest := validRunControlHello()
|
||||
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, "process.start", domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunProtectedRCON, domain.JobCapabilityRemoteRunProtectedSQL)
|
||||
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, "process.start", domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunProtectedRCON, domain.JobCapabilityRemoteRunProtectedSQL, domain.JobCapabilityLogsBackfill)
|
||||
helloRequest.CapabilityReport.Fingerprint = "cap-jobs"
|
||||
hello, err := svc.RegisterRunHello(helloRequest)
|
||||
if err != nil {
|
||||
@@ -379,7 +413,7 @@ func scumTypedRCONSourcePlan() *domain.RuntimeSourceRCONPlan {
|
||||
|
||||
func createSCUMTemplateServerFixture(t *testing.T, svc *CoreService, binding domain.SCUMBindingIdentity) {
|
||||
t.Helper()
|
||||
if _, err := svc.CreateGamePlugin(domain.GamePlugin{ID: binding.PluginID, Name: "SCUM", Version: binding.PluginVersion, ServerType: "scum", ManifestRef: "artifact://manifests/game.scum/0.1.6", CreateFormSchemaRef: "artifact://schemas/game.scum/create-form/0.1.6", RequiredRunCapabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunProtectedRCON, domain.JobCapabilityRemoteRunProtectedSQL}, DeclaredPermissions: []string{"server.remote.access", "server.game-client.command", "server.game-client.maintenance"}, Permissions: domain.PluginPermissions{RemoteAccess: true}}); err != nil {
|
||||
if _, err := svc.CreateGamePlugin(domain.GamePlugin{ID: binding.PluginID, Name: "SCUM", Version: binding.PluginVersion, ServerType: "scum", ManifestRef: "artifact://manifests/game.scum/0.1.6", CreateFormSchemaRef: "artifact://schemas/game.scum/create-form/0.1.6", RequiredRunCapabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunProtectedRCON, domain.JobCapabilityRemoteRunProtectedSQL, domain.JobCapabilityLogsBackfill}, DeclaredPermissions: []string{"server.remote.access", "server.game-client.command", "server.game-client.maintenance", "server.logs.read"}, Permissions: domain.PluginPermissions{RemoteAccess: true}}); err != nil {
|
||||
t.Fatalf("create SCUM plugin fixture: %v", err)
|
||||
}
|
||||
if _, err := svc.CreateServerInstance(domain.ServerInstance{ID: binding.ServerInstanceID, PluginID: binding.PluginID, RunEndpointID: binding.RunEndpointID, Name: "SCUM"}); err != nil {
|
||||
@@ -399,6 +433,11 @@ func scumGuardedMutationTestResult(request domain.SCUMGuardedMutationRequest) do
|
||||
return domain.SCUMGuardedMutationResult{RequestID: request.RequestID, JobID: request.JobID, Binding: request.Binding, Status: domain.SCUMTerminalResultSucceeded, Capability: request.Capability, TargetKey: request.TargetKey, TemplateKey: request.TemplateKey, AdapterVersion: request.AdapterVersion, SchemaFingerprint: request.RequiredSchemaFingerprint, AssetDigest: request.AssetDigest, SourceFingerprint: scumTemplateTestHash(), TargetIdentityDigest: request.TargetIdentityDigest, ExpectedRowDigest: request.ExpectedRowDigest, ExpectedValueDigest: request.ExpectedValueDigest, ExpectedXMLDigest: request.ExpectedXMLDigest, PatchDigest: request.PatchDigest, BackupEvidenceDigest: request.BackupEvidenceDigest, OfflineEvidenceDigest: request.OfflineEvidenceDigest, DangerConfirmationDigest: request.DangerConfirmationDigest, ReadbackExpectationDigest: request.ReadbackExpectationDigest, ObservedAt: time.Now(), ResultDigest: scumTemplateTestHash(), BeforeDigest: scumTemplateTestHash(), AfterDigest: scumTemplateTestHash(), ReadbackDigest: scumTemplateTestHash(), AffectedRows: 1, ReadbackStatus: domain.SCUMMutationReadbackConfirmed, SafeSummary: "confirmed by declared readback", Limits: request.Bounds, SafeError: domain.SCUMSafeError{Code: domain.SCUMSafeErrorNone}}
|
||||
}
|
||||
|
||||
func scumParsedLogBatchTestResult(binding domain.SCUMBindingIdentity, jobID string, source domain.RuntimeLogSource) domain.SCUMParsedLogBatchResult {
|
||||
cursor := domain.SCUMParsedLogCursor{SourceIdentityDigest: scumTemplateTestHash(), StreamGeneration: scumTemplateTestHash(), Sequence: 7}
|
||||
return domain.SCUMParsedLogBatchResult{RequestID: "request-log", JobID: jobID, Binding: binding, Status: domain.SCUMTerminalResultSucceeded, SourceKey: source.Key, StreamKey: source.StreamKey, ParserKey: "scum-login-log-login-parser", ParserVersion: "scum-login-log-v1", AdapterVersion: binding.AdapterVersion, AssetDigest: scumTemplateTestHash(), ParserDigest: scumTemplateTestHash(), ObservedAt: time.Now(), ResultDigest: scumTemplateTestHash(), FirstCursor: cursor, LastCursor: cursor, TailState: domain.SCUMLogTailRotated, Replay: true, EventCount: 1, Events: []domain.SCUMParsedLogEvent{{EventType: "scum.login", OccurredAt: time.Now(), Cursor: cursor, LogicalEventDigest: scumTemplateTestHash(), EventDigest: scumTemplateTestHash(), PayloadDigest: scumTemplateTestHash(), Payload: map[string]any{"externalPlayerId": "player-redacted", "displayName": "Known Player", "profileLocalId": "profile-redacted"}}}, SafeSummary: "one sanitized login event parsed from declared source", Limits: domain.DefaultSCUMParsedLogBatchBounds(), SafeError: domain.SCUMSafeError{Code: domain.SCUMSafeErrorNone}}
|
||||
}
|
||||
|
||||
func createQueuedRunJob(t *testing.T, svc *CoreService, id string, idempotencyKey string) domain.Job {
|
||||
t.Helper()
|
||||
job, err := svc.CreateJob(domain.Job{
|
||||
|
||||
@@ -14,6 +14,7 @@ const (
|
||||
scumSQLiteTemplateExecutionKind = "sqlite.template-query"
|
||||
scumRCONTemplateExecutionKind = "rcon.template-command"
|
||||
scumGuardedMutationExecutionKind = "sqlite.guarded-mutation"
|
||||
scumParsedLogBatchExecutionKind = "log.parsed-events"
|
||||
)
|
||||
|
||||
func (svc *CoreService) RequestSCUMSchemaProbeForSession(sessionID, serverInstanceID, idempotencyKey string) (domain.SCUMSchemaProbeRequest, domain.RemoteAdapterResult, error) {
|
||||
@@ -217,6 +218,45 @@ func validateSCUMGuardedMutationResultForJob(job domain.Job, result domain.SCUMG
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSCUMParsedLogBatchResultForJob(job domain.Job, result domain.SCUMParsedLogBatchResult) error {
|
||||
if err := validator.ValidateSCUMParsedLogBatchResult(result); err != nil {
|
||||
return err
|
||||
}
|
||||
expected := job.ExecutionInput.LogSource
|
||||
if expected == nil {
|
||||
return validationError("parsed log batch request is missing from leased job")
|
||||
}
|
||||
if result.JobID != job.ID {
|
||||
return validationError("parsed log batch result does not match leased job identity")
|
||||
}
|
||||
if result.Binding.ServerInstanceID != job.ServerInstanceID || result.Binding.RunEndpointID != job.RunEndpointID {
|
||||
return validationError("parsed log batch result does not match leased server or Run endpoint")
|
||||
}
|
||||
if result.SourceKey != expected.Key || result.StreamKey != expected.StreamKey {
|
||||
return validationError("parsed log batch result does not match leased log source identity")
|
||||
}
|
||||
for key, value := range map[string]string{
|
||||
"parserKey": result.ParserKey,
|
||||
"parserVersion": result.ParserVersion,
|
||||
"parserDigest": result.ParserDigest,
|
||||
"adapterVersion": result.AdapterVersion,
|
||||
} {
|
||||
if expectedValue := strings.TrimSpace(job.ExecutionInput.Inputs[key]); expectedValue != "" && value != expectedValue {
|
||||
return validationError("parsed log batch result does not match leased parser identity")
|
||||
}
|
||||
}
|
||||
if job.ExecutionInput.PluginID != "" && result.Binding.PluginID != job.ExecutionInput.PluginID {
|
||||
return validationError("parsed log batch result does not match leased plugin identity")
|
||||
}
|
||||
if job.ExecutionInput.TargetVersion != "" && result.Binding.PluginVersion != job.ExecutionInput.TargetVersion {
|
||||
return validationError("parsed log batch result does not match leased plugin version")
|
||||
}
|
||||
if result.FirstCursor.SourceIdentityDigest != result.LastCursor.SourceIdentityDigest || result.FirstCursor.StreamGeneration != result.LastCursor.StreamGeneration {
|
||||
return validationError("parsed log batch result crosses source identity or generation boundaries")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sameSCUMSchemaProbeBinding(a, b domain.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
|
||||
}
|
||||
|
||||
@@ -71,6 +71,11 @@ func ValidateRunJobResult(result domain.RunJobResult) error {
|
||||
violations = append(violations, "executionResult.guardedMutation: "+err.Error())
|
||||
}
|
||||
}
|
||||
if result.ExecutionResult.ParsedLogBatch != nil {
|
||||
if err := ValidateSCUMParsedLogBatchResult(*result.ExecutionResult.ParsedLogBatch); err != nil {
|
||||
violations = append(violations, "executionResult.parsedLogBatch: "+err.Error())
|
||||
}
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
|
||||
@@ -1596,6 +1596,11 @@ func ValidateJob(job domain.Job) error {
|
||||
violations = append(violations, "executionResult.guardedMutation: "+err.Error())
|
||||
}
|
||||
}
|
||||
if job.ExecutionResult.ParsedLogBatch != nil {
|
||||
if err := ValidateSCUMParsedLogBatchResult(*job.ExecutionResult.ParsedLogBatch); err != nil {
|
||||
violations = append(violations, "executionResult.parsedLogBatch: "+err.Error())
|
||||
}
|
||||
}
|
||||
if job.Capability == domain.JobCapabilityConfigWrite || job.Capability == domain.JobCapabilityFilesRead || job.Capability == domain.JobCapabilityFilesWrite {
|
||||
if job.ServerInstanceID == "" {
|
||||
violations = append(violations, "serverInstanceId is required for scoped file jobs")
|
||||
|
||||
@@ -3,6 +3,7 @@ package validator
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
@@ -26,6 +27,10 @@ const (
|
||||
maxSCUMRCONConfirmRecords = 128
|
||||
maxSCUMMutationPayloadBytes = 4096
|
||||
maxSCUMMutationReadbackBytes = 64 * 1024
|
||||
maxSCUMParsedLogEvents = 1024
|
||||
maxSCUMParsedLogPayloadBytes = 64 * 1024
|
||||
maxSCUMParsedLogLineBytes = 64 * 1024
|
||||
maxSCUMParsedLogResultBytes = 1024 * 1024
|
||||
)
|
||||
|
||||
var scumHashPattern = regexp.MustCompile(`^sha256:[a-fA-F0-9]{64}$|^[a-fA-F0-9]{16,128}$`)
|
||||
@@ -342,6 +347,74 @@ func ValidateSCUMGuardedMutationResult(result domain.SCUMGuardedMutationResult)
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateSCUMParsedLogBatchResult(result domain.SCUMParsedLogBatchResult) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "requestId", result.RequestID)
|
||||
violations = appendRequired(violations, "jobId", result.JobID)
|
||||
violations = append(violations, validateSCUMBindingIdentity("binding", result.Binding)...)
|
||||
if !validSCUMTerminalResultStatus(result.Status) {
|
||||
violations = append(violations, "status is invalid")
|
||||
}
|
||||
for _, item := range []struct{ name, value string }{{"sourceKey", result.SourceKey}, {"streamKey", result.StreamKey}, {"parserKey", result.ParserKey}, {"parserVersion", result.ParserVersion}, {"adapterVersion", result.AdapterVersion}} {
|
||||
violations = append(violations, validateSCUMTemplateKey(item.name, item.value)...)
|
||||
if containsSCUMProtectedMaterial(item.value) || containsSCUMNetworkMaterial(item.value) {
|
||||
violations = append(violations, item.name+" contains protected material")
|
||||
}
|
||||
}
|
||||
for _, item := range []struct{ name, value string }{{"assetDigest", result.AssetDigest}, {"parserDigest", result.ParserDigest}, {"resultDigest", result.ResultDigest}} {
|
||||
if !validSCUMDigest(item.value) {
|
||||
violations = append(violations, item.name+" must be sha256 digest")
|
||||
}
|
||||
}
|
||||
violations = append(violations, validateSCUMParsedLogCursor("firstCursor", result.FirstCursor)...)
|
||||
violations = append(violations, validateSCUMParsedLogCursor("lastCursor", result.LastCursor)...)
|
||||
if !validSCUMLogTailState(result.TailState) {
|
||||
violations = append(violations, "tailState is invalid")
|
||||
}
|
||||
if result.EventCount != len(result.Events) {
|
||||
violations = append(violations, "eventCount must match returned events")
|
||||
}
|
||||
violations = append(violations, validateSCUMParsedLogBatchBounds("limits", result.Limits)...)
|
||||
if len(result.Events) > result.Limits.MaxEvents && result.Limits.MaxEvents > 0 {
|
||||
violations = append(violations, "events exceeds declared limit")
|
||||
}
|
||||
if len(result.SafeSummary) > 320 || containsSCUMProtectedMaterial(result.SafeSummary) || containsSCUMNetworkMaterial(result.SafeSummary) || containsSCUMRawXML(result.SafeSummary) {
|
||||
violations = append(violations, "safeSummary is unsafe")
|
||||
}
|
||||
violations = append(violations, validateSCUMSafeError("safeError", result.SafeError)...)
|
||||
if containsSCUMNetworkMaterial(result.SafeError.Message) || containsSCUMRawXML(result.SafeError.Message) {
|
||||
violations = append(violations, "safeError.message is unsafe")
|
||||
}
|
||||
seenLogical := map[string]struct{}{}
|
||||
seenTransport := map[string]struct{}{}
|
||||
for i, event := range result.Events {
|
||||
field := fmt.Sprintf("events[%d]", i)
|
||||
violations = append(violations, validateSCUMParsedLogEvent(field, event, result)...)
|
||||
if event.LogicalEventDigest != "" {
|
||||
if _, exists := seenLogical[event.LogicalEventDigest]; exists {
|
||||
violations = append(violations, field+".logicalEventDigest is duplicated")
|
||||
}
|
||||
seenLogical[event.LogicalEventDigest] = struct{}{}
|
||||
}
|
||||
transport := fmt.Sprintf("%s|%s|%d", event.Cursor.SourceIdentityDigest, event.Cursor.StreamGeneration, event.Cursor.Sequence)
|
||||
if _, exists := seenTransport[transport]; exists {
|
||||
violations = append(violations, field+".cursor is duplicated")
|
||||
}
|
||||
seenTransport[transport] = struct{}{}
|
||||
}
|
||||
if result.EventCount > 0 && result.LastCursor.Sequence < result.FirstCursor.Sequence {
|
||||
violations = append(violations, "lastCursor.sequence must not be before firstCursor.sequence")
|
||||
}
|
||||
if result.Status == domain.SCUMTerminalResultSucceeded {
|
||||
if result.SafeError.Code != "" && result.SafeError.Code != domain.SCUMSafeErrorNone {
|
||||
violations = append(violations, "succeeded result must not carry an error code")
|
||||
}
|
||||
} else if result.SafeError.Code == "" || result.SafeError.Code == domain.SCUMSafeErrorNone {
|
||||
violations = append(violations, "non-succeeded result requires a safe error code")
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func scumGuardedMutationRequestDigests(request domain.SCUMGuardedMutationRequest) []struct{ name, value string } {
|
||||
return []struct{ name, value string }{{"assetDigest", request.AssetDigest}, {"targetIdentityDigest", request.TargetIdentityDigest}, {"expectedRowDigest", request.ExpectedRowDigest}, {"expectedValueDigest", request.ExpectedValueDigest}, {"expectedXmlDigest", request.ExpectedXMLDigest}, {"patchDigest", request.PatchDigest}, {"backupEvidenceDigest", request.BackupEvidenceDigest}, {"offlineEvidenceDigest", request.OfflineEvidenceDigest}, {"dangerConfirmationDigest", request.DangerConfirmationDigest}, {"readbackExpectationDigest", request.ReadbackExpectationDigest}}
|
||||
}
|
||||
@@ -567,6 +640,62 @@ func validateSCUMGuardedMutationBounds(prefix string, value domain.SCUMGuardedMu
|
||||
return violations
|
||||
}
|
||||
|
||||
func validateSCUMParsedLogBatchBounds(prefix string, value domain.SCUMParsedLogBatchBounds) []string {
|
||||
var violations []string
|
||||
if value.MaxEvents < 0 || value.MaxEvents > maxSCUMParsedLogEvents {
|
||||
violations = append(violations, prefix+".maxEvents is out of bounds")
|
||||
}
|
||||
if value.MaxPayloadBytes < 1 || value.MaxPayloadBytes > maxSCUMParsedLogPayloadBytes {
|
||||
violations = append(violations, prefix+".maxPayloadBytes is out of bounds")
|
||||
}
|
||||
if value.MaxLineBytes < 1 || value.MaxLineBytes > maxSCUMParsedLogLineBytes {
|
||||
violations = append(violations, prefix+".maxLineBytes is out of bounds")
|
||||
}
|
||||
if value.MaxResultBytes < 1 || value.MaxResultBytes > maxSCUMParsedLogResultBytes {
|
||||
violations = append(violations, prefix+".maxResultBytes is out of bounds")
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
func validateSCUMParsedLogCursor(prefix string, value domain.SCUMParsedLogCursor) []string {
|
||||
var violations []string
|
||||
if !validSCUMFingerprint(value.SourceIdentityDigest) || containsSCUMProtectedMaterial(value.SourceIdentityDigest) || containsSCUMNetworkMaterial(value.SourceIdentityDigest) {
|
||||
violations = append(violations, prefix+".sourceIdentityDigest must be a redacted fingerprint")
|
||||
}
|
||||
if !validSCUMFingerprint(value.StreamGeneration) || containsSCUMProtectedMaterial(value.StreamGeneration) || containsSCUMNetworkMaterial(value.StreamGeneration) {
|
||||
violations = append(violations, prefix+".streamGeneration must be a redacted fingerprint")
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
func validateSCUMParsedLogEvent(prefix string, event domain.SCUMParsedLogEvent, result domain.SCUMParsedLogBatchResult) []string {
|
||||
var violations []string
|
||||
violations = append(violations, validateSCUMTemplateKey(prefix+".eventType", event.EventType)...)
|
||||
violations = append(violations, validateSCUMParsedLogCursor(prefix+".cursor", event.Cursor)...)
|
||||
for _, item := range []struct{ name, value string }{{"logicalEventDigest", event.LogicalEventDigest}, {"eventDigest", event.EventDigest}, {"payloadDigest", event.PayloadDigest}} {
|
||||
if !validSCUMDigest(item.value) {
|
||||
violations = append(violations, prefix+"."+item.name+" must be sha256 digest")
|
||||
}
|
||||
}
|
||||
if result.EventCount > 0 && event.Cursor.Sequence < result.FirstCursor.Sequence || result.EventCount > 0 && event.Cursor.Sequence > result.LastCursor.Sequence {
|
||||
violations = append(violations, prefix+".cursor.sequence is outside batch range")
|
||||
}
|
||||
if event.Cursor.SourceIdentityDigest != result.FirstCursor.SourceIdentityDigest || event.Cursor.StreamGeneration != result.FirstCursor.StreamGeneration {
|
||||
violations = append(violations, prefix+".cursor must match batch source identity and generation")
|
||||
}
|
||||
violations = append(violations, validateSCUMValueMap(prefix+".payload", event.Payload, 64)...)
|
||||
violations = append(violations, validateSCUMJSONSize(prefix+".payload", event.Payload, result.Limits.MaxPayloadBytes)...)
|
||||
for key, value := range event.Payload {
|
||||
if strings.Contains(strings.ToLower(key), "raw") || containsSCUMProtectedMaterial(key) || containsSCUMNetworkMaterial(key) {
|
||||
violations = append(violations, prefix+".payload key is unsafe")
|
||||
}
|
||||
if text, ok := value.(string); ok && (containsSCUMProtectedMaterial(text) || containsSCUMNetworkMaterial(text) || containsSCUMRawXML(text)) {
|
||||
violations = append(violations, prefix+".payload value is unsafe")
|
||||
}
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
func validateSCUMTemplateKey(prefix, value string) []string {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, prefix, value)
|
||||
@@ -765,6 +894,15 @@ func validSCUMMutationReadbackStatus(value domain.SCUMMutationReadbackStatus) bo
|
||||
}
|
||||
}
|
||||
|
||||
func validSCUMLogTailState(value domain.SCUMLogTailState) bool {
|
||||
switch value {
|
||||
case domain.SCUMLogTailAdvanced, domain.SCUMLogTailRotated, domain.SCUMLogTailTruncated, domain.SCUMLogTailRestarted, domain.SCUMLogTailPartial, domain.SCUMLogTailReplayed:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validSCUMCapabilityEvidenceStatus(value domain.SCUMCapabilityEvidenceStatus) bool {
|
||||
switch value {
|
||||
case domain.SCUMCapabilityEvidenceMissing, domain.SCUMCapabilityEvidenceCompatible, domain.SCUMCapabilityEvidenceIncompatible, domain.SCUMCapabilityEvidenceFailed:
|
||||
@@ -821,3 +959,23 @@ func containsSCUMProtectedMaterial(value string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsSCUMNetworkMaterial(value string) bool {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
lowered := strings.ToLower(trimmed)
|
||||
if trimmed == "" {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(lowered, "ip=") || strings.Contains(lowered, "addr=") || strings.Contains(lowered, "endpoint=") || strings.Contains(lowered, "port=") {
|
||||
return true
|
||||
}
|
||||
if regexp.MustCompile(`\b(?:\d{1,3}\.){3}\d{1,3}\b`).MatchString(trimmed) {
|
||||
return true
|
||||
}
|
||||
for _, token := range regexp.MustCompile(`[\s,;()\[\]{}'"]+`).Split(trimmed, -1) {
|
||||
if strings.Contains(token, ":") && net.ParseIP(strings.Trim(token, "<>")) != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -208,6 +208,27 @@ func TestValidateSCUMGuardedMutationResultRejectsMultiRowMissingReadbackAndUnsaf
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSCUMParsedLogBatchResultAcceptsRotationSafeEnvelope(t *testing.T) {
|
||||
result := validatorSCUMParsedLogBatchResult()
|
||||
|
||||
if err := ValidateSCUMParsedLogBatchResult(result); err != nil {
|
||||
t.Fatalf("expected valid parsed log batch result, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSCUMParsedLogBatchResultRejectsRawLineNetworkMaterialAndLooseBounds(t *testing.T) {
|
||||
result := validatorSCUMParsedLogBatchResult()
|
||||
result.ParserDigest = ""
|
||||
result.FirstCursor.SourceIdentityDigest = `C:\SCUM\Saved\Logs\login.log`
|
||||
result.Events[0].Payload = map[string]any{"rawLine": "2026.08.13: '203.0.113.10 player' logged in"}
|
||||
result.Limits.MaxLineBytes = 1000000
|
||||
|
||||
err := ValidateSCUMParsedLogBatchResult(result)
|
||||
if err == nil || !strings.Contains(err.Error(), "parserDigest") || !strings.Contains(err.Error(), "sourceIdentityDigest") || !strings.Contains(err.Error(), "payload key is unsafe") || !strings.Contains(err.Error(), "payload value is unsafe") || !strings.Contains(err.Error(), "maxLineBytes") {
|
||||
t.Fatalf("expected parsed-log safety violations, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSCUMCapabilityEvidenceRequiresSafeCurrentServiceIdentity(t *testing.T) {
|
||||
evidence := domain.SCUMCapabilityEvidence{Capability: domain.SCUMDataCapabilityPlayerRead, Status: domain.SCUMCapabilityEvidenceCompatible, Binding: validatorSCUMBinding(), AdapterVersion: "adapter-1", SchemaFingerprint: scumProbeHash, ProbeResultDigest: scumProbeHash, AssetDigests: []string{scumProbeHash}, SafeError: domain.SCUMSafeError{Code: domain.SCUMSafeErrorNone}}
|
||||
if err := ValidateSCUMCapabilityEvidence(evidence); err != nil {
|
||||
@@ -250,3 +271,39 @@ func validatorSCUMGuardedMutationResult() domain.SCUMGuardedMutationResult {
|
||||
request := validatorSCUMGuardedMutationRequest()
|
||||
return domain.SCUMGuardedMutationResult{RequestID: request.RequestID, JobID: request.JobID, Binding: request.Binding, Status: domain.SCUMTerminalResultSucceeded, Capability: request.Capability, TargetKey: request.TargetKey, TemplateKey: request.TemplateKey, AdapterVersion: request.AdapterVersion, SchemaFingerprint: request.RequiredSchemaFingerprint, AssetDigest: request.AssetDigest, SourceFingerprint: scumProbeHash, TargetIdentityDigest: request.TargetIdentityDigest, ExpectedRowDigest: request.ExpectedRowDigest, ExpectedValueDigest: request.ExpectedValueDigest, ExpectedXMLDigest: request.ExpectedXMLDigest, PatchDigest: request.PatchDigest, BackupEvidenceDigest: request.BackupEvidenceDigest, OfflineEvidenceDigest: request.OfflineEvidenceDigest, DangerConfirmationDigest: request.DangerConfirmationDigest, ReadbackExpectationDigest: request.ReadbackExpectationDigest, ObservedAt: time.Now(), ResultDigest: scumProbeHash, BeforeDigest: scumProbeHash, AfterDigest: scumProbeHash, ReadbackDigest: scumProbeHash, AffectedRows: 1, ReadbackStatus: domain.SCUMMutationReadbackConfirmed, SafeSummary: "confirmed by declared readback", SafeError: domain.SCUMSafeError{Code: domain.SCUMSafeErrorNone}, Limits: request.Bounds}
|
||||
}
|
||||
|
||||
func validatorSCUMParsedLogBatchResult() domain.SCUMParsedLogBatchResult {
|
||||
cursor := domain.SCUMParsedLogCursor{SourceIdentityDigest: scumProbeHash, StreamGeneration: scumProbeHash, Sequence: 7}
|
||||
return domain.SCUMParsedLogBatchResult{
|
||||
RequestID: "request-log-1",
|
||||
JobID: "job-log-1",
|
||||
Binding: validatorSCUMBinding(),
|
||||
Status: domain.SCUMTerminalResultSucceeded,
|
||||
SourceKey: "scum-login-events",
|
||||
StreamKey: "scum.login",
|
||||
ParserKey: "scum-login-log-login-parser",
|
||||
ParserVersion: "scum-login-log-v1",
|
||||
AdapterVersion: "adapter-1",
|
||||
AssetDigest: scumProbeHash,
|
||||
ParserDigest: scumProbeHash,
|
||||
ObservedAt: time.Now(),
|
||||
ResultDigest: scumProbeHash,
|
||||
FirstCursor: cursor,
|
||||
LastCursor: cursor,
|
||||
TailState: domain.SCUMLogTailRotated,
|
||||
Replay: true,
|
||||
EventCount: 1,
|
||||
SafeSummary: "one sanitized login event parsed from declared source",
|
||||
SafeError: domain.SCUMSafeError{Code: domain.SCUMSafeErrorNone},
|
||||
Limits: domain.DefaultSCUMParsedLogBatchBounds(),
|
||||
Events: []domain.SCUMParsedLogEvent{{
|
||||
EventType: "scum.login",
|
||||
OccurredAt: time.Now(),
|
||||
Cursor: cursor,
|
||||
LogicalEventDigest: scumProbeHash,
|
||||
EventDigest: scumProbeHash,
|
||||
PayloadDigest: scumProbeHash,
|
||||
Payload: map[string]any{"externalPlayerId": "player-redacted", "displayName": "Known Player", "profileLocalId": "profile-redacted"},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user