Freeze SCUM log-source tailing run contract
This commit is contained in:
+34
@@ -0,0 +1,34 @@
|
|||||||
|
# Run Log-Source Tailing Handoff (2026-08-13)
|
||||||
|
|
||||||
|
This is a browser-repository handoff for a separately authorized task in the independent Run repository `git@git.npc0.com:admin343/run.git`. It is not Run implementation or deployment evidence, and it does not enable SCUM login-driven player creation by itself.
|
||||||
|
|
||||||
|
## Positive prompt (正向提示词)
|
||||||
|
|
||||||
|
Implement or extend generic plugin-declared log-source tailing in Run so Platform can consume sanitized parsed login/logout events from the current bound SCUM service without sending or receiving raw log paths, raw log lines, IP/network material, sockets, credentials, SQL, XML, or SCUM-specific executor branches. Success means Run tails only package-declared logical log sources, persists transport cursor state, handles rotation, copy-truncate, restart/resume, zero-byte active files, partial-line buffering, parser digest fencing, logical event fingerprinting, and replay, then returns typed `log.parsed-events` terminal envelopes or later parsed-event ingress payloads that Platform can validate against the active binding and declared parser identity.
|
||||||
|
|
||||||
|
## Directional prompt (方向提示词)
|
||||||
|
|
||||||
|
Work only in the independent Run repository. Preserve Run as a generic executor: resolve `file.tail` sources from the generated Run package and runtime bindings, keep host paths and globs local to Run, fence every parser by package asset digest and parser digest, decode lines according to the packaged parser declaration, buffer incomplete lines across polling/restart boundaries, detect rotation or truncate by source fingerprint/generation rather than SCUM file names, persist acknowledged cursor state `(source identity digest, stream generation, sequence)`, and emit sanitized logical events whose stable `logicalEventDigest` excludes source identity, stream generation, sequence, IP/network material, and coordinates.
|
||||||
|
|
||||||
|
Expected Run verification: focused protocol/runtime tests for zero-byte active file startup, append after empty file, UTF-16LE decoding, partial line buffering, oversized/undecodable/malformed line handling, failed-login discard, rotation, copy-truncate overlap, restart/resume from acknowledged cursor, duplicate transport cursor rejection, duplicate logical event replay, parser digest mismatch, stale binding/source identity rejection, bounded batch limits, safe error codes, and `go test ./...` from the Run repository root. After implementation, record the tested Run commit, distribution/deployment compatibility evidence for the active binding, and a safe redacted terminal-envelope sample back in this browser-repository change before enabling login-event ingestion tasks.
|
||||||
|
|
||||||
|
## Boundary prompt (任务边界)
|
||||||
|
|
||||||
|
Do not edit or vendor Run source into this browser repository, add a `run/` tree here, expose raw host paths, resolved log names, glob patterns, log lines, IP/network identifiers, sockets, credentials, SQL, XML, raw player identities, or parser-internal source paths in Platform, browser, AI context, or evidence. Do not branch Run behavior on SCUM file names, SCUM table names, SCUM parser keys, or SCUM event semantics; do not treat ordinary log ingest as a parsed-event success path; do not fabricate players/sessions from malformed, failed-login, duplicate, obsolete-binding, or out-of-order events; and do not treat this handoff as product acceptance evidence.
|
||||||
|
|
||||||
|
## Browser-side frozen contract
|
||||||
|
|
||||||
|
- Platform domain/DTO contracts define `SCUMParsedLogBatchResult`, `SCUMParsedLogEvent`, `SCUMParsedLogCursor`, parsed-log batch limits, tail states (`advanced`, `rotated`, `truncated`, `restarted`, `partial-buffered`, `replayed`), and safe terminal statuses.
|
||||||
|
- Job-channel DTOs parse terminal `executionResult.parsedLogBatch` from Run when `executionResult.kind` is `log.parsed-events`.
|
||||||
|
- Validators require request/job/binding identity, source/stream/parser key, adapter version, parser asset digest, parser digest, first/last redacted source identity and generation cursors, bounded event counts, logical event digests, event/payload digests, safe scalar payloads, and applied limits.
|
||||||
|
- Validators reject raw log/path/IP/network material, raw XML, SQL-like text, unsafe summaries, unsafe safe-error messages, duplicate transport cursors, duplicate logical event digests within a batch, event-count mismatches, loose line/payload/result bounds, parser digest omissions, and cross-generation batch success.
|
||||||
|
- Service job completion accepts `log.parsed-events` only for `logs.backfill` jobs carrying a leased declared `file.tail` log source, checks server/Run endpoint, declared source/stream key, plugin id/version when frozen, parser key/version/digest/adapter version when frozen in execution inputs, and single source identity/generation boundaries.
|
||||||
|
- Terminal idempotency fingerprints include parsed-log result digest, asset digest, parser digest, stream generation, event count, and tail state.
|
||||||
|
|
||||||
|
## Remaining evidence required before enabling parsed-login ingestion
|
||||||
|
|
||||||
|
- Tested Run commit and `go test ./...` output from the independent Run repository.
|
||||||
|
- Generated Run package evidence carrying the declared `file.tail` source, parser asset, parser digest, max-line limit, cursor policy, and privacy policy.
|
||||||
|
- Active current-service evidence showing the latest zero-byte login file, non-empty UTF-16LE fixtures, rotation/truncate behavior, and sanitized parser output with no raw network material.
|
||||||
|
- Platform acceptance evidence proving parsed batches are authenticated, fenced to the current server/Run/plugin/parser binding, idempotent across replay, and rejected for stale parser digest, stale source identity, duplicate transport cursor, and unsafe payloads.
|
||||||
|
- Follow-on Platform ingestion tests from task 6.1 proving successful login creates one local player/session, logout closes only matching sessions, and malformed/failed/duplicate/out-of-order events do not fabricate state.
|
||||||
@@ -130,12 +130,20 @@
|
|||||||
- Recorded the separately rooted Run handoff prompt in `evidence/run-guarded-sqlite-xml-mutation-handoff-2026-08-13.md` with positive, directional, and boundary prompts. This is a contract handoff only; no guarded XML mutation asset, `855` preset, write gate, external Run implementation evidence, or real-service mutation acceptance is enabled by this task.
|
- Recorded the separately rooted Run handoff prompt in `evidence/run-guarded-sqlite-xml-mutation-handoff-2026-08-13.md` with positive, directional, and boundary prompts. This is a contract handoff only; no guarded XML mutation asset, `855` preset, write gate, external Run implementation evidence, or real-service mutation acceptance is enabled by this task.
|
||||||
- Session verification also passed: `(cd platform && go test ./...)`, `scripts/check-structure.sh`, `openspec validate replace-scum-projections-with-real-data-management --strict`, and `git diff --check`.
|
- Session verification also passed: `(cd platform && go test ./...)`, `scripts/check-structure.sh`, `openspec validate replace-scum-projections-with-real-data-management --strict`, and `git diff --check`.
|
||||||
|
|
||||||
|
### Log-source tailing contract freeze and Run handoff evidence (2026-08-13)
|
||||||
|
|
||||||
|
- Platform protocol docs now freeze the generic `log.parsed-events` terminal envelope for plugin-declared log-source tailing/backfill: leased source/stream key, parser key/version, adapter version, parser asset digest, parser digest, first/last redacted source identity and stream-generation cursors, event count, tail state, replay/partial flags, logical event digests, payload digests, safe summaries, safe errors, and applied limits.
|
||||||
|
- Added Platform domain/DTO/job-channel/validator/service contracts for `SCUMParsedLogBatchResult`, parsed events/cursors, batch bounds, and log-tail states. Run results can carry only sanitized scalar event payloads, not raw log lines, paths, globs, IP/network identifiers, SQL, XML, sockets, credentials, or unredacted player identities.
|
||||||
|
- Service job completion accepts `log.parsed-events` only for `logs.backfill` jobs carrying a leased declared `file.tail` source, checks server/Run endpoint, source/stream key, plugin id/version when frozen, parser key/version/digest/adapter version when frozen, and single source identity/generation boundaries.
|
||||||
|
- Added focused tests for DTO parsing, validator rejection of raw-line/network/path material, loose max-line bounds, missing parser digest, service lease fencing, and parser digest mismatch rejection. Verification passed: `go test ./dto ./validator ./service -run 'Test(RunJobResultRequestParses(SQLite|TypedRCON|GuardedMutation|ParsedLog)|ValidateSCUM(SQLiteTemplate|TypedRCON|GuardedMutation|ParsedLog)|CoreServiceRunJob(SQLiteTemplate|TypedRCONTemplate|GuardedMutation|ParsedLogBatch)EnvelopeIsFencedToLease)'`.
|
||||||
|
- Recorded the separately rooted Run handoff prompt in `evidence/run-log-source-tailing-handoff-2026-08-13.md` with positive, directional, and boundary prompts. This is a contract handoff only; no external Run implementation evidence, parsed-login ingestion enablement, or player/session creation acceptance is enabled by this task.
|
||||||
|
|
||||||
- [x] 4.1 Add Platform protocol contracts under `platform/protocol`, API DTOs under `platform/dto`, validation under `platform/validator`, and plugin contracts/assets under `plugins/sdk` and `plugins/schemas`, plus contract documentation/mocks for probes, read-only template execution, typed RCON, guarded SQLite/XML mutation, parsed log events, and terminal result envelopes.
|
- [x] 4.1 Add Platform protocol contracts under `platform/protocol`, API DTOs under `platform/dto`, validation under `platform/validator`, and plugin contracts/assets under `plugins/sdk` and `plugins/schemas`, plus contract documentation/mocks for probes, read-only template execution, typed RCON, guarded SQLite/XML mutation, parsed log events, and terminal result envelopes.
|
||||||
- [x] 4.2 Freeze the generic executor/result contract and hand off a separately authorized Run-repository task for packaged SQLite-template execution with query-only connections, bound parameters, one-statement validation, short busy/operation timeouts, cancellation, and row/result-byte limits.
|
- [x] 4.2 Freeze the generic executor/result contract and hand off a separately authorized Run-repository task for packaged SQLite-template execution with query-only connections, bound parameters, one-statement validation, short busy/operation timeouts, cancellation, and row/result-byte limits.
|
||||||
- [x] 4.3 Require the independent Run task to return typed envelopes containing server/plugin binding, adapter/schema version, template key, asset digest, job identity, observed time, checksum, rows or affected-row count, and stable safe result/error codes.
|
- [x] 4.3 Require the independent Run task to return typed envelopes containing server/plugin binding, adapter/schema version, template key, asset digest, job identity, observed time, checksum, rows or affected-row count, and stable safe result/error codes.
|
||||||
- [x] 4.4 Require the independent Run task to implement generic plugin-owned typed RCON-template execution without accepting browser command text or adding branches for SCUM, SCUM keys, SCUM commands, or SCUM tables.
|
- [x] 4.4 Require the independent Run task to implement generic plugin-owned typed RCON-template execution without accepting browser command text or adding branches for SCUM, SCUM keys, SCUM commands, or SCUM tables.
|
||||||
- [x] 4.5 Require the independent Run task to implement generic guarded single-row SQLite/XML mutation execution with expected identity/value/checksum guards, a bounded transaction, preserving XML patching, rollback on zero/multiple affected rows, and read-after-write confirmation.
|
- [x] 4.5 Require the independent Run task to implement generic guarded single-row SQLite/XML mutation execution with expected identity/value/checksum guards, a bounded transaction, preserving XML patching, rollback on zero/multiple affected rows, and read-after-write confirmation.
|
||||||
- [ ] 4.6 Require the independent Run task to implement or extend generic plugin-declared log-source tailing so cursor persistence, rotation, truncate, restart, partial-line buffering, parser digest fencing, logical event fingerprinting, and replay remain independent of SCUM-specific source paths.
|
- [x] 4.6 Require the independent Run task to implement or extend generic plugin-declared log-source tailing so cursor persistence, rotation, truncate, restart, partial-line buffering, parser digest fencing, logical event fingerprinting, and replay remain independent of SCUM-specific source paths.
|
||||||
- [ ] 4.7 Verify from the independent Run task's acceptance evidence that control/job/log/artifact priorities, leases, fencing, acknowledgements, idempotency, and late/duplicate terminal-result handling remain intact for the new generic capabilities.
|
- [ ] 4.7 Verify from the independent Run task's acceptance evidence that control/job/log/artifact priorities, leases, fencing, acknowledgements, idempotency, and late/duplicate terminal-result handling remain intact for the new generic capabilities.
|
||||||
- [ ] 4.8 Add Platform-side capability negotiation so probe, player/squad/vehicle/flag/position reads, typed commands, gifts, and guarded mutations are gated independently for each active Run/plugin/adapter binding.
|
- [ ] 4.8 Add Platform-side capability negotiation so probe, player/squad/vehicle/flag/position reads, typed commands, gifts, and guarded mutations are gated independently for each active Run/plugin/adapter binding.
|
||||||
- [ ] 4.9 After the external Run contract tests/commit/deployment evidence is available, wire immutable assets and digests into generated Run packages and add distribution/contract tests proving Platform sends only template keys, bounded parameters, adapter version, and expected digest.
|
- [ ] 4.9 After the external Run contract tests/commit/deployment evidence is available, wire immutable assets and digests into generated Run packages and add distribution/contract tests proving Platform sends only template keys, bounded parameters, adapter version, and expected digest.
|
||||||
|
|||||||
@@ -1206,6 +1206,7 @@ type JobExecutionResult struct {
|
|||||||
SQLiteTemplate *SCUMSQLiteTemplateResult
|
SQLiteTemplate *SCUMSQLiteTemplateResult
|
||||||
RCONTemplate *SCUMTypedRCONTemplateResult
|
RCONTemplate *SCUMTypedRCONTemplateResult
|
||||||
GuardedMutation *SCUMGuardedMutationResult
|
GuardedMutation *SCUMGuardedMutationResult
|
||||||
|
ParsedLogBatch *SCUMParsedLogBatchResult
|
||||||
ServerDeploymentEvidence *ServerDeploymentEvidence
|
ServerDeploymentEvidence *ServerDeploymentEvidence
|
||||||
DeploymentReceipt *ServerDeploymentExecutionReceipt
|
DeploymentReceipt *ServerDeploymentExecutionReceipt
|
||||||
}
|
}
|
||||||
@@ -2058,6 +2059,7 @@ func CopyJob(job Job) Job {
|
|||||||
job.ExecutionResult.SQLiteTemplate = CopySCUMSQLiteTemplateResultPtr(job.ExecutionResult.SQLiteTemplate)
|
job.ExecutionResult.SQLiteTemplate = CopySCUMSQLiteTemplateResultPtr(job.ExecutionResult.SQLiteTemplate)
|
||||||
job.ExecutionResult.RCONTemplate = CopySCUMTypedRCONTemplateResultPtr(job.ExecutionResult.RCONTemplate)
|
job.ExecutionResult.RCONTemplate = CopySCUMTypedRCONTemplateResultPtr(job.ExecutionResult.RCONTemplate)
|
||||||
job.ExecutionResult.GuardedMutation = CopySCUMGuardedMutationResultPtr(job.ExecutionResult.GuardedMutation)
|
job.ExecutionResult.GuardedMutation = CopySCUMGuardedMutationResultPtr(job.ExecutionResult.GuardedMutation)
|
||||||
|
job.ExecutionResult.ParsedLogBatch = CopySCUMParsedLogBatchResultPtr(job.ExecutionResult.ParsedLogBatch)
|
||||||
job.ExecutionResult.ServerDeploymentEvidence = CopyServerDeploymentEvidence(job.ExecutionResult.ServerDeploymentEvidence)
|
job.ExecutionResult.ServerDeploymentEvidence = CopyServerDeploymentEvidence(job.ExecutionResult.ServerDeploymentEvidence)
|
||||||
job.ExecutionResult.DeploymentReceipt = CopyServerDeploymentExecutionReceipt(job.ExecutionResult.DeploymentReceipt)
|
job.ExecutionResult.DeploymentReceipt = CopyServerDeploymentExecutionReceipt(job.ExecutionResult.DeploymentReceipt)
|
||||||
if job.ExecutionInput.Deployment != nil {
|
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}
|
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 {
|
type SCUMSchemaProbeRequest struct {
|
||||||
RequestID string
|
RequestID string
|
||||||
JobID string
|
JobID string
|
||||||
@@ -384,6 +422,32 @@ type SCUMGuardedMutationResult struct {
|
|||||||
Limits SCUMGuardedMutationBounds
|
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 {
|
type SCUMCapabilityRequirement struct {
|
||||||
Capability SCUMDataCapability
|
Capability SCUMDataCapability
|
||||||
AdapterVersion string
|
AdapterVersion string
|
||||||
@@ -528,6 +592,27 @@ func CopySCUMGuardedMutationResultPtr(value *SCUMGuardedMutationResult) *SCUMGua
|
|||||||
return ©
|
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 {
|
func CopySCUMValueMap(value map[string]any) map[string]any {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -260,6 +260,7 @@ type RunJobExecutionResultBody struct {
|
|||||||
SQLiteTemplate *SCUMSQLiteTemplateResultDTO `json:"sqliteTemplate,omitempty"`
|
SQLiteTemplate *SCUMSQLiteTemplateResultDTO `json:"sqliteTemplate,omitempty"`
|
||||||
RCONTemplate *SCUMTypedRCONTemplateResultDTO `json:"rconTemplate,omitempty"`
|
RCONTemplate *SCUMTypedRCONTemplateResultDTO `json:"rconTemplate,omitempty"`
|
||||||
GuardedMutation *SCUMGuardedMutationResultDTO `json:"guardedMutation,omitempty"`
|
GuardedMutation *SCUMGuardedMutationResultDTO `json:"guardedMutation,omitempty"`
|
||||||
|
ParsedLogBatch *SCUMParsedLogBatchResultDTO `json:"parsedLogBatch,omitempty"`
|
||||||
ServerDeploymentEvidence *ServerDeploymentEvidenceBody `json:"serverDeploymentEvidence,omitempty"`
|
ServerDeploymentEvidence *ServerDeploymentEvidenceBody `json:"serverDeploymentEvidence,omitempty"`
|
||||||
DeploymentReceipt *ServerDeploymentExecutionReceiptBody `json:"deploymentReceipt,omitempty"`
|
DeploymentReceipt *ServerDeploymentExecutionReceiptBody `json:"deploymentReceipt,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -519,7 +520,7 @@ func (request RunJobResultRequest) ToDomain() domain.RunJobResult {
|
|||||||
Message: request.Message,
|
Message: request.Message,
|
||||||
ErrorCode: request.ErrorCode,
|
ErrorCode: request.ErrorCode,
|
||||||
Retryable: request.Retryable,
|
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) {
|
func TestAIProviderFromDomainCopiesModels(t *testing.T) {
|
||||||
provider := domain.AIProvider{
|
provider := domain.AIProvider{
|
||||||
ID: "ai.openai",
|
ID: "ai.openai",
|
||||||
|
|||||||
@@ -51,6 +51,13 @@ type SCUMGuardedMutationBoundsDTO struct {
|
|||||||
MaxAffectedRows int `json:"maxAffectedRows"`
|
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 {
|
type SCUMSchemaProbeRequestDTO struct {
|
||||||
RequestID string `json:"requestId"`
|
RequestID string `json:"requestId"`
|
||||||
JobID string `json:"jobId"`
|
JobID string `json:"jobId"`
|
||||||
@@ -262,6 +269,48 @@ type SCUMGuardedMutationResultDTO struct {
|
|||||||
Limits SCUMGuardedMutationBoundsDTO `json:"limits"`
|
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 {
|
type SCUMCapabilityGateDTO struct {
|
||||||
Capability string `json:"capability"`
|
Capability string `json:"capability"`
|
||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
@@ -430,6 +479,22 @@ func SCUMGuardedMutationResultPtrToDomain(value *SCUMGuardedMutationResultDTO) *
|
|||||||
return &result
|
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 {
|
func SCUMSchemaProbeResultFromDomain(value domain.SCUMSchemaProbeResult) SCUMSchemaProbeResultDTO {
|
||||||
value = domain.CopySCUMSchemaProbeResult(value)
|
value = domain.CopySCUMSchemaProbeResult(value)
|
||||||
objects := make([]SCUMSchemaObjectEvidenceDTO, len(value.Objects))
|
objects := make([]SCUMSchemaObjectEvidenceDTO, len(value.Objects))
|
||||||
@@ -496,6 +561,23 @@ func SCUMGuardedMutationResultPtrFromDomain(value *domain.SCUMGuardedMutationRes
|
|||||||
return &result
|
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 {
|
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}
|
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 {
|
func scumGuardedMutationBoundsFromDomain(value domain.SCUMGuardedMutationBounds) SCUMGuardedMutationBoundsDTO {
|
||||||
return SCUMGuardedMutationBoundsDTO{MaxPayloadBytes: value.MaxPayloadBytes, TimeoutMS: value.TimeoutMS, BusyTimeoutMS: value.BusyTimeoutMS, MaxReadbackBytes: value.MaxReadbackBytes, MaxAffectedRows: value.MaxAffectedRows}
|
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.
|
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.
|
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.
|
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.
|
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
|
## 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:
|
`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.
|
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
|
## 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.
|
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,
|
InputRef: request.CheckpointRef,
|
||||||
IdempotencyKey: request.IdempotencyKey,
|
IdempotencyKey: request.IdempotencyKey,
|
||||||
Progress: domain.JobProgress{Percent: 0, Message: "historical log backfill queued"},
|
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 {
|
if err != nil {
|
||||||
_ = svc.recordAuditEvent(user.ID, "logs.backfill.denied", "server-instance", instance.ID, domain.AuditResultDenied, "log backfill denied: endpoint unsupported or offline")
|
_ = 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 {
|
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 {
|
if definition := job.ExecutionInput.Deployment; definition != nil {
|
||||||
receipt := result.ExecutionResult.DeploymentReceipt
|
receipt := result.ExecutionResult.DeploymentReceipt
|
||||||
if result.State == domain.JobStateSucceeded && definition.Mode == domain.ServerDeploymentModeCustom && receipt == nil {
|
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
|
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:
|
case domain.JobCapabilityClientManagerDeploy:
|
||||||
if result.State == domain.JobStateSucceeded && result.ExecutionResult.Kind != "client-manager.deployed" {
|
if result.State == domain.JobStateSucceeded && result.ExecutionResult.Kind != "client-manager.deployed" {
|
||||||
return validationError("client-manager deploy result type is invalid")
|
return validationError("client-manager deploy result type is invalid")
|
||||||
@@ -787,7 +802,11 @@ func terminalFingerprint(result domain.RunJobResult) string {
|
|||||||
if result.ExecutionResult.GuardedMutation != nil {
|
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)
|
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 {
|
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) {
|
func TestCoreServiceRunJobReconcile(t *testing.T) {
|
||||||
svc, sessionToken := newRegisteredRunJobService(t)
|
svc, sessionToken := newRegisteredRunJobService(t)
|
||||||
createQueuedRunJob(t, svc, "job-1", "idem-1")
|
createQueuedRunJob(t, svc, "job-1", "idem-1")
|
||||||
@@ -350,7 +384,7 @@ func newRegisteredRunJobService(t *testing.T) (*CoreService, string) {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
svc := newTestCoreService()
|
svc := newTestCoreService()
|
||||||
helloRequest := validRunControlHello()
|
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"
|
helloRequest.CapabilityReport.Fingerprint = "cap-jobs"
|
||||||
hello, err := svc.RegisterRunHello(helloRequest)
|
hello, err := svc.RegisterRunHello(helloRequest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -379,7 +413,7 @@ func scumTypedRCONSourcePlan() *domain.RuntimeSourceRCONPlan {
|
|||||||
|
|
||||||
func createSCUMTemplateServerFixture(t *testing.T, svc *CoreService, binding domain.SCUMBindingIdentity) {
|
func createSCUMTemplateServerFixture(t *testing.T, svc *CoreService, binding domain.SCUMBindingIdentity) {
|
||||||
t.Helper()
|
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)
|
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 {
|
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}}
|
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 {
|
func createQueuedRunJob(t *testing.T, svc *CoreService, id string, idempotencyKey string) domain.Job {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
job, err := svc.CreateJob(domain.Job{
|
job, err := svc.CreateJob(domain.Job{
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const (
|
|||||||
scumSQLiteTemplateExecutionKind = "sqlite.template-query"
|
scumSQLiteTemplateExecutionKind = "sqlite.template-query"
|
||||||
scumRCONTemplateExecutionKind = "rcon.template-command"
|
scumRCONTemplateExecutionKind = "rcon.template-command"
|
||||||
scumGuardedMutationExecutionKind = "sqlite.guarded-mutation"
|
scumGuardedMutationExecutionKind = "sqlite.guarded-mutation"
|
||||||
|
scumParsedLogBatchExecutionKind = "log.parsed-events"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (svc *CoreService) RequestSCUMSchemaProbeForSession(sessionID, serverInstanceID, idempotencyKey string) (domain.SCUMSchemaProbeRequest, domain.RemoteAdapterResult, error) {
|
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
|
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 {
|
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
|
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())
|
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)
|
return finish(violations)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1596,6 +1596,11 @@ func ValidateJob(job domain.Job) error {
|
|||||||
violations = append(violations, "executionResult.guardedMutation: "+err.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.Capability == domain.JobCapabilityConfigWrite || job.Capability == domain.JobCapabilityFilesRead || job.Capability == domain.JobCapabilityFilesWrite {
|
||||||
if job.ServerInstanceID == "" {
|
if job.ServerInstanceID == "" {
|
||||||
violations = append(violations, "serverInstanceId is required for scoped file jobs")
|
violations = append(violations, "serverInstanceId is required for scoped file jobs")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package validator
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -26,6 +27,10 @@ const (
|
|||||||
maxSCUMRCONConfirmRecords = 128
|
maxSCUMRCONConfirmRecords = 128
|
||||||
maxSCUMMutationPayloadBytes = 4096
|
maxSCUMMutationPayloadBytes = 4096
|
||||||
maxSCUMMutationReadbackBytes = 64 * 1024
|
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}$`)
|
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)
|
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 } {
|
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}}
|
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
|
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 {
|
func validateSCUMTemplateKey(prefix, value string) []string {
|
||||||
var violations []string
|
var violations []string
|
||||||
violations = appendRequired(violations, prefix, value)
|
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 {
|
func validSCUMCapabilityEvidenceStatus(value domain.SCUMCapabilityEvidenceStatus) bool {
|
||||||
switch value {
|
switch value {
|
||||||
case domain.SCUMCapabilityEvidenceMissing, domain.SCUMCapabilityEvidenceCompatible, domain.SCUMCapabilityEvidenceIncompatible, domain.SCUMCapabilityEvidenceFailed:
|
case domain.SCUMCapabilityEvidenceMissing, domain.SCUMCapabilityEvidenceCompatible, domain.SCUMCapabilityEvidenceIncompatible, domain.SCUMCapabilityEvidenceFailed:
|
||||||
@@ -821,3 +959,23 @@ func containsSCUMProtectedMaterial(value string) bool {
|
|||||||
}
|
}
|
||||||
return false
|
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) {
|
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}}
|
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 {
|
if err := ValidateSCUMCapabilityEvidence(evidence); err != nil {
|
||||||
@@ -250,3 +271,39 @@ func validatorSCUMGuardedMutationResult() domain.SCUMGuardedMutationResult {
|
|||||||
request := validatorSCUMGuardedMutationRequest()
|
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}
|
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