From 467d5c3a8cb2332832a0974b0e2094b8ceafe6a1 Mon Sep 17 00:00:00 2001 From: npc0-hue Date: Thu, 13 Aug 2026 13:05:39 +0800 Subject: [PATCH] Freeze SCUM SQLite template run contract --- ...e-template-execution-handoff-2026-08-13.md | 31 +++ .../tasks.md | 13 +- platform/domain/job_channel.go | 1 + platform/domain/resources.go | 4 + platform/domain/scum_live_data.go | 103 ++++++++++ platform/dto/job_channel.go | 20 +- platform/dto/resources_test.go | 43 +++++ platform/dto/scum_live_data.go | 102 ++++++++++ platform/protocol/scum-live-data-contracts.md | 20 ++ platform/protocol/scum-run-integration.md | 6 +- platform/service/job_channel.go | 26 ++- platform/service/job_channel_test.go | 58 +++++- platform/service/scum_live_data.go | 25 ++- platform/validator/job_channel.go | 5 + platform/validator/resources.go | 13 ++ platform/validator/scum_live_data.go | 177 +++++++++++++++++- platform/validator/scum_live_data_test.go | 48 +++++ 17 files changed, 683 insertions(+), 12 deletions(-) create mode 100644 openspec/changes/replace-scum-projections-with-real-data-management/evidence/run-sqlite-template-execution-handoff-2026-08-13.md diff --git a/openspec/changes/replace-scum-projections-with-real-data-management/evidence/run-sqlite-template-execution-handoff-2026-08-13.md b/openspec/changes/replace-scum-projections-with-real-data-management/evidence/run-sqlite-template-execution-handoff-2026-08-13.md new file mode 100644 index 0000000..ee70b1d --- /dev/null +++ b/openspec/changes/replace-scum-projections-with-real-data-management/evidence/run-sqlite-template-execution-handoff-2026-08-13.md @@ -0,0 +1,31 @@ +# Run SQLite Template Execution 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 database-backed reads by itself. + +## Positive prompt (正向提示词) + +Implement generic packaged SQLite-template execution for Run's `remote.run.db.sqlite.query` capability so Platform can dispatch current-service SCUM read jobs without sending SQL text, host paths, credentials, sockets, raw XML, raw RCON, or browser-supplied table names. Success means Run accepts only a typed leased `sqliteTemplate` request containing a logical target key, template key, adapter/schema version, immutable asset digest, canonical parameter digest, bounded scalar parameters, and strict limits; executes a query-only package-resolved SQLite template; and returns a typed `sqlite.template-query` terminal envelope that Platform can validate against the original durable job. + +## Directional prompt (方向提示词) + +Work only in the independent Run repository. Preserve Run as a generic executor: resolve package-scoped logical `databases/...` targets and packaged assets from the generated Run workspace, verify the asset digest and adapter/schema fingerprint, validate canonical bounded parameters, open SQLite in query-only/read-only mode or use a fenced short-lived read-only snapshot, enforce one-statement validation, reject mutation/DDL/`ATTACH`/extension loading/write PRAGMAs/multi-statement input, bind parameters, apply short busy and operation timeouts, honor cancellation, and enforce row/result-byte limits. Return the terminal envelope through the existing signed job-result channel with request/job/binding identity, capability, target/template key, adapter version, schema fingerprint, asset digest, parameter digest, source fingerprint, observed time, result digest, row count, bounded rows, truncation flag, applied limits, status, and stable safe error code. + +Expected Run verification: focused protocol/runtime tests for valid template execution, digest mismatch, schema mismatch, parameter validation, cancellation, busy/timeout handling, result limits, one-statement enforcement, mutation/DDL/`ATTACH`/extension/write-PRAGMA rejection, duplicate/late terminal result behavior, and `go test ./...` from the Run repository root. After implementation, record the tested Run commit, distribution/deployment evidence for the active binding, and safe terminal-envelope evidence back in this browser-repository change before enabling DB-backed read gates. + +## Boundary prompt (任务边界) + +Do not edit or vendor Run source into this browser repository, add a `run/` tree here, download or parse `SCUM.db` on the platform/plugin/browser side, expose raw SQL/RCON/XML/paths/credentials/sockets/IPs/player identities in evidence, accept browser command/query text, add SCUM-specific executor branches, infer SCUM table semantics inside Run, enable write capabilities, enable database-backed read gates before tested Run evidence is recorded, or treat this handoff as product acceptance evidence. Run must remain game-agnostic and execute only package-declared generic assets under the active signed binding and lease. + +## Browser-side frozen contract + +- Platform domain/DTO contracts define `SCUMSQLiteTemplateRequest`, `SCUMSQLiteTemplateResult`, bounded template limits, scalar parameters/rows, and stable terminal statuses. +- Job-channel DTOs expose leased `executionInput.sqliteTemplate` to Run and parse terminal `executionResult.sqliteTemplate` from Run. +- Validators reject unsafe template keys, protected material, raw SQL/path-like values, unsupported capabilities, invalid digests, loose bounds, mismatched row counts, and unsafe result rows. +- Service job completion accepts `sqlite.template-query` only for `remote.run.db.sqlite.query`, requires the typed result on success, checks leased job/binding/template/schema/asset/parameter identity, and includes typed result digests in terminal idempotency fingerprints. + +## Remaining evidence required before enabling reads + +- Tested Run commit and `go test ./...` output from the independent Run repository. +- Generated Run package carrying the packaged query assets and immutable digests. +- Active binding deployment evidence showing the compatible Run advertises and executes `remote.run.db.sqlite.query` through the typed envelope. +- Platform acceptance evidence for at least one safe read-only template job with no raw SQL, host paths, credentials, sockets, raw XML, raw RCON, or browser-supplied query material. diff --git a/openspec/changes/replace-scum-projections-with-real-data-management/tasks.md b/openspec/changes/replace-scum-projections-with-real-data-management/tasks.md index 19f9bf9..13ef726 100644 --- a/openspec/changes/replace-scum-projections-with-real-data-management/tasks.md +++ b/openspec/changes/replace-scum-projections-with-real-data-management/tasks.md @@ -106,11 +106,18 @@ - Platform now has an internal-only `RequestSCUMSchemaProbeForSession` path that builds a durable `remote.run.db.sqlite.probe` job from the SCUM plugin's manifest declaration, active runtime binding, logical target key, adapter version, and bounded probe limits; public remote-adapter and plugin-page requests for the probe capability are denied. - Run terminal results may carry `executionResult.sqliteSchemaProbe`; Platform DTO/domain/validator/job-channel code validates the typed redacted envelope, job/request identity, and binding fence before persisting it on the durable job. - Generated Run packages now carry redacted autonomous lifecycle `dataTargets` entries for plugin-owned sqlite snapshots, and browser-facing runtime-profile responses continue to omit those source declarations. -- Focused evidence: `go test ./dto ./service -run 'Test(RunJobResultRequestParsesSQLiteSchemaProbeEnvelope|SCUMSchemaProbeDispatchIsPlatformScheduledAndFenced|RemoteAdapterRequestPropagatesTypedInputsToRunJob)'` and `(cd platform && go test ./...)` passed locally. These tests do not prove the active Windows Run deployment or current SCUM schema, so tasks 2.3-2.9 and 4.2-4.9 remain unchecked. +- Focused evidence: `go test ./dto ./service -run 'Test(RunJobResultRequestParsesSQLiteSchemaProbeEnvelope|SCUMSchemaProbeDispatchIsPlatformScheduledAndFenced|RemoteAdapterRequestPropagatesTypedInputsToRunJob)'` and `(cd platform && go test ./...)` passed locally. These tests did not prove the active Windows Run deployment or current SCUM schema, so the later current-service and external-Run acceptance tasks stayed gated until additional evidence was recorded. + +### SQLite-template contract freeze and Run handoff evidence (2026-08-13) + +- Platform protocol docs now freeze the read-only `sqliteTemplate` request and `sqlite.template-query` terminal envelope: template key, logical target key, adapter/schema fingerprint, asset digest, parameter digest, bounded scalar parameters, query-only execution limits, row/result-byte limits, cancellation, and stable safe status/error codes. +- Added Platform domain/DTO/job-channel/validator/service contracts for `SCUMSQLiteTemplateRequest` and `SCUMSQLiteTemplateResult`. Run assignments can carry only the typed template request, Run results can return only the typed envelope, and Platform verifies leased job identity, binding, capability, target/template key, schema fingerprint, asset digest, parameter digest, row count, and result digest before accepting a successful result. +- Added focused tests for DTO parsing, safe validator rejection of raw SQL/path-like material and loose bounds, typed row/result validation, service lease fencing, and digest mismatch rejection. Verification passed: `go test ./dto ./validator ./service -run 'Test(RunJobResultRequestParsesSQLite|ValidateSCUMSQLiteTemplate|CoreServiceRunJobSQLiteTemplateEnvelopeIsFencedToLease)'`. +- Recorded the separately rooted Run handoff prompt in `evidence/run-sqlite-template-execution-handoff-2026-08-13.md` with positive, directional, and boundary prompts. This is a contract handoff only; tasks 4.7, 4.9, and DB-backed read gates remain unchecked until tested Run commit/deployment/terminal-envelope evidence is recorded. - [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. -- [ ] 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. -- [ ] 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.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. - [ ] 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. - [ ] 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. diff --git a/platform/domain/job_channel.go b/platform/domain/job_channel.go index e0f8ba2..dc68fbf 100644 --- a/platform/domain/job_channel.go +++ b/platform/domain/job_channel.go @@ -436,6 +436,7 @@ func CopyRunJobAssignment(assignment RunJobAssignment) RunJobAssignment { assignment.ExecutionInput.DLLExtensions = append([]RuntimeDLLExtensionPlan(nil), assignment.ExecutionInput.DLLExtensions...) assignment.ExecutionInput.SourceRCON = CopyRuntimeSourceRCONPlan(assignment.ExecutionInput.SourceRCON) assignment.ExecutionInput.SQLiteSchemaProbe = CopySCUMSchemaProbeRequestPtr(assignment.ExecutionInput.SQLiteSchemaProbe) + assignment.ExecutionInput.SQLiteTemplate = CopySCUMSQLiteTemplateRequestPtr(assignment.ExecutionInput.SQLiteTemplate) return assignment } diff --git a/platform/domain/resources.go b/platform/domain/resources.go index 5fef306..3f6e444 100644 --- a/platform/domain/resources.go +++ b/platform/domain/resources.go @@ -1157,6 +1157,7 @@ type JobExecutionInput struct { Deployment *ServerDeploymentDefinition ServerDeploymentPlan *ServerDeploymentPlan SQLiteSchemaProbe *SCUMSchemaProbeRequest + SQLiteTemplate *SCUMSQLiteTemplateRequest } type ServerDeploymentPlan struct { @@ -1200,6 +1201,7 @@ type JobExecutionResult struct { AuditSummary string Content string SQLiteSchemaProbe *SCUMSchemaProbeResult + SQLiteTemplate *SCUMSQLiteTemplateResult ServerDeploymentEvidence *ServerDeploymentEvidence DeploymentReceipt *ServerDeploymentExecutionReceipt } @@ -2045,7 +2047,9 @@ func CopyJob(job Job) Job { job.ExecutionInput.SourceRCON = CopyRuntimeSourceRCONPlan(job.ExecutionInput.SourceRCON) job.ExecutionInput.ServerDeploymentPlan = CopyServerDeploymentPlan(job.ExecutionInput.ServerDeploymentPlan) job.ExecutionInput.SQLiteSchemaProbe = CopySCUMSchemaProbeRequestPtr(job.ExecutionInput.SQLiteSchemaProbe) + job.ExecutionInput.SQLiteTemplate = CopySCUMSQLiteTemplateRequestPtr(job.ExecutionInput.SQLiteTemplate) job.ExecutionResult.SQLiteSchemaProbe = CopySCUMSchemaProbeResultPtr(job.ExecutionResult.SQLiteSchemaProbe) + job.ExecutionResult.SQLiteTemplate = CopySCUMSQLiteTemplateResultPtr(job.ExecutionResult.SQLiteTemplate) job.ExecutionResult.ServerDeploymentEvidence = CopyServerDeploymentEvidence(job.ExecutionResult.ServerDeploymentEvidence) job.ExecutionResult.DeploymentReceipt = CopyServerDeploymentExecutionReceipt(job.ExecutionResult.DeploymentReceipt) if job.ExecutionInput.Deployment != nil { diff --git a/platform/domain/scum_live_data.go b/platform/domain/scum_live_data.go index 003beb7..5363808 100644 --- a/platform/domain/scum_live_data.go +++ b/platform/domain/scum_live_data.go @@ -59,6 +59,19 @@ const ( SCUMSafeErrorCancelled SCUMSafeErrorCode = "cancelled" SCUMSafeErrorSourceChanged SCUMSafeErrorCode = "source_changed" SCUMSafeErrorResultLimitExceeded SCUMSafeErrorCode = "result_limit_exceeded" + SCUMSafeErrorTemplateMissing SCUMSafeErrorCode = "template_missing" + SCUMSafeErrorTemplateMismatch SCUMSafeErrorCode = "template_digest_mismatch" + SCUMSafeErrorParameterInvalid SCUMSafeErrorCode = "parameter_schema_invalid" + SCUMSafeErrorRowLimitExceeded SCUMSafeErrorCode = "row_limit_exceeded" + SCUMSafeErrorResultSchemaInvalid SCUMSafeErrorCode = "result_schema_invalid" +) + +type SCUMTerminalResultStatus string + +const ( + SCUMTerminalResultSucceeded SCUMTerminalResultStatus = "succeeded" + SCUMTerminalResultFailed SCUMTerminalResultStatus = "failed" + SCUMTerminalResultCancelled SCUMTerminalResultStatus = "cancelled" ) type SCUMSafeError struct { @@ -93,6 +106,18 @@ func DefaultSCUMSchemaProbeBounds() SCUMSchemaProbeBounds { return SCUMSchemaProbeBounds{MaxObjects: 256, MaxColumnsPerObject: 128, MaxIndexesPerObject: 64, MaxForeignKeys: 64, MaxCardinalityReads: 64, MaxSampleRows: 3, TimeoutMS: 5000, MaxResultBytes: 512 * 1024} } +type SCUMSQLiteTemplateBounds struct { + MaxParameters int + MaxRows int + TimeoutMS int + BusyTimeoutMS int + MaxResultBytes int +} + +func DefaultSCUMSQLiteTemplateBounds() SCUMSQLiteTemplateBounds { + return SCUMSQLiteTemplateBounds{MaxParameters: 64, MaxRows: 500, TimeoutMS: 5000, BusyTimeoutMS: 250, MaxResultBytes: 1024 * 1024} +} + type SCUMSchemaProbeRequest struct { RequestID string JobID string @@ -101,6 +126,22 @@ type SCUMSchemaProbeRequest struct { RequestedAt time.Time } +type SCUMSQLiteTemplateRequest struct { + RequestID string + JobID string + Binding SCUMBindingIdentity + Capability SCUMDataCapability + TargetKey string + TemplateKey string + AdapterVersion string + RequiredSchemaFingerprint string + AssetDigest string + ParameterDigest string + Parameters map[string]any + Bounds SCUMSQLiteTemplateBounds + RequestedAt time.Time +} + type SCUMSchemaProbeDeclaration struct { Capability string TargetKey string @@ -168,6 +209,28 @@ type SCUMSchemaProbeResult struct { Limits SCUMSchemaProbeBounds } +type SCUMSQLiteTemplateResult struct { + RequestID string + JobID string + Binding SCUMBindingIdentity + Status SCUMTerminalResultStatus + Capability SCUMDataCapability + TargetKey string + TemplateKey string + AdapterVersion string + SchemaFingerprint string + AssetDigest string + ParameterDigest string + SourceFingerprint string + ObservedAt time.Time + ResultDigest string + RowCount int + Rows []map[string]any + Truncated bool + SafeError SCUMSafeError + Limits SCUMSQLiteTemplateBounds +} + type SCUMCapabilityRequirement struct { Capability SCUMDataCapability AdapterVersion string @@ -260,6 +323,46 @@ func CopySCUMCapabilityEvidence(value SCUMCapabilityEvidence) SCUMCapabilityEvid return value } +func CopySCUMSQLiteTemplateRequestPtr(value *SCUMSQLiteTemplateRequest) *SCUMSQLiteTemplateRequest { + if value == nil { + return nil + } + copy := *value + copy.Parameters = CopySCUMValueMap(value.Parameters) + return © +} + +func CopySCUMSQLiteTemplateResultPtr(value *SCUMSQLiteTemplateResult) *SCUMSQLiteTemplateResult { + if value == nil { + return nil + } + copy := *value + copy.Rows = CopySCUMRows(value.Rows) + return © +} + +func CopySCUMValueMap(value map[string]any) map[string]any { + if value == nil { + return nil + } + copy := make(map[string]any, len(value)) + for key, item := range value { + copy[key] = item + } + return copy +} + +func CopySCUMRows(rows []map[string]any) []map[string]any { + if rows == nil { + return nil + } + copy := make([]map[string]any, len(rows)) + for index, row := range rows { + copy[index] = CopySCUMValueMap(row) + } + return copy +} + func CopySCUMSchemaProbeResult(value SCUMSchemaProbeResult) SCUMSchemaProbeResult { value.Objects = append([]SCUMSchemaObjectEvidence(nil), value.Objects...) for index := range value.Objects { diff --git a/platform/dto/job_channel.go b/platform/dto/job_channel.go index e153aed..89d825e 100644 --- a/platform/dto/job_channel.go +++ b/platform/dto/job_channel.go @@ -112,6 +112,7 @@ type RunJobExecutionInputBody struct { Deployment *ServerDeploymentExecutionBody `json:"deployment,omitempty"` ServerDeploymentPlan *ServerDeploymentPlanBody `json:"serverDeploymentPlan,omitempty"` SQLiteSchemaProbe *RunSQLiteSchemaProbeRequestBody `json:"sqliteSchemaProbe,omitempty"` + SQLiteTemplate *RunSQLiteTemplateRequestBody `json:"sqliteTemplate,omitempty"` } type RunSQLiteSchemaProbeRequestBody struct { @@ -120,6 +121,20 @@ type RunSQLiteSchemaProbeRequestBody struct { Limits SCUMSchemaProbeBoundsDTO `json:"limits"` } +type RunSQLiteTemplateRequestBody struct { + RequestID string `json:"requestId"` + Binding SCUMBindingIdentityDTO `json:"binding"` + Capability string `json:"capability"` + TargetKey string `json:"targetKey"` + TemplateKey string `json:"templateKey"` + AdapterVersion string `json:"adapterVersion"` + RequiredSchemaFingerprint string `json:"requiredSchemaFingerprint"` + AssetDigest string `json:"assetDigest"` + ParameterDigest string `json:"parameterDigest"` + Parameters map[string]any `json:"parameters,omitempty"` + Limits SCUMSQLiteTemplateBoundsDTO `json:"limits"` +} + type ServerDeploymentPlanBody struct { SchemaVersion string `json:"schemaVersion"` Operation string `json:"operation"` @@ -197,6 +212,7 @@ type RunJobExecutionResultBody struct { AuditSummary string `json:"auditSummary,omitempty"` Content string `json:"content,omitempty"` SQLiteSchemaProbe *SCUMSchemaProbeResultDTO `json:"sqliteSchemaProbe,omitempty"` + SQLiteTemplate *SCUMSQLiteTemplateResultDTO `json:"sqliteTemplate,omitempty"` ServerDeploymentEvidence *ServerDeploymentEvidenceBody `json:"serverDeploymentEvidence,omitempty"` DeploymentReceipt *ServerDeploymentExecutionReceiptBody `json:"deploymentReceipt,omitempty"` } @@ -456,7 +472,7 @@ func (request RunJobResultRequest) ToDomain() domain.RunJobResult { Message: request.Message, ErrorCode: request.ErrorCode, Retryable: request.Retryable, - ExecutionResult: domain.JobExecutionResult{Kind: request.ExecutionResult.Kind, ProcessState: request.ExecutionResult.ProcessState, ExitClassification: request.ExecutionResult.ExitClassification, ExitCode: request.ExecutionResult.ExitCode, Version: request.ExecutionResult.Version, Checksum: request.ExecutionResult.Checksum, SizeBytes: request.ExecutionResult.SizeBytes, AuditSummary: request.ExecutionResult.AuditSummary, Content: request.ExecutionResult.Content, SQLiteSchemaProbe: SCUMSchemaProbeResultPtrToDomain(request.ExecutionResult.SQLiteSchemaProbe), 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), ServerDeploymentEvidence: serverDeploymentEvidenceToDomain(request.ExecutionResult.ServerDeploymentEvidence), DeploymentReceipt: deploymentReceiptToDomain(request.ExecutionResult.DeploymentReceipt)}, } } @@ -671,7 +687,7 @@ func RunJobAssignmentFromDomain(assignment domain.RunJobAssignment) RunJobAssign State: assignment.State, Progress: progressReportFromDomain(assignment.Progress), ResultRef: assignment.ResultRef, - ExecutionInput: RunJobExecutionInputBody{WorkspaceScope: assignment.ExecutionInput.WorkspaceScope, Content: assignment.ExecutionInput.Content, ExpectedVersion: assignment.ExecutionInput.ExpectedVersion, ExpectedChecksum: assignment.ExecutionInput.ExpectedChecksum, MaxReadBytes: assignment.ExecutionInput.MaxReadBytes, RemoteAdapterKey: assignment.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: assignment.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: assignment.ExecutionInput.TimeoutSeconds, PluginID: assignment.ExecutionInput.PluginID, LifecycleOperation: assignment.ExecutionInput.LifecycleOperation, TargetVersion: assignment.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(assignment.ExecutionInput.Inputs), LogSource: runtimeLogSourceFromDomain(assignment.ExecutionInput.LogSource), LogSources: runtimeLogSourcesFromDomain(assignment.ExecutionInput.LogSources), DLLExtensions: dllExtensionPlansFromDomain(assignment.ExecutionInput.DLLExtensions), SourceRCON: runtimeSourceRCONPlanFromDomain(assignment.ExecutionInput.SourceRCON), Deployment: deploymentExecutionFromDomain(assignment.ExecutionInput.Deployment), ServerDeploymentPlan: serverDeploymentPlanFromDomain(assignment.ExecutionInput.ServerDeploymentPlan), SQLiteSchemaProbe: runSQLiteSchemaProbeRequestPtrFromDomain(assignment.ExecutionInput.SQLiteSchemaProbe)}, + ExecutionInput: RunJobExecutionInputBody{WorkspaceScope: assignment.ExecutionInput.WorkspaceScope, Content: assignment.ExecutionInput.Content, ExpectedVersion: assignment.ExecutionInput.ExpectedVersion, ExpectedChecksum: assignment.ExecutionInput.ExpectedChecksum, MaxReadBytes: assignment.ExecutionInput.MaxReadBytes, RemoteAdapterKey: assignment.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: assignment.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: assignment.ExecutionInput.TimeoutSeconds, PluginID: assignment.ExecutionInput.PluginID, LifecycleOperation: assignment.ExecutionInput.LifecycleOperation, TargetVersion: assignment.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(assignment.ExecutionInput.Inputs), LogSource: runtimeLogSourceFromDomain(assignment.ExecutionInput.LogSource), LogSources: runtimeLogSourcesFromDomain(assignment.ExecutionInput.LogSources), DLLExtensions: dllExtensionPlansFromDomain(assignment.ExecutionInput.DLLExtensions), SourceRCON: runtimeSourceRCONPlanFromDomain(assignment.ExecutionInput.SourceRCON), Deployment: deploymentExecutionFromDomain(assignment.ExecutionInput.Deployment), ServerDeploymentPlan: serverDeploymentPlanFromDomain(assignment.ExecutionInput.ServerDeploymentPlan), SQLiteSchemaProbe: runSQLiteSchemaProbeRequestPtrFromDomain(assignment.ExecutionInput.SQLiteSchemaProbe), SQLiteTemplate: runSQLiteTemplateRequestPtrFromDomain(assignment.ExecutionInput.SQLiteTemplate)}, LeaseToken: assignment.LeaseToken, Attempt: assignment.Attempt, FencingToken: assignment.FencingToken, diff --git a/platform/dto/resources_test.go b/platform/dto/resources_test.go index bcab649..a62c44d 100644 --- a/platform/dto/resources_test.go +++ b/platform/dto/resources_test.go @@ -66,6 +66,49 @@ func TestRunJobResultRequestParsesSQLiteSchemaProbeEnvelope(t *testing.T) { } } +func TestRunJobResultRequestParsesSQLiteTemplateEnvelope(t *testing.T) { + payload := `{ + "runEndpointId":"run-local", + "sessionToken":"run-session", + "jobId":"job-query", + "leaseToken":"lease-query", + "attempt":1, + "state":"succeeded", + "progress":{"percent":100,"message":"done"}, + "executionResult":{ + "kind":"sqlite.template-query", + "sqliteTemplate":{ + "requestId":"request-query", + "jobId":"job-query", + "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", + "capability":"players.read", + "targetKey":"scum-database", + "templateKey":"players.active.v1", + "adapterVersion":"scum-live-data-v0", + "schemaFingerprint":"sha256:` + strings.Repeat("a", 64) + `", + "assetDigest":"sha256:` + strings.Repeat("d", 64) + `", + "parameterDigest":"sha256:` + strings.Repeat("e", 64) + `", + "sourceFingerprint":"sha256:` + strings.Repeat("c", 64) + `", + "observedAt":"2026-08-13T00:00:00Z", + "resultDigest":"sha256:` + strings.Repeat("b", 64) + `", + "rowCount":1, + "rows":[{"externalPlayerId":"player-redacted","fame":12.5,"online":true,"squadId":null}], + "limits":{"maxParameters":64,"maxRows":500,"timeoutMs":5000,"busyTimeoutMs":250,"maxResultBytes":1048576} + } + } + }` + var request RunJobResultRequest + if err := json.Unmarshal([]byte(payload), &request); err != nil { + t.Fatalf("unmarshal Run job result: %v", err) + } + domainRequest := request.ToDomain() + result := domainRequest.ExecutionResult.SQLiteTemplate + if result == nil || result.TemplateKey != "players.active.v1" || result.AssetDigest != "sha256:"+strings.Repeat("d", 64) || result.RowCount != 1 || result.Rows[0]["fame"].(float64) != 12.5 { + t.Fatalf("sqliteTemplate envelope did not parse: %+v", result) + } +} + func TestAIProviderFromDomainCopiesModels(t *testing.T) { provider := domain.AIProvider{ ID: "ai.openai", diff --git a/platform/dto/scum_live_data.go b/platform/dto/scum_live_data.go index 79efff9..50b1e8d 100644 --- a/platform/dto/scum_live_data.go +++ b/platform/dto/scum_live_data.go @@ -28,6 +28,14 @@ type SCUMSchemaProbeBoundsDTO struct { MaxResultBytes int `json:"maxResultBytes"` } +type SCUMSQLiteTemplateBoundsDTO struct { + MaxParameters int `json:"maxParameters"` + MaxRows int `json:"maxRows"` + TimeoutMS int `json:"timeoutMs"` + BusyTimeoutMS int `json:"busyTimeoutMs"` + MaxResultBytes int `json:"maxResultBytes"` +} + type SCUMSchemaProbeRequestDTO struct { RequestID string `json:"requestId"` JobID string `json:"jobId"` @@ -36,6 +44,22 @@ type SCUMSchemaProbeRequestDTO struct { RequestedAt time.Time `json:"requestedAt"` } +type SCUMSQLiteTemplateRequestDTO struct { + RequestID string `json:"requestId"` + JobID string `json:"jobId"` + Binding SCUMBindingIdentityDTO `json:"binding"` + Capability string `json:"capability"` + TargetKey string `json:"targetKey"` + TemplateKey string `json:"templateKey"` + AdapterVersion string `json:"adapterVersion"` + RequiredSchemaFingerprint string `json:"requiredSchemaFingerprint"` + AssetDigest string `json:"assetDigest"` + ParameterDigest string `json:"parameterDigest"` + Parameters map[string]any `json:"parameters,omitempty"` + Bounds SCUMSQLiteTemplateBoundsDTO `json:"bounds"` + RequestedAt time.Time `json:"requestedAt"` +} + type SCUMSchemaProbeDispatchRequest struct { IdempotencyKey string `json:"idempotencyKey"` } @@ -96,6 +120,28 @@ type SCUMSchemaProbeResultDTO struct { Limits SCUMSchemaProbeBoundsDTO `json:"limits"` } +type SCUMSQLiteTemplateResultDTO struct { + RequestID string `json:"requestId"` + JobID string `json:"jobId"` + Binding SCUMBindingIdentityDTO `json:"binding"` + Status string `json:"status"` + Capability string `json:"capability"` + TargetKey string `json:"targetKey"` + TemplateKey string `json:"templateKey"` + AdapterVersion string `json:"adapterVersion"` + SchemaFingerprint string `json:"schemaFingerprint,omitempty"` + AssetDigest string `json:"assetDigest"` + ParameterDigest string `json:"parameterDigest"` + SourceFingerprint string `json:"sourceFingerprint,omitempty"` + ObservedAt time.Time `json:"observedAt"` + ResultDigest string `json:"resultDigest,omitempty"` + RowCount int `json:"rowCount"` + Rows []map[string]any `json:"rows,omitempty"` + Truncated bool `json:"truncated,omitempty"` + SafeError SCUMSafeErrorDTO `json:"safeError,omitempty"` + Limits SCUMSQLiteTemplateBoundsDTO `json:"limits"` +} + type SCUMCapabilityGateDTO struct { Capability string `json:"capability"` State string `json:"state"` @@ -120,6 +166,22 @@ func SCUMSchemaProbeRequestPtrFromDomain(value *domain.SCUMSchemaProbeRequest) * return &result } +func SCUMSQLiteTemplateRequestToDomain(value SCUMSQLiteTemplateRequestDTO) domain.SCUMSQLiteTemplateRequest { + return domain.SCUMSQLiteTemplateRequest{RequestID: value.RequestID, JobID: value.JobID, Binding: scumBindingIdentityToDomain(value.Binding), Capability: domain.SCUMDataCapability(value.Capability), TargetKey: value.TargetKey, TemplateKey: value.TemplateKey, AdapterVersion: value.AdapterVersion, RequiredSchemaFingerprint: value.RequiredSchemaFingerprint, AssetDigest: value.AssetDigest, ParameterDigest: value.ParameterDigest, Parameters: domain.CopySCUMValueMap(value.Parameters), Bounds: scumSQLiteTemplateBoundsToDomain(value.Bounds), RequestedAt: value.RequestedAt} +} + +func SCUMSQLiteTemplateRequestFromDomain(value domain.SCUMSQLiteTemplateRequest) SCUMSQLiteTemplateRequestDTO { + return SCUMSQLiteTemplateRequestDTO{RequestID: value.RequestID, JobID: value.JobID, Binding: scumBindingIdentityFromDomain(value.Binding), Capability: string(value.Capability), TargetKey: value.TargetKey, TemplateKey: value.TemplateKey, AdapterVersion: value.AdapterVersion, RequiredSchemaFingerprint: value.RequiredSchemaFingerprint, AssetDigest: value.AssetDigest, ParameterDigest: value.ParameterDigest, Parameters: domain.CopySCUMValueMap(value.Parameters), Bounds: scumSQLiteTemplateBoundsFromDomain(value.Bounds), RequestedAt: value.RequestedAt} +} + +func SCUMSQLiteTemplateRequestPtrFromDomain(value *domain.SCUMSQLiteTemplateRequest) *SCUMSQLiteTemplateRequestDTO { + if value == nil { + return nil + } + result := SCUMSQLiteTemplateRequestFromDomain(*value) + return &result +} + func runSQLiteSchemaProbeRequestPtrFromDomain(value *domain.SCUMSchemaProbeRequest) *RunSQLiteSchemaProbeRequestBody { if value == nil { return nil @@ -127,6 +189,13 @@ func runSQLiteSchemaProbeRequestPtrFromDomain(value *domain.SCUMSchemaProbeReque return &RunSQLiteSchemaProbeRequestBody{RequestID: value.RequestID, Binding: scumBindingIdentityFromDomain(value.Binding), Limits: scumProbeBoundsFromDomain(value.Bounds)} } +func runSQLiteTemplateRequestPtrFromDomain(value *domain.SCUMSQLiteTemplateRequest) *RunSQLiteTemplateRequestBody { + if value == nil { + return nil + } + return &RunSQLiteTemplateRequestBody{RequestID: value.RequestID, Binding: scumBindingIdentityFromDomain(value.Binding), Capability: string(value.Capability), TargetKey: value.TargetKey, TemplateKey: value.TemplateKey, AdapterVersion: value.AdapterVersion, RequiredSchemaFingerprint: value.RequiredSchemaFingerprint, AssetDigest: value.AssetDigest, ParameterDigest: value.ParameterDigest, Parameters: domain.CopySCUMValueMap(value.Parameters), Limits: scumSQLiteTemplateBoundsFromDomain(value.Bounds)} +} + func SCUMSchemaProbeDispatchFromDomain(request domain.SCUMSchemaProbeRequest, queued domain.RemoteAdapterResult) SCUMSchemaProbeDispatchResponse { return SCUMSchemaProbeDispatchResponse{ProbeRequest: SCUMSchemaProbeRequestFromDomain(request), QueuedJob: RemoteAdapterFromDomain(queued)} } @@ -159,6 +228,18 @@ func SCUMSchemaProbeResultPtrToDomain(value *SCUMSchemaProbeResultDTO) *domain.S return &result } +func SCUMSQLiteTemplateResultToDomain(value SCUMSQLiteTemplateResultDTO) domain.SCUMSQLiteTemplateResult { + return domain.SCUMSQLiteTemplateResult{RequestID: value.RequestID, JobID: value.JobID, Binding: scumBindingIdentityToDomain(value.Binding), Status: domain.SCUMTerminalResultStatus(value.Status), Capability: domain.SCUMDataCapability(value.Capability), TargetKey: value.TargetKey, TemplateKey: value.TemplateKey, AdapterVersion: value.AdapterVersion, SchemaFingerprint: value.SchemaFingerprint, AssetDigest: value.AssetDigest, ParameterDigest: value.ParameterDigest, SourceFingerprint: value.SourceFingerprint, ObservedAt: value.ObservedAt, ResultDigest: value.ResultDigest, RowCount: value.RowCount, Rows: domain.CopySCUMRows(value.Rows), Truncated: value.Truncated, SafeError: domain.SCUMSafeError{Code: domain.SCUMSafeErrorCode(value.SafeError.Code), Message: value.SafeError.Message, Retryable: value.SafeError.Retryable}, Limits: scumSQLiteTemplateBoundsToDomain(value.Limits)} +} + +func SCUMSQLiteTemplateResultPtrToDomain(value *SCUMSQLiteTemplateResultDTO) *domain.SCUMSQLiteTemplateResult { + if value == nil { + return nil + } + result := SCUMSQLiteTemplateResultToDomain(*value) + return &result +} + func SCUMSchemaProbeResultFromDomain(value domain.SCUMSchemaProbeResult) SCUMSchemaProbeResultDTO { value = domain.CopySCUMSchemaProbeResult(value) objects := make([]SCUMSchemaObjectEvidenceDTO, len(value.Objects)) @@ -188,6 +269,19 @@ func SCUMSchemaProbeResultPtrFromDomain(value *domain.SCUMSchemaProbeResult) *SC return &result } +func SCUMSQLiteTemplateResultFromDomain(value domain.SCUMSQLiteTemplateResult) SCUMSQLiteTemplateResultDTO { + value = *domain.CopySCUMSQLiteTemplateResultPtr(&value) + return SCUMSQLiteTemplateResultDTO{RequestID: value.RequestID, JobID: value.JobID, Binding: scumBindingIdentityFromDomain(value.Binding), Status: string(value.Status), Capability: string(value.Capability), TargetKey: value.TargetKey, TemplateKey: value.TemplateKey, AdapterVersion: value.AdapterVersion, SchemaFingerprint: value.SchemaFingerprint, AssetDigest: value.AssetDigest, ParameterDigest: value.ParameterDigest, SourceFingerprint: value.SourceFingerprint, ObservedAt: value.ObservedAt, ResultDigest: value.ResultDigest, RowCount: value.RowCount, Rows: value.Rows, Truncated: value.Truncated, SafeError: SCUMSafeErrorDTO{Code: string(value.SafeError.Code), Message: value.SafeError.Message, Retryable: value.SafeError.Retryable}, Limits: scumSQLiteTemplateBoundsFromDomain(value.Limits)} +} + +func SCUMSQLiteTemplateResultPtrFromDomain(value *domain.SCUMSQLiteTemplateResult) *SCUMSQLiteTemplateResultDTO { + if value == nil { + return nil + } + result := SCUMSQLiteTemplateResultFromDomain(*value) + return &result +} + func SCUMCapabilityGateFromDomain(value domain.SCUMCapabilityGate) SCUMCapabilityGateDTO { return SCUMCapabilityGateDTO{Capability: string(value.Capability), State: string(value.State), Enabled: value.Enabled, ReasonCode: string(value.ReasonCode), Reason: value.Reason} } @@ -207,3 +301,11 @@ func scumProbeBoundsToDomain(value SCUMSchemaProbeBoundsDTO) domain.SCUMSchemaPr func scumProbeBoundsFromDomain(value domain.SCUMSchemaProbeBounds) SCUMSchemaProbeBoundsDTO { return SCUMSchemaProbeBoundsDTO{MaxObjects: value.MaxObjects, MaxColumnsPerObject: value.MaxColumnsPerObject, MaxIndexesPerObject: value.MaxIndexesPerObject, MaxForeignKeys: value.MaxForeignKeys, MaxCardinalityReads: value.MaxCardinalityReads, MaxSampleRows: value.MaxSampleRows, TimeoutMS: value.TimeoutMS, MaxResultBytes: value.MaxResultBytes} } + +func scumSQLiteTemplateBoundsToDomain(value SCUMSQLiteTemplateBoundsDTO) domain.SCUMSQLiteTemplateBounds { + return domain.SCUMSQLiteTemplateBounds{MaxParameters: value.MaxParameters, MaxRows: value.MaxRows, TimeoutMS: value.TimeoutMS, BusyTimeoutMS: value.BusyTimeoutMS, MaxResultBytes: value.MaxResultBytes} +} + +func scumSQLiteTemplateBoundsFromDomain(value domain.SCUMSQLiteTemplateBounds) SCUMSQLiteTemplateBoundsDTO { + return SCUMSQLiteTemplateBoundsDTO{MaxParameters: value.MaxParameters, MaxRows: value.MaxRows, TimeoutMS: value.TimeoutMS, BusyTimeoutMS: value.BusyTimeoutMS, MaxResultBytes: value.MaxResultBytes} +} diff --git a/platform/protocol/scum-live-data-contracts.md b/platform/protocol/scum-live-data-contracts.md index 8ee91d3..4283a50 100644 --- a/platform/protocol/scum-live-data-contracts.md +++ b/platform/protocol/scum-live-data-contracts.md @@ -41,6 +41,26 @@ The payload must not include a host database path, DSN, socket, credential, raw Samples are hashes/fingerprints only. Raw row content, XML payloads, SQL, paths, DSNs, sockets, credentials, host names, IPs, and RCON text are never returned to Platform Web, plugin pages, AI prompts, or safe diagnostic fields. +## SQLite template request + +`SCUMSQLiteTemplateRequest` is the Platform durable-job payload for read-only plugin-owned query assets after a capability-specific gate is compatible. Required fields are: + +- `requestId`, `jobId`; +- `binding`: `serverInstanceId`, `runBindingId`, `runEndpointId`, `pluginId`, `pluginVersion`, `adapterVersion`, `gameVersion`, `databaseIdentity`; +- `capability`, limited to database-backed read capabilities such as player, squad, vehicle, flag, and position reads; +- logical `targetKey`, `templateKey`, `adapterVersion`, `requiredSchemaFingerprint`, immutable `assetDigest`, and canonical `parameterDigest`; +- scalar `parameters` bounded by `maxParameters` and validated against the plugin-declared parameter schema; +- `bounds`: `maxParameters`, `maxRows`, `timeoutMs`, `busyTimeoutMs`, and `maxResultBytes`; +- `requestedAt`. + +The request never contains raw SQL, host/database paths, DSNs, sockets, credentials, raw XML, RCON text, browser-supplied table names, or undeclared parameters. Run resolves the logical target and packaged template inside the generated Run package. + +## SQLite template result + +`SCUMSQLiteTemplateResult` is the terminal envelope for `sqlite.template-query` results. Required fields are request/job/binding identity, status (`succeeded`, `failed`, or `cancelled`), read capability, target/template key, adapter version, schema fingerprint, asset digest, parameter digest, source fingerprint, observed time, result digest, row count, bounded rows, truncation flag, safe error, and limits actually applied. + +Platform accepts rows only when the terminal envelope matches the leased durable job's binding, template key, adapter/schema fingerprint, asset digest, and parameter digest. Late, duplicate, mismatched, stale, unsafe, over-limit, or schema-invalid results remain safe terminal failures and must not be converted into empty successful generations. + ## Release behavior The first-party SCUM plugin declares `scumLiveData` with `remote.run.db.sqlite.probe` and per-capability gates. Until current-service evidence exists, all gates remain `disabled` with `evidenceStatus: missing`. Query assets, RCON templates, XML mutations, map transforms, and gift transports may be added only after current-service probe evidence proves their adapter requirements; unsupported or ambiguous capabilities stay disabled independently. diff --git a/platform/protocol/scum-run-integration.md b/platform/protocol/scum-run-integration.md index 8b3b320..04ac756 100644 --- a/platform/protocol/scum-run-integration.md +++ b/platform/protocol/scum-run-integration.md @@ -26,7 +26,11 @@ Run executes the generic `remote.run.db.sqlite.probe` capability against the pac After probe evidence matches a plugin adapter, Platform can schedule plugin-owned read-only SQLite template jobs by template key, adapter/schema version, immutable asset digest, and bounded parameters. Platform does not build SQL strings, and the browser never submits query text or undeclared parameters. -Run verifies the packaged asset digest, enforces one approved read-only statement or introspection boundary, binds parameters, applies short busy/operation timeouts, row/result-byte limits, and returns a terminal envelope with safe rows matching the declared result schema. DDL, mutation, `ATTACH`, extension loading, write PRAGMAs, multi-statement input, and string-concatenated parameters are rejected. +The leased Run assignment carries a typed `sqliteTemplate` request only. Required fields are `requestId`, server/plugin binding, read capability, logical `targetKey`, `templateKey`, `adapterVersion`, `requiredSchemaFingerprint`, immutable `assetDigest`, canonical `parameterDigest`, bounded scalar `parameters`, and `limits` containing `maxParameters`, `maxRows`, `timeoutMs`, `busyTimeoutMs`, and `maxResultBytes`. The payload carries no SQL text, table names from the browser, database path, DSN, socket, credential, raw XML, RCON text, or host identifier. + +Run verifies the packaged asset digest, adapter/schema fingerprint, canonical parameter digest, and active binding before opening a query-only SQLite connection or fenced short-lived read-only snapshot. It enforces one approved read-only statement or introspection boundary, bound parameters, short busy/operation timeouts, cancellation, row/result-byte limits, and rejects DDL, mutation, `ATTACH`, extension loading, write PRAGMAs, multi-statement input, and string-concatenated parameters. + +The terminal `sqlite.template-query` envelope contains `requestId`, `jobId`, binding, status (`succeeded`, `failed`, or `cancelled`), capability, target/template key, adapter version, schema fingerprint, asset digest, parameter digest, source fingerprint, observed time, result digest, row count, bounded rows, truncation flag, safe error, and applied limits. Platform validates the envelope against the original durable job, lease attempt, binding, template key, schema fingerprint, asset digest, and parameter digest before any local SCUM generation can consume the rows. ## Parsed Log Event Jobs diff --git a/platform/service/job_channel.go b/platform/service/job_channel.go index 96228e1..ca47c71 100644 --- a/platform/service/job_channel.go +++ b/platform/service/job_channel.go @@ -346,6 +346,18 @@ func validateExecutionResultForJob(job domain.Job, result domain.RunJobResult) e return err } } + case domain.JobCapabilityRemoteRunDBSQLiteQuery: + if result.ExecutionResult.Kind != "" && result.ExecutionResult.Kind != scumSQLiteTemplateExecutionKind { + return validationError("SQLite template query result type is invalid") + } + if result.State == domain.JobStateSucceeded { + if result.ExecutionResult.SQLiteTemplate == nil { + return validationError("SQLite template terminal result is required") + } + if err := validateSCUMSQLiteTemplateResultForJob(job, *result.ExecutionResult.SQLiteTemplate); err != nil { + return err + } + } case domain.JobCapabilityClientManagerDeploy: if result.State == domain.JobStateSucceeded && result.ExecutionResult.Kind != "client-manager.deployed" { return validationError("client-manager deploy result type is invalid") @@ -637,7 +649,7 @@ func firstEligibleSupportedJob(jobs []domain.Job, capabilities []string, stamp t func assignmentFromJob(job domain.Job, leaseToken string) domain.RunJobAssignment { fencingToken := uint64(0) - if isProtectedRequestCapability(job.Capability) || job.Capability == domain.JobCapabilityRemoteRunDBSQLiteProbe { + if isProtectedRequestCapability(job.Capability) || job.Capability == domain.JobCapabilityRemoteRunDBSQLiteProbe || job.Capability == domain.JobCapabilityRemoteRunDBSQLiteQuery { fencingToken = uint64(job.Attempt) } return domain.RunJobAssignment{ @@ -651,7 +663,7 @@ func assignmentFromJob(job domain.Job, leaseToken string) domain.RunJobAssignmen State: job.State, Progress: domain.RunJobProgressReport{Percent: job.Progress.Percent, Phase: job.Progress.Phase, Message: job.Progress.Message}, ResultRef: job.ResultRef, - ExecutionInput: domain.JobExecutionInput{WorkspaceScope: job.ExecutionInput.WorkspaceScope, Content: job.ExecutionInput.Content, ExpectedVersion: job.ExecutionInput.ExpectedVersion, ExpectedChecksum: job.ExecutionInput.ExpectedChecksum, MaxReadBytes: job.ExecutionInput.MaxReadBytes, RemoteAdapterKey: job.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: job.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: job.ExecutionInput.TimeoutSeconds, PluginID: job.ExecutionInput.PluginID, LifecycleOperation: job.ExecutionInput.LifecycleOperation, TargetVersion: job.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(job.ExecutionInput.Inputs), LogSource: domain.CopyRuntimeLogSourcePtr(job.ExecutionInput.LogSource), LogSources: domain.CopyRuntimeLogSources(job.ExecutionInput.LogSources), DLLExtensions: append([]domain.RuntimeDLLExtensionPlan(nil), job.ExecutionInput.DLLExtensions...), SourceRCON: domain.CopyRuntimeSourceRCONPlan(job.ExecutionInput.SourceRCON), Deployment: deploymentPlanForDispatchValue(job.ExecutionInput.Deployment), ServerDeploymentPlan: domain.CopyServerDeploymentPlan(job.ExecutionInput.ServerDeploymentPlan), SQLiteSchemaProbe: domain.CopySCUMSchemaProbeRequestPtr(job.ExecutionInput.SQLiteSchemaProbe)}, + ExecutionInput: domain.JobExecutionInput{WorkspaceScope: job.ExecutionInput.WorkspaceScope, Content: job.ExecutionInput.Content, ExpectedVersion: job.ExecutionInput.ExpectedVersion, ExpectedChecksum: job.ExecutionInput.ExpectedChecksum, MaxReadBytes: job.ExecutionInput.MaxReadBytes, RemoteAdapterKey: job.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: job.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: job.ExecutionInput.TimeoutSeconds, PluginID: job.ExecutionInput.PluginID, LifecycleOperation: job.ExecutionInput.LifecycleOperation, TargetVersion: job.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(job.ExecutionInput.Inputs), LogSource: domain.CopyRuntimeLogSourcePtr(job.ExecutionInput.LogSource), LogSources: domain.CopyRuntimeLogSources(job.ExecutionInput.LogSources), DLLExtensions: append([]domain.RuntimeDLLExtensionPlan(nil), job.ExecutionInput.DLLExtensions...), SourceRCON: domain.CopyRuntimeSourceRCONPlan(job.ExecutionInput.SourceRCON), Deployment: deploymentPlanForDispatchValue(job.ExecutionInput.Deployment), ServerDeploymentPlan: domain.CopyServerDeploymentPlan(job.ExecutionInput.ServerDeploymentPlan), SQLiteSchemaProbe: domain.CopySCUMSchemaProbeRequestPtr(job.ExecutionInput.SQLiteSchemaProbe), SQLiteTemplate: domain.CopySCUMSQLiteTemplateRequestPtr(job.ExecutionInput.SQLiteTemplate)}, LeaseToken: leaseToken, Attempt: job.Attempt, FencingToken: fencingToken, @@ -729,7 +741,15 @@ func jobRetryBackoff(policy domain.JobRetryPolicy, attempt int) time.Duration { } func terminalFingerprint(result domain.RunJobResult) string { - return fmt.Sprintf("%s|%d|%s|%s|%s|%s|%t", result.State, result.Progress.Percent, result.ResultRef, result.Message, result.ErrorCode, result.Progress.Message, result.Retryable) + schemaProbeFingerprint := "" + if result.ExecutionResult.SQLiteSchemaProbe != nil { + schemaProbeFingerprint = result.ExecutionResult.SQLiteSchemaProbe.ResultDigest + } + sqliteTemplateFingerprint := "" + if result.ExecutionResult.SQLiteTemplate != nil { + sqliteTemplateFingerprint = fmt.Sprintf("%s|%s|%s|%d", result.ExecutionResult.SQLiteTemplate.ResultDigest, result.ExecutionResult.SQLiteTemplate.AssetDigest, result.ExecutionResult.SQLiteTemplate.ParameterDigest, result.ExecutionResult.SQLiteTemplate.RowCount) + } + return fmt.Sprintf("%s|%d|%s|%s|%s|%s|%t|%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) } func terminalMessage(result domain.RunJobResult) string { diff --git a/platform/service/job_channel_test.go b/platform/service/job_channel_test.go index 78390ec..71a209b 100644 --- a/platform/service/job_channel_test.go +++ b/platform/service/job_channel_test.go @@ -3,6 +3,7 @@ package service import ( "strings" "testing" + "time" "browser.local/platform/domain" ) @@ -212,6 +213,41 @@ func TestCoreServiceRunJobTerminalResultIsIdempotent(t *testing.T) { } } +func TestCoreServiceRunJobSQLiteTemplateEnvelopeIsFencedToLease(t *testing.T) { + svc, sessionToken := newRegisteredRunJobService(t) + request := domain.SCUMSQLiteTemplateRequest{RequestID: "request-query", JobID: "job-query", Binding: scumTemplateTestBinding(), Capability: domain.SCUMDataCapabilityPlayerRead, TargetKey: "scum-database", TemplateKey: "players.active.v1", AdapterVersion: "adapter-1", RequiredSchemaFingerprint: scumTemplateTestHash(), AssetDigest: scumTemplateTestHash(), ParameterDigest: scumTemplateTestHash(), Parameters: map[string]any{"limit": 100.0}, Bounds: domain.DefaultSCUMSQLiteTemplateBounds(), RequestedAt: time.Now()} + createSCUMTemplateServerFixture(t, svc, request.Binding) + job, err := svc.CreateJob(domain.Job{ID: request.JobID, ServerInstanceID: request.Binding.ServerInstanceID, RunEndpointID: request.Binding.RunEndpointID, Capability: domain.JobCapabilityRemoteRunDBSQLiteQuery, TargetKey: request.TargetKey, InputRef: "input://sqlite-template/request-query", IdempotencyKey: "idem-query", ExecutionInput: domain.JobExecutionInput{SQLiteTemplate: &request}}) + if err != nil { + t.Fatalf("create sqlite query job: %v", err) + } + claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: request.Binding.RunEndpointID, SessionToken: sessionToken, Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}, Capacity: domain.RunCapacity{MaxJobs: 4}}) + if err != nil || !claim.HasJob || claim.Job.JobID != job.ID || claim.Job.ExecutionInput.SQLiteTemplate == nil { + t.Fatalf("claim sqlite query job: claim=%+v err=%v", claim, err) + } + result := scumTemplateTestResult(request) + if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: request.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: "query complete"}, Message: "query complete", ExecutionResult: domain.JobExecutionResult{Kind: scumSQLiteTemplateExecutionKind, SQLiteTemplate: &result, AuditSummary: "redacted sqlite template query"}}); err != nil { + t.Fatalf("complete matching sqlite query: %v", err) + } + + badRequest := request + badRequest.RequestID = "request-query-bad" + badRequest.JobID = "job-query-bad" + if _, err := svc.CreateJob(domain.Job{ID: badRequest.JobID, ServerInstanceID: badRequest.Binding.ServerInstanceID, RunEndpointID: badRequest.Binding.RunEndpointID, Capability: domain.JobCapabilityRemoteRunDBSQLiteQuery, TargetKey: badRequest.TargetKey, InputRef: "input://sqlite-template/request-query-bad", IdempotencyKey: "idem-query-bad", ExecutionInput: domain.JobExecutionInput{SQLiteTemplate: &badRequest}}); err != nil { + t.Fatalf("create bad sqlite query job: %v", err) + } + badClaim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: badRequest.Binding.RunEndpointID, SessionToken: sessionToken, Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}, Capacity: domain.RunCapacity{MaxJobs: 4}}) + if err != nil { + t.Fatalf("claim bad sqlite query job: %v", err) + } + badResult := scumTemplateTestResult(badRequest) + badResult.AssetDigest = "sha256:" + strings.Repeat("b", 64) + _, err = svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: badRequest.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: "query complete"}, Message: "query complete", ExecutionResult: domain.JobExecutionResult{Kind: scumSQLiteTemplateExecutionKind, SQLiteTemplate: &badResult, AuditSummary: "redacted sqlite template query"}}) + if err == nil || !strings.Contains(err.Error(), "digest") { + t.Fatalf("expected digest-fenced query result rejection, got %v", err) + } +} + func TestCoreServiceRunJobReconcile(t *testing.T) { svc, sessionToken := newRegisteredRunJobService(t) createQueuedRunJob(t, svc, "job-1", "idem-1") @@ -242,7 +278,7 @@ func newRegisteredRunJobService(t *testing.T) (*CoreService, string) { t.Helper() svc := newTestCoreService() helloRequest := validRunControlHello() - helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, "process.start") + helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, "process.start", domain.JobCapabilityRemoteRunDBSQLiteQuery) helloRequest.CapabilityReport.Fingerprint = "cap-jobs" hello, err := svc.RegisterRunHello(helloRequest) if err != nil { @@ -251,6 +287,26 @@ func newRegisteredRunJobService(t *testing.T) (*CoreService, string) { return svc, hello.SessionToken } +func scumTemplateTestHash() string { return "sha256:" + strings.Repeat("a", 64) } + +func scumTemplateTestBinding() domain.SCUMBindingIdentity { + return domain.SCUMBindingIdentity{ServerInstanceID: "server-scum", RunBindingID: "binding-scum", RunEndpointID: "run-local", PluginID: "game.scum", PluginVersion: "0.1.6", AdapterVersion: "adapter-1", GameVersion: "scum-1", DatabaseIdentity: "scum-database"} +} + +func createSCUMTemplateServerFixture(t *testing.T, svc *CoreService, binding domain.SCUMBindingIdentity) { + t.Helper() + if _, err := svc.CreateGamePlugin(domain.GamePlugin{ID: binding.PluginID, Name: "SCUM", Version: binding.PluginVersion, ServerType: "scum", ManifestRef: "artifact://manifests/game.scum/0.1.6", CreateFormSchemaRef: "artifact://schemas/game.scum/create-form/0.1.6", RequiredRunCapabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}, DeclaredPermissions: []string{"server.remote.access"}, Permissions: domain.PluginPermissions{RemoteAccess: true}}); err != nil { + t.Fatalf("create SCUM plugin fixture: %v", err) + } + if _, err := svc.CreateServerInstance(domain.ServerInstance{ID: binding.ServerInstanceID, PluginID: binding.PluginID, RunEndpointID: binding.RunEndpointID, Name: "SCUM"}); err != nil { + t.Fatalf("create SCUM server fixture: %v", err) + } +} + +func scumTemplateTestResult(request domain.SCUMSQLiteTemplateRequest) domain.SCUMSQLiteTemplateResult { + return domain.SCUMSQLiteTemplateResult{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, ParameterDigest: request.ParameterDigest, SourceFingerprint: scumTemplateTestHash(), ObservedAt: time.Now(), ResultDigest: scumTemplateTestHash(), RowCount: 1, Rows: []map[string]any{{"externalPlayerId": "player-redacted", "displayName": "Known Player"}}, Limits: request.Bounds, SafeError: domain.SCUMSafeError{Code: domain.SCUMSafeErrorNone}} +} + func createQueuedRunJob(t *testing.T, svc *CoreService, id string, idempotencyKey string) domain.Job { t.Helper() job, err := svc.CreateJob(domain.Job{ diff --git a/platform/service/scum_live_data.go b/platform/service/scum_live_data.go index 18ed8b8..9c8745b 100644 --- a/platform/service/scum_live_data.go +++ b/platform/service/scum_live_data.go @@ -9,7 +9,10 @@ import ( "browser.local/platform/validator" ) -const scumSchemaProbeExecutionKind = "sqlite.schema-probe" +const ( + scumSchemaProbeExecutionKind = "sqlite.schema-probe" + scumSQLiteTemplateExecutionKind = "sqlite.template-query" +) func (svc *CoreService) RequestSCUMSchemaProbeForSession(sessionID, serverInstanceID, idempotencyKey string) (domain.SCUMSchemaProbeRequest, domain.RemoteAdapterResult, error) { idempotencyKey = strings.TrimSpace(idempotencyKey) @@ -149,6 +152,26 @@ func validateSCUMSchemaProbeResultForJob(job domain.Job, result domain.SCUMSchem return nil } +func validateSCUMSQLiteTemplateResultForJob(job domain.Job, result domain.SCUMSQLiteTemplateResult) error { + if err := validator.ValidateSCUMSQLiteTemplateResult(result); err != nil { + return err + } + expected := job.ExecutionInput.SQLiteTemplate + if expected == nil { + return validationError("SQLite template request is missing from leased job") + } + if result.JobID != job.ID || result.JobID != expected.JobID || result.RequestID != expected.RequestID { + return validationError("SQLite template result does not match leased job identity") + } + if !sameSCUMSchemaProbeBinding(result.Binding, expected.Binding) { + return validationError("SQLite template result does not match leased binding identity") + } + if result.Capability != expected.Capability || result.TargetKey != expected.TargetKey || result.TemplateKey != expected.TemplateKey || result.AdapterVersion != expected.AdapterVersion || result.SchemaFingerprint != expected.RequiredSchemaFingerprint || result.AssetDigest != expected.AssetDigest || result.ParameterDigest != expected.ParameterDigest { + return validationError("SQLite template result does not match leased template, adapter, digest, or parameter identity") + } + return nil +} + func sameSCUMSchemaProbeBinding(a, b domain.SCUMBindingIdentity) bool { return a.ServerInstanceID == b.ServerInstanceID && a.RunBindingID == b.RunBindingID && a.RunEndpointID == b.RunEndpointID && a.PluginID == b.PluginID && a.PluginVersion == b.PluginVersion && a.AdapterVersion == b.AdapterVersion && a.GameVersion == b.GameVersion && a.DatabaseIdentity == b.DatabaseIdentity } diff --git a/platform/validator/job_channel.go b/platform/validator/job_channel.go index 3e22a9a..3cb948e 100644 --- a/platform/validator/job_channel.go +++ b/platform/validator/job_channel.go @@ -56,6 +56,11 @@ func ValidateRunJobResult(result domain.RunJobResult) error { violations = append(violations, "executionResult.sqliteSchemaProbe: "+err.Error()) } } + if result.ExecutionResult.SQLiteTemplate != nil { + if err := ValidateSCUMSQLiteTemplateResult(*result.ExecutionResult.SQLiteTemplate); err != nil { + violations = append(violations, "executionResult.sqliteTemplate: "+err.Error()) + } + } return finish(violations) } diff --git a/platform/validator/resources.go b/platform/validator/resources.go index c045e77..00e36c5 100644 --- a/platform/validator/resources.go +++ b/platform/validator/resources.go @@ -1533,6 +1533,14 @@ func ValidateJob(job domain.Job) error { violations = append(violations, "executionInput.sourceRcon must not persist adapter inputs") } } + if job.ExecutionInput.SQLiteTemplate != nil { + if err := ValidateSCUMSQLiteTemplateRequest(*job.ExecutionInput.SQLiteTemplate); err != nil { + violations = append(violations, "executionInput.sqliteTemplate: "+err.Error()) + } + if job.Capability != domain.JobCapabilityRemoteRunDBSQLiteQuery { + violations = append(violations, "executionInput.sqliteTemplate is allowed only for sqlite query jobs") + } + } violations = append(violations, validateRemoteAdapterInputs("executionInput.inputs", job.ExecutionInput.Inputs)...) if job.ExecutionResult.Checksum != "" && !validSHA256Checksum(job.ExecutionResult.Checksum) { violations = append(violations, "executionResult.checksum must be sha256:") @@ -1548,6 +1556,11 @@ func ValidateJob(job domain.Job) error { violations = append(violations, "executionResult.sqliteSchemaProbe: "+err.Error()) } } + if job.ExecutionResult.SQLiteTemplate != nil { + if err := ValidateSCUMSQLiteTemplateResult(*job.ExecutionResult.SQLiteTemplate); err != nil { + violations = append(violations, "executionResult.sqliteTemplate: "+err.Error()) + } + } if job.Capability == domain.JobCapabilityConfigWrite || job.Capability == domain.JobCapabilityFilesRead || job.Capability == domain.JobCapabilityFilesWrite { if job.ServerInstanceID == "" { violations = append(violations, "serverInstanceId is required for scoped file jobs") diff --git a/platform/validator/scum_live_data.go b/platform/validator/scum_live_data.go index b30bd77..b3c7a74 100644 --- a/platform/validator/scum_live_data.go +++ b/platform/validator/scum_live_data.go @@ -16,10 +16,15 @@ const ( maxSCUMProbeSamples = 3 maxSCUMProbeTimeoutMS = 10000 maxSCUMProbeResultBytes = 1024 * 1024 + maxSCUMTemplateParameters = 64 + maxSCUMTemplateRows = 1000 + maxSCUMTemplateBusyTimeoutMS = 1000 + maxSCUMTemplateValueBytes = 4096 ) var scumHashPattern = regexp.MustCompile(`^sha256:[a-fA-F0-9]{64}$|^[a-fA-F0-9]{16,128}$`) var scumDataTargetSafeErrorPattern = regexp.MustCompile(`^data_target_[a-z0-9_]{1,80}$`) +var scumTemplateKeyPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_.-]{0,127}$`) func ValidateSCUMSchemaProbeRequest(request domain.SCUMSchemaProbeRequest) error { var violations []string @@ -30,6 +35,37 @@ func ValidateSCUMSchemaProbeRequest(request domain.SCUMSchemaProbeRequest) error return finish(violations) } +func ValidateSCUMSQLiteTemplateRequest(request domain.SCUMSQLiteTemplateRequest) error { + var violations []string + violations = appendRequired(violations, "requestId", request.RequestID) + violations = appendRequired(violations, "jobId", request.JobID) + violations = append(violations, validateSCUMBindingIdentity("binding", request.Binding)...) + if !validSCUMReadCapability(request.Capability) { + violations = append(violations, "capability must be a read capability") + } + violations = append(violations, validateSCUMTemplateKey("targetKey", request.TargetKey)...) + violations = append(violations, validateSCUMTemplateKey("templateKey", request.TemplateKey)...) + violations = appendRequired(violations, "adapterVersion", request.AdapterVersion) + if request.AdapterVersion != "" && containsSCUMProtectedMaterial(request.AdapterVersion) { + violations = append(violations, "adapterVersion contains protected material") + } + if request.AdapterVersion != "" && request.Binding.AdapterVersion != "" && request.AdapterVersion != request.Binding.AdapterVersion { + violations = append(violations, "adapterVersion must match binding.adapterVersion") + } + if !validSCUMFingerprint(request.RequiredSchemaFingerprint) { + violations = append(violations, "requiredSchemaFingerprint must be a digest/fingerprint") + } + if !validSCUMDigest(request.AssetDigest) { + violations = append(violations, "assetDigest must be sha256 digest") + } + if !validSCUMDigest(request.ParameterDigest) { + violations = append(violations, "parameterDigest must be sha256 digest") + } + violations = append(violations, validateSCUMSQLiteTemplateBounds("bounds", request.Bounds)...) + violations = append(violations, validateSCUMValueMap("parameters", request.Parameters, request.Bounds.MaxParameters)...) + return finish(violations) +} + func ValidateSCUMSchemaProbeResult(result domain.SCUMSchemaProbeResult) error { var violations []string violations = appendRequired(violations, "requestId", result.RequestID) @@ -59,6 +95,65 @@ func ValidateSCUMSchemaProbeResult(result domain.SCUMSchemaProbeResult) error { return finish(violations) } +func ValidateSCUMSQLiteTemplateResult(result domain.SCUMSQLiteTemplateResult) 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") + } + if !validSCUMReadCapability(result.Capability) { + violations = append(violations, "capability must be a read capability") + } + violations = append(violations, validateSCUMTemplateKey("targetKey", result.TargetKey)...) + violations = append(violations, validateSCUMTemplateKey("templateKey", result.TemplateKey)...) + violations = appendRequired(violations, "adapterVersion", result.AdapterVersion) + if result.AdapterVersion != "" && containsSCUMProtectedMaterial(result.AdapterVersion) { + violations = append(violations, "adapterVersion contains protected material") + } + if result.AdapterVersion != "" && result.Binding.AdapterVersion != "" && result.AdapterVersion != result.Binding.AdapterVersion { + violations = append(violations, "adapterVersion must match binding.adapterVersion") + } + if result.SchemaFingerprint != "" && !validSCUMFingerprint(result.SchemaFingerprint) { + violations = append(violations, "schemaFingerprint must be a digest/fingerprint") + } + if !validSCUMDigest(result.AssetDigest) { + violations = append(violations, "assetDigest must be sha256 digest") + } + if !validSCUMDigest(result.ParameterDigest) { + violations = append(violations, "parameterDigest must be sha256 digest") + } + if result.SourceFingerprint != "" && !validSCUMFingerprint(result.SourceFingerprint) { + violations = append(violations, "sourceFingerprint must be a digest/fingerprint") + } + if !validSCUMFingerprint(result.ResultDigest) { + violations = append(violations, "resultDigest must be a digest/fingerprint") + } + violations = append(violations, validateSCUMSafeError("safeError", result.SafeError)...) + violations = append(violations, validateSCUMSQLiteTemplateBounds("limits", result.Limits)...) + if result.RowCount != len(result.Rows) { + violations = append(violations, "rowCount must match returned rows") + } + if len(result.Rows) > result.Limits.MaxRows && result.Limits.MaxRows > 0 { + violations = append(violations, "rows exceeds declared limit") + } + for i, row := range result.Rows { + violations = append(violations, validateSCUMValueMap(fmt.Sprintf("rows[%d]", i), row, 256)...) + } + if result.Status == domain.SCUMTerminalResultSucceeded { + if result.SchemaFingerprint == "" || result.SourceFingerprint == "" { + violations = append(violations, "succeeded result requires schema/source fingerprints") + } + 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 ValidateSCUMCapabilityEvidence(evidence domain.SCUMCapabilityEvidence) error { var violations []string if !validSCUMDataCapability(evidence.Capability) { @@ -213,6 +308,68 @@ func validateSCUMSchemaProbeBounds(prefix string, value domain.SCUMSchemaProbeBo return violations } +func validateSCUMSQLiteTemplateBounds(prefix string, value domain.SCUMSQLiteTemplateBounds) []string { + var violations []string + if value.MaxParameters < 0 || value.MaxParameters > maxSCUMTemplateParameters { + violations = append(violations, prefix+".maxParameters is out of bounds") + } + if value.MaxRows < 1 || value.MaxRows > maxSCUMTemplateRows { + violations = append(violations, prefix+".maxRows is out of bounds") + } + if value.TimeoutMS < 1 || value.TimeoutMS > maxSCUMProbeTimeoutMS { + violations = append(violations, prefix+".timeoutMs is out of bounds") + } + if value.BusyTimeoutMS < 0 || value.BusyTimeoutMS > maxSCUMTemplateBusyTimeoutMS { + violations = append(violations, prefix+".busyTimeoutMs is out of bounds") + } + if value.MaxResultBytes < 1 || value.MaxResultBytes > maxSCUMProbeResultBytes { + violations = append(violations, prefix+".maxResultBytes is out of bounds") + } + return violations +} + +func validateSCUMTemplateKey(prefix, value string) []string { + var violations []string + violations = appendRequired(violations, prefix, value) + if value != "" && (!scumTemplateKeyPattern.MatchString(value) || containsSCUMProtectedMaterial(value) || strings.Contains(value, "..")) { + violations = append(violations, prefix+" is unsafe") + } + return violations +} + +func validateSCUMValueMap(prefix string, values map[string]any, maxItems int) []string { + var violations []string + if maxItems >= 0 && len(values) > maxItems { + violations = append(violations, prefix+" exceeds declared limit") + } + for key, value := range values { + if !scumTemplateKeyPattern.MatchString(key) || containsSCUMProtectedMaterial(key) || strings.Contains(key, "..") { + violations = append(violations, prefix+" key is unsafe") + } + field := prefix + ".value" + violations = append(violations, validateSCUMValue(field, value)...) + } + return violations +} + +func validateSCUMValue(prefix string, value any) []string { + var violations []string + switch item := value.(type) { + case nil, bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64: + return nil + case string: + if len([]byte(item)) > maxSCUMTemplateValueBytes { + violations = append(violations, prefix+" is too large") + } + if containsSCUMProtectedMaterial(item) { + violations = append(violations, prefix+" contains protected material") + } + default: + violations = append(violations, prefix+" must be a scalar value") + } + return violations +} + func validateSCUMSchemaObjectEvidence(prefix string, value domain.SCUMSchemaObjectEvidence) []string { var violations []string if !validSCUMFingerprint(value.ObjectHash) { @@ -283,6 +440,24 @@ func validSCUMDataCapability(value domain.SCUMDataCapability) bool { } } +func validSCUMReadCapability(value domain.SCUMDataCapability) bool { + switch value { + case domain.SCUMDataCapabilityPlayerRead, domain.SCUMDataCapabilityPlayerDetailRead, domain.SCUMDataCapabilitySquadRead, domain.SCUMDataCapabilitySquadMemberRead, domain.SCUMDataCapabilityVehicleRead, domain.SCUMDataCapabilityFlagRead, domain.SCUMDataCapabilityPositionRead: + return true + default: + return false + } +} + +func validSCUMTerminalResultStatus(value domain.SCUMTerminalResultStatus) bool { + switch value { + case domain.SCUMTerminalResultSucceeded, domain.SCUMTerminalResultFailed, domain.SCUMTerminalResultCancelled: + return true + default: + return false + } +} + func validSCUMCapabilityEvidenceStatus(value domain.SCUMCapabilityEvidenceStatus) bool { switch value { case domain.SCUMCapabilityEvidenceMissing, domain.SCUMCapabilityEvidenceCompatible, domain.SCUMCapabilityEvidenceIncompatible, domain.SCUMCapabilityEvidenceFailed: @@ -303,7 +478,7 @@ func validSCUMSchemaProbeResultStatus(value domain.SCUMCapabilityEvidenceStatus) func validSCUMSafeErrorCode(value domain.SCUMSafeErrorCode) bool { switch value { - case "", domain.SCUMSafeErrorNone, domain.SCUMSafeErrorProbeExecutorAbsent, domain.SCUMSafeErrorProbeMissing, domain.SCUMSafeErrorProbeFailed, domain.SCUMSafeErrorSchemaIncompatible, domain.SCUMSafeErrorBindingMismatch, domain.SCUMSafeErrorAdapterMismatch, domain.SCUMSafeErrorFingerprintMismatch, domain.SCUMSafeErrorDigestMismatch, domain.SCUMSafeErrorEvidenceExpired, domain.SCUMSafeErrorInvalidProbePayload, domain.SCUMSafeErrorInvalidRequest, domain.SCUMSafeErrorTargetUnavailable, domain.SCUMSafeErrorSourceUnavailable, domain.SCUMSafeErrorSQLiteOpenFailed, domain.SCUMSafeErrorSQLiteReadFailed, domain.SCUMSafeErrorDatabaseBusy, domain.SCUMSafeErrorTimeout, domain.SCUMSafeErrorCancelled, domain.SCUMSafeErrorSourceChanged, domain.SCUMSafeErrorResultLimitExceeded: + case "", domain.SCUMSafeErrorNone, domain.SCUMSafeErrorProbeExecutorAbsent, domain.SCUMSafeErrorProbeMissing, domain.SCUMSafeErrorProbeFailed, domain.SCUMSafeErrorSchemaIncompatible, domain.SCUMSafeErrorBindingMismatch, domain.SCUMSafeErrorAdapterMismatch, domain.SCUMSafeErrorFingerprintMismatch, domain.SCUMSafeErrorDigestMismatch, domain.SCUMSafeErrorEvidenceExpired, domain.SCUMSafeErrorInvalidProbePayload, domain.SCUMSafeErrorInvalidRequest, domain.SCUMSafeErrorTargetUnavailable, domain.SCUMSafeErrorSourceUnavailable, domain.SCUMSafeErrorSQLiteOpenFailed, domain.SCUMSafeErrorSQLiteReadFailed, domain.SCUMSafeErrorDatabaseBusy, domain.SCUMSafeErrorTimeout, domain.SCUMSafeErrorCancelled, domain.SCUMSafeErrorSourceChanged, domain.SCUMSafeErrorResultLimitExceeded, domain.SCUMSafeErrorTemplateMissing, domain.SCUMSafeErrorTemplateMismatch, domain.SCUMSafeErrorParameterInvalid, domain.SCUMSafeErrorRowLimitExceeded, domain.SCUMSafeErrorResultSchemaInvalid: return true default: return scumDataTargetSafeErrorPattern.MatchString(string(value)) diff --git a/platform/validator/scum_live_data_test.go b/platform/validator/scum_live_data_test.go index fc2130a..9643795 100644 --- a/platform/validator/scum_live_data_test.go +++ b/platform/validator/scum_live_data_test.go @@ -85,6 +85,45 @@ func TestValidateSCUMSchemaProbeResultAcceptsRunTerminalStatuses(t *testing.T) { } } +func TestValidateSCUMSQLiteTemplateRequestAllowsBoundedGenericTemplate(t *testing.T) { + request := validatorSCUMSQLiteTemplateRequest() + + if err := ValidateSCUMSQLiteTemplateRequest(request); err != nil { + t.Fatalf("expected valid SQLite template request, got %v", err) + } +} + +func TestValidateSCUMSQLiteTemplateRequestRejectsSQLPathsAndLooseBounds(t *testing.T) { + request := validatorSCUMSQLiteTemplateRequest() + request.TemplateKey = "select * from players" + request.Parameters = map[string]any{"profilePath": `C:\SCUM\Saved\SCUM.db`} + request.Bounds.MaxRows = 50000 + + err := ValidateSCUMSQLiteTemplateRequest(request) + if err == nil || !strings.Contains(err.Error(), "templateKey is unsafe") || !strings.Contains(err.Error(), "protected material") || !strings.Contains(err.Error(), "maxRows") { + t.Fatalf("expected template/key/bounds violations, got %v", err) + } +} + +func TestValidateSCUMSQLiteTemplateResultAcceptsTypedRows(t *testing.T) { + result := validatorSCUMSQLiteTemplateResult() + + if err := ValidateSCUMSQLiteTemplateResult(result); err != nil { + t.Fatalf("expected valid SQLite template result, got %v", err) + } +} + +func TestValidateSCUMSQLiteTemplateResultRejectsUnsafeRowsAndMismatchedCounts(t *testing.T) { + result := validatorSCUMSQLiteTemplateResult() + result.RowCount = 2 + result.Rows[0]["displayName"] = "select * from user_profile" + + err := ValidateSCUMSQLiteTemplateResult(result) + if err == nil || !strings.Contains(err.Error(), "rowCount") || !strings.Contains(err.Error(), "protected material") { + t.Fatalf("expected row-count and protected-row violations, got %v", err) + } +} + func TestValidateSCUMCapabilityEvidenceRequiresSafeCurrentServiceIdentity(t *testing.T) { evidence := domain.SCUMCapabilityEvidence{Capability: domain.SCUMDataCapabilityPlayerRead, Status: domain.SCUMCapabilityEvidenceCompatible, Binding: validatorSCUMBinding(), AdapterVersion: "adapter-1", SchemaFingerprint: scumProbeHash, ProbeResultDigest: scumProbeHash, AssetDigests: []string{scumProbeHash}, SafeError: domain.SCUMSafeError{Code: domain.SCUMSafeErrorNone}} if err := ValidateSCUMCapabilityEvidence(evidence); err != nil { @@ -100,3 +139,12 @@ func TestValidateSCUMCapabilityEvidenceRequiresSafeCurrentServiceIdentity(t *tes func validatorSCUMBinding() domain.SCUMBindingIdentity { return domain.SCUMBindingIdentity{ServerInstanceID: "server-1", RunBindingID: "binding-1", RunEndpointID: "run-1", PluginID: "game.scum", PluginVersion: "0.1.6", AdapterVersion: "adapter-1", GameVersion: "scum-1", DatabaseIdentity: "db-fingerprint-1"} } + +func validatorSCUMSQLiteTemplateRequest() domain.SCUMSQLiteTemplateRequest { + return domain.SCUMSQLiteTemplateRequest{RequestID: "request-1", JobID: "job-1", Binding: validatorSCUMBinding(), Capability: domain.SCUMDataCapabilityPlayerRead, TargetKey: "scum-database", TemplateKey: "players.active.v1", AdapterVersion: "adapter-1", RequiredSchemaFingerprint: scumProbeHash, AssetDigest: scumProbeHash, ParameterDigest: scumProbeHash, Parameters: map[string]any{"cursor": "", "limit": 100.0}, Bounds: domain.DefaultSCUMSQLiteTemplateBounds(), RequestedAt: time.Now()} +} + +func validatorSCUMSQLiteTemplateResult() domain.SCUMSQLiteTemplateResult { + request := validatorSCUMSQLiteTemplateRequest() + return domain.SCUMSQLiteTemplateResult{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, ParameterDigest: request.ParameterDigest, SourceFingerprint: scumProbeHash, ObservedAt: time.Now(), ResultDigest: scumProbeHash, RowCount: 1, Rows: []map[string]any{{"externalPlayerId": "player-redacted", "displayName": "Known Player", "fame": 12.5, "online": true, "squadId": nil}}, Limits: request.Bounds, SafeError: domain.SCUMSafeError{Code: domain.SCUMSafeErrorNone}} +}