Freeze SCUM log-source tailing run contract

This commit is contained in:
npc0-hue
2026-08-13 14:37:16 +08:00
parent 41c109128d
commit 4f55c71de5
18 changed files with 627 additions and 6 deletions
+2
View File
@@ -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 {
+85
View File
@@ -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 &copy
}
func CopySCUMParsedLogBatchResultPtr(value *SCUMParsedLogBatchResult) *SCUMParsedLogBatchResult {
if value == nil {
return nil
}
copy := *value
copy.Events = CopySCUMParsedLogEvents(value.Events)
return &copy
}
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
+2 -1
View File
@@ -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)},
}
}
+47
View File
@@ -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",
+98
View File
@@ -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}
}
+2
View File
@@ -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.
+1 -1
View File
@@ -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")
+20 -1
View File
@@ -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 {
+41 -2
View File
@@ -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{
+40
View File
@@ -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
}
+5
View File
@@ -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)
}
+5
View File
@@ -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")
+158
View File
@@ -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
}
+57
View File
@@ -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"},
}},
}
}