From 41c109128df0c023754fed0c57d4299a4d1249dc Mon Sep 17 00:00:00 2001 From: npc0-hue Date: Thu, 13 Aug 2026 14:04:44 +0800 Subject: [PATCH] Freeze SCUM guarded mutation run contract --- ...-sqlite-xml-mutation-handoff-2026-08-13.md | 32 ++++ .../tasks.md | 10 +- platform/domain/job_channel.go | 1 + platform/domain/resources.go | 4 + platform/domain/scum_live_data.go | 157 +++++++++++++--- platform/dto/job_channel.go | 30 +++- platform/dto/resources_test.go | 55 ++++++ platform/dto/scum_live_data.go | 122 +++++++++++++ platform/protocol/scum-live-data-contracts.md | 20 +++ platform/protocol/scum-run-integration.md | 4 + platform/service/job_channel.go | 23 ++- platform/service/job_channel_test.go | 48 ++++- platform/service/scum_live_data.go | 27 ++- platform/validator/job_channel.go | 5 + platform/validator/resources.go | 19 ++ platform/validator/scum_live_data.go | 167 +++++++++++++++++- platform/validator/scum_live_data_test.go | 54 ++++++ 17 files changed, 741 insertions(+), 37 deletions(-) create mode 100644 openspec/changes/replace-scum-projections-with-real-data-management/evidence/run-guarded-sqlite-xml-mutation-handoff-2026-08-13.md diff --git a/openspec/changes/replace-scum-projections-with-real-data-management/evidence/run-guarded-sqlite-xml-mutation-handoff-2026-08-13.md b/openspec/changes/replace-scum-projections-with-real-data-management/evidence/run-guarded-sqlite-xml-mutation-handoff-2026-08-13.md new file mode 100644 index 0000000..0edd277 --- /dev/null +++ b/openspec/changes/replace-scum-projections-with-real-data-management/evidence/run-guarded-sqlite-xml-mutation-handoff-2026-08-13.md @@ -0,0 +1,32 @@ +# Run Guarded SQLite/XML Mutation 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/XML write capabilities by itself. + +## Positive prompt (正向提示词) + +Implement generic plugin-owned guarded single-row SQLite/XML mutation execution for Run's protected SQL capability so Platform can dispatch reviewed SCUM profile-XML writes without sending raw SQL, raw XML, browser mutation text, host paths, sockets, credentials, table/column overrides, raw rows, `fieldKey=855`, or undeclared patch fields. Success means Run accepts only a typed leased `guardedMutation` request containing logical target/template keys, adapter/schema version, immutable asset digest, target identity digest, expected row/value/XML digests, patch digest, backup/offline/danger-confirmation evidence digests, readback expectation digest, idempotency key, bounded scalar payload, safe review reason, and strict limits; applies the packaged preserving patch inside a bounded transaction; rolls back on guard mismatch, malformed XML, zero/multiple affected rows, or failed readback; and returns a typed `sqlite.guarded-mutation` 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 protected SQLite targets and packaged mutation templates from the generated Run workspace, verify active binding, adapter/schema fingerprint, asset/target/guard/patch/backup/offline/confirmation/readback digests, validate scalar payload against packaged schema, open only the package-declared SQLite target or fenced current-service snapshot as permitted by the generated plan, enforce timeout/busy/readback/payload limits, execute a bounded transaction, use a preserving XML patcher that changes only allowlisted named attributes or existing nodes, preserve unknown XML content, reject absent undeclared nodes instead of synthesizing them, update exactly one guarded row, and read back conclusive digest evidence before success. + +Expected Run verification: focused protocol/runtime tests for valid single-row mutation, unknown template, digest mismatch, stale expected row/value/XML guard, missing backup/offline/danger confirmation, malformed XML, absent node, zero-row update rollback, multi-row rollback, timeout/cancellation, readback mismatch/unknown, result-size limit, 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 guarded mutation gates or declaring production XML mutation assets. + +## Boundary prompt (任务边界) + +Do not edit or vendor Run source into this browser repository, add a `run/` tree here, accept raw SQL/XML/browser mutation text, expose host paths/credentials/sockets/raw XML/SQL/IPs/player identities in evidence, add SCUM-specific executor branches, branch on SCUM keys/tables/profile names/`855` semantics, synthesize missing XML nodes, rewrite XML from incomplete structs, update more than one row, skip same-instance backup/offline/danger-confirmation/readback guards, enable `855`, enable production guarded mutation assets, 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 `SCUMGuardedMutationRequest`, `SCUMGuardedMutationResult`, guarded mutation bounds, terminal statuses, mutation readback statuses, and stable safe error codes for guard/readback/rollback failures. +- Job-channel DTOs expose leased `executionInput.guardedMutation` to Run and parse terminal `executionResult.guardedMutation` from Run. +- Validators reject unsafe template keys, protected material, raw XML, SQL/path-like material, `855` field payloads, missing schema/asset/identity/value/XML/patch/backup/offline/danger-confirmation/readback digests, loose affected-row bounds, unsafe review reasons, unsafe summaries, multi-row success, and missing conclusive readback. +- Service job completion accepts `sqlite.guarded-mutation` only for `remote.run.protected.sql` jobs carrying a typed guarded mutation request, requires the typed result on success, checks leased job/binding/template/schema/asset/target/guard/patch/backup/offline/confirmation/readback identity, and includes typed result digests in terminal idempotency fingerprints. + +## Remaining evidence required before enabling mutations + +- Tested Run commit and `go test ./...` output from the independent Run repository. +- Generated Run package carrying any future packaged mutation template, preserving XML patcher declaration, schema/payload/result/readback schemas, immutable asset digests, and logical SQLite target binding. +- Current-service evidence proving the exact source row/XML payload, named attribute mapping, preserving patch semantics, same-instance backup/restore evidence, offline/maintenance requirements, readback behavior, and safe failure outcomes. +- Active binding deployment evidence showing compatible Run executes `remote.run.protected.sql` through the typed guarded envelope without raw SQL/XML exposure. +- Platform acceptance evidence against isolated test data or an explicitly authorized test player proving permission, explicit confirmation, guards, backup/offline requirements, idempotency, readback, unknown-result handling, XML preservation, and no implicit respawn/death/kick activation. 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 e2b0b64..e1accb7 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 @@ -122,11 +122,19 @@ - Added focused tests for DTO parsing, validator rejection of raw command-like payload keys, unsafe review reasons, loose bounds, unconfirmed success, unsafe summaries, service lease fencing, and payload digest mismatch rejection. Verification passed: `go test ./dto ./validator ./service -run 'Test(RunJobResultRequestParses(SQLite|TypedRCON)|ValidateSCUM(SQLiteTemplate|TypedRCON)|CoreServiceRunJob(SQLiteTemplate|TypedRCONTemplate)EnvelopeIsFencedToLease)'`. - Recorded the separately rooted Run handoff prompt in `evidence/run-typed-rcon-template-execution-handoff-2026-08-13.md` with positive, directional, and boundary prompts. This is a contract handoff only; typed RCON templates, gift catalogs, write gates, and task 4.7 remain unchecked until tested Run commit/deployment/current-service command/readback evidence is recorded. +### Guarded SQLite/XML mutation contract freeze and Run handoff evidence (2026-08-13) + +- Platform protocol docs now freeze the generic `guardedMutation` request and `sqlite.guarded-mutation` terminal envelope: logical target/template key, adapter/schema fingerprint, asset digest, target identity digest, expected row/value/XML digests, preserving patch digest, backup/offline/danger-confirmation evidence digests, readback expectation digest, idempotency key, bounded scalar payload, safe review reason, single-row limit, affected-row count, readback status, and stable safe result/error codes. +- Added Platform domain/DTO/job-channel/validator/service contracts for `SCUMGuardedMutationRequest` and `SCUMGuardedMutationResult`. Run assignments can carry only the typed guarded mutation request, not raw SQL/XML/browser mutation text; Run results can return only safe digests/status/summary; Platform verifies leased job identity, binding, template, schema, asset, target, guard, patch, backup, offline, danger confirmation, and readback digests before accepting success. +- Added focused tests for DTO parsing, validator rejection of raw XML/SQL/path-like material, `855` field payloads, missing backup/offline/danger-confirmation/readback digests, loose affected-row bounds, unsafe summaries, multi-row success, missing readback, service lease fencing, and patch digest mismatch rejection. Verification passed: `go test ./dto ./validator ./service -run 'Test(RunJobResultRequestParses(SQLite|TypedRCON|GuardedMutation)|ValidateSCUM(SQLiteTemplate|TypedRCON|GuardedMutation)|CoreServiceRunJob(SQLiteTemplate|TypedRCONTemplate|GuardedMutation)EnvelopeIsFencedToLease)'`. +- 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`. + - [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.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. -- [ ] 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. - [ ] 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. diff --git a/platform/domain/job_channel.go b/platform/domain/job_channel.go index 38f66b0..faa0d8e 100644 --- a/platform/domain/job_channel.go +++ b/platform/domain/job_channel.go @@ -438,6 +438,7 @@ func CopyRunJobAssignment(assignment RunJobAssignment) RunJobAssignment { assignment.ExecutionInput.SQLiteSchemaProbe = CopySCUMSchemaProbeRequestPtr(assignment.ExecutionInput.SQLiteSchemaProbe) assignment.ExecutionInput.SQLiteTemplate = CopySCUMSQLiteTemplateRequestPtr(assignment.ExecutionInput.SQLiteTemplate) assignment.ExecutionInput.RCONTemplate = CopySCUMTypedRCONTemplateRequestPtr(assignment.ExecutionInput.RCONTemplate) + assignment.ExecutionInput.GuardedMutation = CopySCUMGuardedMutationRequestPtr(assignment.ExecutionInput.GuardedMutation) return assignment } diff --git a/platform/domain/resources.go b/platform/domain/resources.go index 10a9418..979458a 100644 --- a/platform/domain/resources.go +++ b/platform/domain/resources.go @@ -1159,6 +1159,7 @@ type JobExecutionInput struct { SQLiteSchemaProbe *SCUMSchemaProbeRequest SQLiteTemplate *SCUMSQLiteTemplateRequest RCONTemplate *SCUMTypedRCONTemplateRequest + GuardedMutation *SCUMGuardedMutationRequest } type ServerDeploymentPlan struct { @@ -1204,6 +1205,7 @@ type JobExecutionResult struct { SQLiteSchemaProbe *SCUMSchemaProbeResult SQLiteTemplate *SCUMSQLiteTemplateResult RCONTemplate *SCUMTypedRCONTemplateResult + GuardedMutation *SCUMGuardedMutationResult ServerDeploymentEvidence *ServerDeploymentEvidence DeploymentReceipt *ServerDeploymentExecutionReceipt } @@ -2051,9 +2053,11 @@ func CopyJob(job Job) Job { job.ExecutionInput.SQLiteSchemaProbe = CopySCUMSchemaProbeRequestPtr(job.ExecutionInput.SQLiteSchemaProbe) job.ExecutionInput.SQLiteTemplate = CopySCUMSQLiteTemplateRequestPtr(job.ExecutionInput.SQLiteTemplate) job.ExecutionInput.RCONTemplate = CopySCUMTypedRCONTemplateRequestPtr(job.ExecutionInput.RCONTemplate) + job.ExecutionInput.GuardedMutation = CopySCUMGuardedMutationRequestPtr(job.ExecutionInput.GuardedMutation) job.ExecutionResult.SQLiteSchemaProbe = CopySCUMSchemaProbeResultPtr(job.ExecutionResult.SQLiteSchemaProbe) job.ExecutionResult.SQLiteTemplate = CopySCUMSQLiteTemplateResultPtr(job.ExecutionResult.SQLiteTemplate) job.ExecutionResult.RCONTemplate = CopySCUMTypedRCONTemplateResultPtr(job.ExecutionResult.RCONTemplate) + job.ExecutionResult.GuardedMutation = CopySCUMGuardedMutationResultPtr(job.ExecutionResult.GuardedMutation) 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 9791292..8b9ea79 100644 --- a/platform/domain/scum_live_data.go +++ b/platform/domain/scum_live_data.go @@ -38,32 +38,40 @@ const ( type SCUMSafeErrorCode string const ( - SCUMSafeErrorNone SCUMSafeErrorCode = "none" - SCUMSafeErrorProbeExecutorAbsent SCUMSafeErrorCode = "probe_executor_absent" - SCUMSafeErrorProbeMissing SCUMSafeErrorCode = "probe_missing" - SCUMSafeErrorProbeFailed SCUMSafeErrorCode = "probe_failed" - SCUMSafeErrorSchemaIncompatible SCUMSafeErrorCode = "schema_incompatible" - SCUMSafeErrorBindingMismatch SCUMSafeErrorCode = "binding_mismatch" - SCUMSafeErrorAdapterMismatch SCUMSafeErrorCode = "adapter_mismatch" - SCUMSafeErrorFingerprintMismatch SCUMSafeErrorCode = "fingerprint_mismatch" - SCUMSafeErrorDigestMismatch SCUMSafeErrorCode = "digest_mismatch" - SCUMSafeErrorEvidenceExpired SCUMSafeErrorCode = "evidence_expired" - SCUMSafeErrorInvalidProbePayload SCUMSafeErrorCode = "invalid_probe_payload" - SCUMSafeErrorInvalidRequest SCUMSafeErrorCode = "invalid_request" - SCUMSafeErrorTargetUnavailable SCUMSafeErrorCode = "target_unavailable" - SCUMSafeErrorSourceUnavailable SCUMSafeErrorCode = "source_unavailable" - SCUMSafeErrorSQLiteOpenFailed SCUMSafeErrorCode = "sqlite_open_failed" - SCUMSafeErrorSQLiteReadFailed SCUMSafeErrorCode = "sqlite_read_failed" - SCUMSafeErrorDatabaseBusy SCUMSafeErrorCode = "database_busy" - SCUMSafeErrorTimeout SCUMSafeErrorCode = "timeout" - 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" + SCUMSafeErrorNone SCUMSafeErrorCode = "none" + SCUMSafeErrorProbeExecutorAbsent SCUMSafeErrorCode = "probe_executor_absent" + SCUMSafeErrorProbeMissing SCUMSafeErrorCode = "probe_missing" + SCUMSafeErrorProbeFailed SCUMSafeErrorCode = "probe_failed" + SCUMSafeErrorSchemaIncompatible SCUMSafeErrorCode = "schema_incompatible" + SCUMSafeErrorBindingMismatch SCUMSafeErrorCode = "binding_mismatch" + SCUMSafeErrorAdapterMismatch SCUMSafeErrorCode = "adapter_mismatch" + SCUMSafeErrorFingerprintMismatch SCUMSafeErrorCode = "fingerprint_mismatch" + SCUMSafeErrorDigestMismatch SCUMSafeErrorCode = "digest_mismatch" + SCUMSafeErrorEvidenceExpired SCUMSafeErrorCode = "evidence_expired" + SCUMSafeErrorInvalidProbePayload SCUMSafeErrorCode = "invalid_probe_payload" + SCUMSafeErrorInvalidRequest SCUMSafeErrorCode = "invalid_request" + SCUMSafeErrorTargetUnavailable SCUMSafeErrorCode = "target_unavailable" + SCUMSafeErrorSourceUnavailable SCUMSafeErrorCode = "source_unavailable" + SCUMSafeErrorSQLiteOpenFailed SCUMSafeErrorCode = "sqlite_open_failed" + SCUMSafeErrorSQLiteReadFailed SCUMSafeErrorCode = "sqlite_read_failed" + SCUMSafeErrorDatabaseBusy SCUMSafeErrorCode = "database_busy" + SCUMSafeErrorTimeout SCUMSafeErrorCode = "timeout" + 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" + SCUMSafeErrorMutationGuardMismatch SCUMSafeErrorCode = "mutation_guard_mismatch" + SCUMSafeErrorMutationBackupUnavailable SCUMSafeErrorCode = "mutation_backup_unavailable" + SCUMSafeErrorMutationOfflineRequired SCUMSafeErrorCode = "mutation_offline_required" + SCUMSafeErrorMutationConfirmationMissing SCUMSafeErrorCode = "mutation_confirmation_missing" + SCUMSafeErrorMutationPatchInvalid SCUMSafeErrorCode = "mutation_patch_invalid" + SCUMSafeErrorAffectedRowsMismatch SCUMSafeErrorCode = "affected_rows_mismatch" + SCUMSafeErrorReadbackMismatch SCUMSafeErrorCode = "readback_mismatch" + SCUMSafeErrorRollbackFailed SCUMSafeErrorCode = "rollback_failed" ) type SCUMTerminalResultStatus string @@ -82,6 +90,15 @@ const ( SCUMRCONConfirmationUnknown SCUMRCONConfirmationStatus = "unknown" ) +type SCUMMutationReadbackStatus string + +const ( + SCUMMutationReadbackConfirmed SCUMMutationReadbackStatus = "confirmed" + SCUMMutationReadbackFailed SCUMMutationReadbackStatus = "failed" + SCUMMutationReadbackConflict SCUMMutationReadbackStatus = "conflict" + SCUMMutationReadbackUnknown SCUMMutationReadbackStatus = "unknown" +) + type SCUMSafeError struct { Code SCUMSafeErrorCode Message string @@ -137,6 +154,18 @@ func DefaultSCUMTypedRCONTemplateBounds() SCUMTypedRCONTemplateBounds { return SCUMTypedRCONTemplateBounds{MaxPayloadBytes: 2048, TimeoutMS: 5000, MaxResponseBytes: 16 * 1024, MaxConfirmRecords: 16} } +type SCUMGuardedMutationBounds struct { + MaxPayloadBytes int + TimeoutMS int + BusyTimeoutMS int + MaxReadbackBytes int + MaxAffectedRows int +} + +func DefaultSCUMGuardedMutationBounds() SCUMGuardedMutationBounds { + return SCUMGuardedMutationBounds{MaxPayloadBytes: 4096, TimeoutMS: 5000, BusyTimeoutMS: 250, MaxReadbackBytes: 16 * 1024, MaxAffectedRows: 1} +} + type SCUMSchemaProbeRequest struct { RequestID string JobID string @@ -182,6 +211,32 @@ type SCUMTypedRCONTemplateRequest struct { RequestedAt time.Time } +type SCUMGuardedMutationRequest struct { + RequestID string + JobID string + Binding SCUMBindingIdentity + Capability SCUMDataCapability + TargetKey string + TemplateKey string + AdapterVersion string + RequiredSchemaFingerprint string + AssetDigest string + TargetIdentityDigest string + ExpectedRowDigest string + ExpectedValueDigest string + ExpectedXMLDigest string + PatchDigest string + BackupEvidenceDigest string + OfflineEvidenceDigest string + DangerConfirmationDigest string + ReadbackExpectationDigest string + IdempotencyKey string + Payload map[string]any + ReviewReason string + Bounds SCUMGuardedMutationBounds + RequestedAt time.Time +} + type SCUMSchemaProbeDeclaration struct { Capability string TargetKey string @@ -296,6 +351,39 @@ type SCUMTypedRCONTemplateResult struct { Limits SCUMTypedRCONTemplateBounds } +type SCUMGuardedMutationResult struct { + RequestID string + JobID string + Binding SCUMBindingIdentity + Status SCUMTerminalResultStatus + Capability SCUMDataCapability + TargetKey string + TemplateKey string + AdapterVersion string + SchemaFingerprint string + AssetDigest string + SourceFingerprint string + TargetIdentityDigest string + ExpectedRowDigest string + ExpectedValueDigest string + ExpectedXMLDigest string + PatchDigest string + BackupEvidenceDigest string + OfflineEvidenceDigest string + DangerConfirmationDigest string + ReadbackExpectationDigest string + ObservedAt time.Time + ResultDigest string + BeforeDigest string + AfterDigest string + ReadbackDigest string + AffectedRows int + ReadbackStatus SCUMMutationReadbackStatus + SafeSummary string + SafeError SCUMSafeError + Limits SCUMGuardedMutationBounds +} + type SCUMCapabilityRequirement struct { Capability SCUMDataCapability AdapterVersion string @@ -406,6 +494,15 @@ func CopySCUMTypedRCONTemplateRequestPtr(value *SCUMTypedRCONTemplateRequest) *S return © } +func CopySCUMGuardedMutationRequestPtr(value *SCUMGuardedMutationRequest) *SCUMGuardedMutationRequest { + if value == nil { + return nil + } + copy := *value + copy.Payload = CopySCUMValueMap(value.Payload) + return © +} + func CopySCUMSQLiteTemplateResultPtr(value *SCUMSQLiteTemplateResult) *SCUMSQLiteTemplateResult { if value == nil { return nil @@ -423,6 +520,14 @@ func CopySCUMTypedRCONTemplateResultPtr(value *SCUMTypedRCONTemplateResult) *SCU return © } +func CopySCUMGuardedMutationResultPtr(value *SCUMGuardedMutationResult) *SCUMGuardedMutationResult { + if value == nil { + return nil + } + copy := *value + return © +} + func CopySCUMValueMap(value map[string]any) map[string]any { if value == nil { return nil diff --git a/platform/dto/job_channel.go b/platform/dto/job_channel.go index 1fd739c..9913d5c 100644 --- a/platform/dto/job_channel.go +++ b/platform/dto/job_channel.go @@ -114,6 +114,7 @@ type RunJobExecutionInputBody struct { SQLiteSchemaProbe *RunSQLiteSchemaProbeRequestBody `json:"sqliteSchemaProbe,omitempty"` SQLiteTemplate *RunSQLiteTemplateRequestBody `json:"sqliteTemplate,omitempty"` RCONTemplate *RunTypedRCONTemplateRequestBody `json:"rconTemplate,omitempty"` + GuardedMutation *RunGuardedMutationRequestBody `json:"guardedMutation,omitempty"` } type RunSQLiteSchemaProbeRequestBody struct { @@ -155,6 +156,30 @@ type RunTypedRCONTemplateRequestBody struct { Limits SCUMTypedRCONTemplateBoundsDTO `json:"limits"` } +type RunGuardedMutationRequestBody 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"` + TargetIdentityDigest string `json:"targetIdentityDigest"` + ExpectedRowDigest string `json:"expectedRowDigest"` + ExpectedValueDigest string `json:"expectedValueDigest"` + ExpectedXMLDigest string `json:"expectedXmlDigest"` + PatchDigest string `json:"patchDigest"` + BackupEvidenceDigest string `json:"backupEvidenceDigest"` + OfflineEvidenceDigest string `json:"offlineEvidenceDigest"` + DangerConfirmationDigest string `json:"dangerConfirmationDigest"` + ReadbackExpectationDigest string `json:"readbackExpectationDigest"` + IdempotencyKey string `json:"idempotencyKey"` + Payload map[string]any `json:"payload,omitempty"` + ReviewReason string `json:"reviewReason"` + Limits SCUMGuardedMutationBoundsDTO `json:"limits"` +} + type ServerDeploymentPlanBody struct { SchemaVersion string `json:"schemaVersion"` Operation string `json:"operation"` @@ -234,6 +259,7 @@ type RunJobExecutionResultBody struct { SQLiteSchemaProbe *SCUMSchemaProbeResultDTO `json:"sqliteSchemaProbe,omitempty"` SQLiteTemplate *SCUMSQLiteTemplateResultDTO `json:"sqliteTemplate,omitempty"` RCONTemplate *SCUMTypedRCONTemplateResultDTO `json:"rconTemplate,omitempty"` + GuardedMutation *SCUMGuardedMutationResultDTO `json:"guardedMutation,omitempty"` ServerDeploymentEvidence *ServerDeploymentEvidenceBody `json:"serverDeploymentEvidence,omitempty"` DeploymentReceipt *ServerDeploymentExecutionReceiptBody `json:"deploymentReceipt,omitempty"` } @@ -493,7 +519,7 @@ func (request RunJobResultRequest) ToDomain() domain.RunJobResult { Message: request.Message, ErrorCode: request.ErrorCode, Retryable: request.Retryable, - ExecutionResult: domain.JobExecutionResult{Kind: request.ExecutionResult.Kind, ProcessState: request.ExecutionResult.ProcessState, ExitClassification: request.ExecutionResult.ExitClassification, ExitCode: request.ExecutionResult.ExitCode, Version: request.ExecutionResult.Version, Checksum: request.ExecutionResult.Checksum, SizeBytes: request.ExecutionResult.SizeBytes, AuditSummary: request.ExecutionResult.AuditSummary, Content: request.ExecutionResult.Content, SQLiteSchemaProbe: SCUMSchemaProbeResultPtrToDomain(request.ExecutionResult.SQLiteSchemaProbe), SQLiteTemplate: SCUMSQLiteTemplateResultPtrToDomain(request.ExecutionResult.SQLiteTemplate), RCONTemplate: SCUMTypedRCONTemplateResultPtrToDomain(request.ExecutionResult.RCONTemplate), 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), ServerDeploymentEvidence: serverDeploymentEvidenceToDomain(request.ExecutionResult.ServerDeploymentEvidence), DeploymentReceipt: deploymentReceiptToDomain(request.ExecutionResult.DeploymentReceipt)}, } } @@ -708,7 +734,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), SQLiteTemplate: runSQLiteTemplateRequestPtrFromDomain(assignment.ExecutionInput.SQLiteTemplate), RCONTemplate: runTypedRCONTemplateRequestPtrFromDomain(assignment.ExecutionInput.RCONTemplate)}, + 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), RCONTemplate: runTypedRCONTemplateRequestPtrFromDomain(assignment.ExecutionInput.RCONTemplate), GuardedMutation: runGuardedMutationRequestPtrFromDomain(assignment.ExecutionInput.GuardedMutation)}, LeaseToken: assignment.LeaseToken, Attempt: assignment.Attempt, FencingToken: assignment.FencingToken, diff --git a/platform/dto/resources_test.go b/platform/dto/resources_test.go index 7f88e2e..5277fb2 100644 --- a/platform/dto/resources_test.go +++ b/platform/dto/resources_test.go @@ -156,6 +156,61 @@ func TestRunJobResultRequestParsesTypedRCONTemplateEnvelope(t *testing.T) { } } +func TestRunJobResultRequestParsesGuardedMutationEnvelope(t *testing.T) { + payload := `{ + "runEndpointId":"run-local", + "sessionToken":"run-session", + "jobId":"job-mutation", + "leaseToken":"lease-mutation", + "attempt":1, + "state":"succeeded", + "progress":{"percent":100,"message":"done"}, + "executionResult":{ + "kind":"sqlite.guarded-mutation", + "guardedMutation":{ + "requestId":"request-mutation", + "jobId":"job-mutation", + "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":"profile-xml.write", + "targetKey":"scum-mutation-db", + "templateKey":"profile.attributes.patch.v1", + "adapterVersion":"scum-live-data-v0", + "schemaFingerprint":"sha256:` + strings.Repeat("a", 64) + `", + "assetDigest":"sha256:` + strings.Repeat("d", 64) + `", + "sourceFingerprint":"sha256:` + strings.Repeat("c", 64) + `", + "targetIdentityDigest":"sha256:` + strings.Repeat("1", 64) + `", + "expectedRowDigest":"sha256:` + strings.Repeat("2", 64) + `", + "expectedValueDigest":"sha256:` + strings.Repeat("3", 64) + `", + "expectedXmlDigest":"sha256:` + strings.Repeat("4", 64) + `", + "patchDigest":"sha256:` + strings.Repeat("5", 64) + `", + "backupEvidenceDigest":"sha256:` + strings.Repeat("6", 64) + `", + "offlineEvidenceDigest":"sha256:` + strings.Repeat("7", 64) + `", + "dangerConfirmationDigest":"sha256:` + strings.Repeat("8", 64) + `", + "readbackExpectationDigest":"sha256:` + strings.Repeat("9", 64) + `", + "observedAt":"2026-08-13T00:00:00Z", + "resultDigest":"sha256:` + strings.Repeat("b", 64) + `", + "beforeDigest":"sha256:` + strings.Repeat("a", 64) + `", + "afterDigest":"sha256:` + strings.Repeat("b", 64) + `", + "readbackDigest":"sha256:` + strings.Repeat("c", 64) + `", + "affectedRows":1, + "readbackStatus":"confirmed", + "safeSummary":"confirmed by declared readback", + "limits":{"maxPayloadBytes":4096,"timeoutMs":5000,"busyTimeoutMs":250,"maxReadbackBytes":16384,"maxAffectedRows":1} + } + } + }` + 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.GuardedMutation + if result == nil || result.TemplateKey != "profile.attributes.patch.v1" || result.PatchDigest != "sha256:"+strings.Repeat("5", 64) || result.AffectedRows != 1 || result.ReadbackStatus != domain.SCUMMutationReadbackConfirmed { + t.Fatalf("guardedMutation 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 e9f7b98..f9b013a 100644 --- a/platform/dto/scum_live_data.go +++ b/platform/dto/scum_live_data.go @@ -43,6 +43,14 @@ type SCUMTypedRCONTemplateBoundsDTO struct { MaxConfirmRecords int `json:"maxConfirmRecords"` } +type SCUMGuardedMutationBoundsDTO struct { + MaxPayloadBytes int `json:"maxPayloadBytes"` + TimeoutMS int `json:"timeoutMs"` + BusyTimeoutMS int `json:"busyTimeoutMs"` + MaxReadbackBytes int `json:"maxReadbackBytes"` + MaxAffectedRows int `json:"maxAffectedRows"` +} + type SCUMSchemaProbeRequestDTO struct { RequestID string `json:"requestId"` JobID string `json:"jobId"` @@ -88,6 +96,32 @@ type SCUMTypedRCONTemplateRequestDTO struct { RequestedAt time.Time `json:"requestedAt"` } +type SCUMGuardedMutationRequestDTO 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"` + TargetIdentityDigest string `json:"targetIdentityDigest"` + ExpectedRowDigest string `json:"expectedRowDigest"` + ExpectedValueDigest string `json:"expectedValueDigest"` + ExpectedXMLDigest string `json:"expectedXmlDigest"` + PatchDigest string `json:"patchDigest"` + BackupEvidenceDigest string `json:"backupEvidenceDigest"` + OfflineEvidenceDigest string `json:"offlineEvidenceDigest"` + DangerConfirmationDigest string `json:"dangerConfirmationDigest"` + ReadbackExpectationDigest string `json:"readbackExpectationDigest"` + IdempotencyKey string `json:"idempotencyKey"` + Payload map[string]any `json:"payload,omitempty"` + ReviewReason string `json:"reviewReason"` + Bounds SCUMGuardedMutationBoundsDTO `json:"bounds"` + RequestedAt time.Time `json:"requestedAt"` +} + type SCUMSchemaProbeDispatchRequest struct { IdempotencyKey string `json:"idempotencyKey"` } @@ -195,6 +229,39 @@ type SCUMTypedRCONTemplateResultDTO struct { Limits SCUMTypedRCONTemplateBoundsDTO `json:"limits"` } +type SCUMGuardedMutationResultDTO 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"` + AssetDigest string `json:"assetDigest"` + SourceFingerprint string `json:"sourceFingerprint,omitempty"` + TargetIdentityDigest string `json:"targetIdentityDigest"` + ExpectedRowDigest string `json:"expectedRowDigest"` + ExpectedValueDigest string `json:"expectedValueDigest"` + ExpectedXMLDigest string `json:"expectedXmlDigest"` + PatchDigest string `json:"patchDigest"` + BackupEvidenceDigest string `json:"backupEvidenceDigest"` + OfflineEvidenceDigest string `json:"offlineEvidenceDigest"` + DangerConfirmationDigest string `json:"dangerConfirmationDigest"` + ReadbackExpectationDigest string `json:"readbackExpectationDigest"` + ObservedAt time.Time `json:"observedAt"` + ResultDigest string `json:"resultDigest"` + BeforeDigest string `json:"beforeDigest,omitempty"` + AfterDigest string `json:"afterDigest,omitempty"` + ReadbackDigest string `json:"readbackDigest,omitempty"` + AffectedRows int `json:"affectedRows"` + ReadbackStatus string `json:"readbackStatus"` + SafeSummary string `json:"safeSummary,omitempty"` + SafeError SCUMSafeErrorDTO `json:"safeError,omitempty"` + Limits SCUMGuardedMutationBoundsDTO `json:"limits"` +} + type SCUMCapabilityGateDTO struct { Capability string `json:"capability"` State string `json:"state"` @@ -251,6 +318,22 @@ func SCUMTypedRCONTemplateRequestPtrFromDomain(value *domain.SCUMTypedRCONTempla return &result } +func SCUMGuardedMutationRequestToDomain(value SCUMGuardedMutationRequestDTO) domain.SCUMGuardedMutationRequest { + return domain.SCUMGuardedMutationRequest{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, TargetIdentityDigest: value.TargetIdentityDigest, ExpectedRowDigest: value.ExpectedRowDigest, ExpectedValueDigest: value.ExpectedValueDigest, ExpectedXMLDigest: value.ExpectedXMLDigest, PatchDigest: value.PatchDigest, BackupEvidenceDigest: value.BackupEvidenceDigest, OfflineEvidenceDigest: value.OfflineEvidenceDigest, DangerConfirmationDigest: value.DangerConfirmationDigest, ReadbackExpectationDigest: value.ReadbackExpectationDigest, IdempotencyKey: value.IdempotencyKey, Payload: domain.CopySCUMValueMap(value.Payload), ReviewReason: value.ReviewReason, Bounds: scumGuardedMutationBoundsToDomain(value.Bounds), RequestedAt: value.RequestedAt} +} + +func SCUMGuardedMutationRequestFromDomain(value domain.SCUMGuardedMutationRequest) SCUMGuardedMutationRequestDTO { + return SCUMGuardedMutationRequestDTO{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, TargetIdentityDigest: value.TargetIdentityDigest, ExpectedRowDigest: value.ExpectedRowDigest, ExpectedValueDigest: value.ExpectedValueDigest, ExpectedXMLDigest: value.ExpectedXMLDigest, PatchDigest: value.PatchDigest, BackupEvidenceDigest: value.BackupEvidenceDigest, OfflineEvidenceDigest: value.OfflineEvidenceDigest, DangerConfirmationDigest: value.DangerConfirmationDigest, ReadbackExpectationDigest: value.ReadbackExpectationDigest, IdempotencyKey: value.IdempotencyKey, Payload: domain.CopySCUMValueMap(value.Payload), ReviewReason: value.ReviewReason, Bounds: scumGuardedMutationBoundsFromDomain(value.Bounds), RequestedAt: value.RequestedAt} +} + +func SCUMGuardedMutationRequestPtrFromDomain(value *domain.SCUMGuardedMutationRequest) *SCUMGuardedMutationRequestDTO { + if value == nil { + return nil + } + result := SCUMGuardedMutationRequestFromDomain(*value) + return &result +} + func runSQLiteSchemaProbeRequestPtrFromDomain(value *domain.SCUMSchemaProbeRequest) *RunSQLiteSchemaProbeRequestBody { if value == nil { return nil @@ -272,6 +355,13 @@ func runTypedRCONTemplateRequestPtrFromDomain(value *domain.SCUMTypedRCONTemplat return &RunTypedRCONTemplateRequestBody{RequestID: value.RequestID, Binding: scumBindingIdentityFromDomain(value.Binding), Capability: string(value.Capability), TransportKey: value.TransportKey, TargetKey: value.TargetKey, TemplateKey: value.TemplateKey, AdapterVersion: value.AdapterVersion, RequiredSchemaFingerprint: value.RequiredSchemaFingerprint, AssetDigest: value.AssetDigest, PayloadDigest: value.PayloadDigest, ConfirmationDigest: value.ConfirmationDigest, TargetIdentityDigest: value.TargetIdentityDigest, IdempotencyKey: value.IdempotencyKey, Payload: domain.CopySCUMValueMap(value.Payload), ReviewReason: value.ReviewReason, Limits: scumTypedRCONTemplateBoundsFromDomain(value.Bounds)} } +func runGuardedMutationRequestPtrFromDomain(value *domain.SCUMGuardedMutationRequest) *RunGuardedMutationRequestBody { + if value == nil { + return nil + } + return &RunGuardedMutationRequestBody{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, TargetIdentityDigest: value.TargetIdentityDigest, ExpectedRowDigest: value.ExpectedRowDigest, ExpectedValueDigest: value.ExpectedValueDigest, ExpectedXMLDigest: value.ExpectedXMLDigest, PatchDigest: value.PatchDigest, BackupEvidenceDigest: value.BackupEvidenceDigest, OfflineEvidenceDigest: value.OfflineEvidenceDigest, DangerConfirmationDigest: value.DangerConfirmationDigest, ReadbackExpectationDigest: value.ReadbackExpectationDigest, IdempotencyKey: value.IdempotencyKey, Payload: domain.CopySCUMValueMap(value.Payload), ReviewReason: value.ReviewReason, Limits: scumGuardedMutationBoundsFromDomain(value.Bounds)} +} + func SCUMSchemaProbeDispatchFromDomain(request domain.SCUMSchemaProbeRequest, queued domain.RemoteAdapterResult) SCUMSchemaProbeDispatchResponse { return SCUMSchemaProbeDispatchResponse{ProbeRequest: SCUMSchemaProbeRequestFromDomain(request), QueuedJob: RemoteAdapterFromDomain(queued)} } @@ -328,6 +418,18 @@ func SCUMTypedRCONTemplateResultPtrToDomain(value *SCUMTypedRCONTemplateResultDT return &result } +func SCUMGuardedMutationResultToDomain(value SCUMGuardedMutationResultDTO) domain.SCUMGuardedMutationResult { + return domain.SCUMGuardedMutationResult{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, SourceFingerprint: value.SourceFingerprint, TargetIdentityDigest: value.TargetIdentityDigest, ExpectedRowDigest: value.ExpectedRowDigest, ExpectedValueDigest: value.ExpectedValueDigest, ExpectedXMLDigest: value.ExpectedXMLDigest, PatchDigest: value.PatchDigest, BackupEvidenceDigest: value.BackupEvidenceDigest, OfflineEvidenceDigest: value.OfflineEvidenceDigest, DangerConfirmationDigest: value.DangerConfirmationDigest, ReadbackExpectationDigest: value.ReadbackExpectationDigest, ObservedAt: value.ObservedAt, ResultDigest: value.ResultDigest, BeforeDigest: value.BeforeDigest, AfterDigest: value.AfterDigest, ReadbackDigest: value.ReadbackDigest, AffectedRows: value.AffectedRows, ReadbackStatus: domain.SCUMMutationReadbackStatus(value.ReadbackStatus), SafeSummary: value.SafeSummary, SafeError: domain.SCUMSafeError{Code: domain.SCUMSafeErrorCode(value.SafeError.Code), Message: value.SafeError.Message, Retryable: value.SafeError.Retryable}, Limits: scumGuardedMutationBoundsToDomain(value.Limits)} +} + +func SCUMGuardedMutationResultPtrToDomain(value *SCUMGuardedMutationResultDTO) *domain.SCUMGuardedMutationResult { + if value == nil { + return nil + } + result := SCUMGuardedMutationResultToDomain(*value) + return &result +} + func SCUMSchemaProbeResultFromDomain(value domain.SCUMSchemaProbeResult) SCUMSchemaProbeResultDTO { value = domain.CopySCUMSchemaProbeResult(value) objects := make([]SCUMSchemaObjectEvidenceDTO, len(value.Objects)) @@ -382,6 +484,18 @@ func SCUMTypedRCONTemplateResultPtrFromDomain(value *domain.SCUMTypedRCONTemplat return &result } +func SCUMGuardedMutationResultFromDomain(value domain.SCUMGuardedMutationResult) SCUMGuardedMutationResultDTO { + return SCUMGuardedMutationResultDTO{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, SourceFingerprint: value.SourceFingerprint, TargetIdentityDigest: value.TargetIdentityDigest, ExpectedRowDigest: value.ExpectedRowDigest, ExpectedValueDigest: value.ExpectedValueDigest, ExpectedXMLDigest: value.ExpectedXMLDigest, PatchDigest: value.PatchDigest, BackupEvidenceDigest: value.BackupEvidenceDigest, OfflineEvidenceDigest: value.OfflineEvidenceDigest, DangerConfirmationDigest: value.DangerConfirmationDigest, ReadbackExpectationDigest: value.ReadbackExpectationDigest, ObservedAt: value.ObservedAt, ResultDigest: value.ResultDigest, BeforeDigest: value.BeforeDigest, AfterDigest: value.AfterDigest, ReadbackDigest: value.ReadbackDigest, AffectedRows: value.AffectedRows, ReadbackStatus: string(value.ReadbackStatus), SafeSummary: value.SafeSummary, SafeError: SCUMSafeErrorDTO{Code: string(value.SafeError.Code), Message: value.SafeError.Message, Retryable: value.SafeError.Retryable}, Limits: scumGuardedMutationBoundsFromDomain(value.Limits)} +} + +func SCUMGuardedMutationResultPtrFromDomain(value *domain.SCUMGuardedMutationResult) *SCUMGuardedMutationResultDTO { + if value == nil { + return nil + } + result := SCUMGuardedMutationResultFromDomain(*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} } @@ -417,3 +531,11 @@ func scumTypedRCONTemplateBoundsToDomain(value SCUMTypedRCONTemplateBoundsDTO) d func scumTypedRCONTemplateBoundsFromDomain(value domain.SCUMTypedRCONTemplateBounds) SCUMTypedRCONTemplateBoundsDTO { return SCUMTypedRCONTemplateBoundsDTO{MaxPayloadBytes: value.MaxPayloadBytes, TimeoutMS: value.TimeoutMS, MaxResponseBytes: value.MaxResponseBytes, MaxConfirmRecords: value.MaxConfirmRecords} } + +func scumGuardedMutationBoundsToDomain(value SCUMGuardedMutationBoundsDTO) domain.SCUMGuardedMutationBounds { + return domain.SCUMGuardedMutationBounds{MaxPayloadBytes: value.MaxPayloadBytes, TimeoutMS: value.TimeoutMS, BusyTimeoutMS: value.BusyTimeoutMS, MaxReadbackBytes: value.MaxReadbackBytes, MaxAffectedRows: value.MaxAffectedRows} +} + +func scumGuardedMutationBoundsFromDomain(value domain.SCUMGuardedMutationBounds) SCUMGuardedMutationBoundsDTO { + return SCUMGuardedMutationBoundsDTO{MaxPayloadBytes: value.MaxPayloadBytes, TimeoutMS: value.TimeoutMS, BusyTimeoutMS: value.BusyTimeoutMS, MaxReadbackBytes: value.MaxReadbackBytes, MaxAffectedRows: value.MaxAffectedRows} +} diff --git a/platform/protocol/scum-live-data-contracts.md b/platform/protocol/scum-live-data-contracts.md index e3e5495..e608545 100644 --- a/platform/protocol/scum-live-data-contracts.md +++ b/platform/protocol/scum-live-data-contracts.md @@ -81,6 +81,26 @@ 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. +## 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: + +- `requestId`, `jobId`; +- `binding`: `serverInstanceId`, `runBindingId`, `runEndpointId`, `pluginId`, `pluginVersion`, `adapterVersion`, `gameVersion`, `databaseIdentity`; +- `capability`, limited to guarded database/XML write capabilities such as `profile-xml.write`; +- logical `targetKey`, `templateKey`, `adapterVersion`, `requiredSchemaFingerprint`, immutable `assetDigest`, `targetIdentityDigest`, `expectedRowDigest`, `expectedValueDigest`, `expectedXmlDigest`, `patchDigest`, `backupEvidenceDigest`, `offlineEvidenceDigest`, `dangerConfirmationDigest`, and `readbackExpectationDigest`; +- `idempotencyKey`, scalar `payload` validated against the plugin-declared payload schema, and safe `reviewReason`; +- `bounds`: `maxPayloadBytes`, `timeoutMs`, `busyTimeoutMs`, `maxReadbackBytes`, and `maxAffectedRows`, which must equal `1`; +- `requestedAt`. + +The request never contains raw SQL, raw XML, browser mutation text, host/database paths, DSNs, sockets, credentials, table/column overrides, raw row payloads, `fieldKey=855`, `prisoner.value`, or undeclared patch fields. Run resolves the logical target and packaged preserving patch template inside the generated Run package. + +## Guarded mutation result + +`SCUMGuardedMutationResult` is the terminal envelope for `sqlite.guarded-mutation` results. Required fields are request/job/binding identity, status (`succeeded`, `failed`, or `cancelled`), write capability, target/template key, adapter version, schema fingerprint, asset digest, source fingerprint when succeeded, target identity digest, expected row/value/XML digests, patch digest, backup/offline/danger-confirmation digests, readback expectation digest, observed time, result digest, before/after/readback digests when succeeded, affected-row count, readback status, safe summary, safe error, and limits actually applied. + +Platform accepts mutation success only when the terminal envelope matches the leased durable job and the declared binding/template/schema/asset/target/guard/patch/backup/offline/confirmation/readback digests, `affectedRows` is exactly `1`, and readback is `confirmed`. Zero rows, multiple rows, stale expected values, schema or source changes, malformed XML, absent named nodes, rollback, missing backup/offline/danger confirmation, missing readback, or unsafe summaries remain failed/conflict/unknown outcomes and must not update local verified facts. + ## 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 3955107..a3efad7 100644 --- a/platform/protocol/scum-run-integration.md +++ b/platform/protocol/scum-run-integration.md @@ -54,6 +54,10 @@ Database/XML writes are disabled until current-service evidence proves the sourc Platform dispatches guarded mutations only with effective `server.game-client.maintenance`, explicit danger confirmation, target identity, expected before values/checksum, same-instance backup evidence, idempotency key, reason, adapter/digest, and declared safety requirements. Run performs one bounded transaction, updates exactly one guarded row, preserves untargeted XML content, rolls back on zero/multiple affected rows or conflicts, and performs read-after-write confirmation before any success result. +The leased Run assignment carries a typed `guardedMutation` request only. Required fields are `requestId`, server/plugin binding, `profile-xml.write` capability, logical `targetKey`, `templateKey`, `adapterVersion`, `requiredSchemaFingerprint`, immutable `assetDigest`, `targetIdentityDigest`, `expectedRowDigest`, `expectedValueDigest`, `expectedXmlDigest`, `patchDigest`, `backupEvidenceDigest`, `offlineEvidenceDigest`, `dangerConfirmationDigest`, `readbackExpectationDigest`, idempotency key, bounded scalar payload, review reason, and limits containing `maxPayloadBytes`, `timeoutMs`, `busyTimeoutMs`, `maxReadbackBytes`, and `maxAffectedRows=1`. The payload carries no raw SQL, raw XML, database path, table/column override, `855` field key, browser mutation text, host path, socket, credential, or undeclared patch field. + +The terminal `sqlite.guarded-mutation` envelope contains `requestId`, `jobId`, binding, status (`succeeded`, `failed`, or `cancelled`), capability, target/template key, adapter/schema fingerprint, asset digest, source fingerprint, target identity digest, expected row/value/XML digests, patch digest, backup/offline/danger-confirmation digests, readback expectation digest, observed time, result digest, before/after/readback digests, affected-row count, readback status, safe summary, safe error, and applied limits. Platform accepts success only when the envelope matches the leased job and binding, `affectedRows` is exactly `1`, and `readbackStatus` is `confirmed`; zero/multiple rows, guard mismatches, malformed XML, missing backup/offline/danger confirmation, missing readback, or stale schema remain safe failed/conflict/unknown results. + Saving attributes must never implicitly kill, respawn, kick, or otherwise activate destructive game behavior. Any verified required activation is a separate permission-checked and explicitly confirmed typed command. ## Terminal Result Envelope diff --git a/platform/service/job_channel.go b/platform/service/job_channel.go index f8419e1..0628216 100644 --- a/platform/service/job_channel.go +++ b/platform/service/job_channel.go @@ -373,6 +373,21 @@ func validateExecutionResultForJob(job domain.Job, result domain.RunJobResult) e return err } } + case domain.JobCapabilityRemoteRunProtectedSQL: + if job.ExecutionInput.GuardedMutation == nil { + break + } + if result.ExecutionResult.Kind != "" && result.ExecutionResult.Kind != scumGuardedMutationExecutionKind { + return validationError("guarded mutation result type is invalid") + } + if result.State == domain.JobStateSucceeded { + if result.ExecutionResult.GuardedMutation == nil { + return validationError("guarded mutation terminal result is required") + } + if err := validateSCUMGuardedMutationResultForJob(job, *result.ExecutionResult.GuardedMutation); 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") @@ -678,7 +693,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), SQLiteTemplate: domain.CopySCUMSQLiteTemplateRequestPtr(job.ExecutionInput.SQLiteTemplate), RCONTemplate: domain.CopySCUMTypedRCONTemplateRequestPtr(job.ExecutionInput.RCONTemplate)}, + 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), RCONTemplate: domain.CopySCUMTypedRCONTemplateRequestPtr(job.ExecutionInput.RCONTemplate), GuardedMutation: domain.CopySCUMGuardedMutationRequestPtr(job.ExecutionInput.GuardedMutation)}, LeaseToken: leaseToken, Attempt: job.Attempt, FencingToken: fencingToken, @@ -768,7 +783,11 @@ func terminalFingerprint(result domain.RunJobResult) string { if result.ExecutionResult.RCONTemplate != nil { rconTemplateFingerprint = fmt.Sprintf("%s|%s|%s|%s|%s", result.ExecutionResult.RCONTemplate.ResultDigest, result.ExecutionResult.RCONTemplate.AssetDigest, result.ExecutionResult.RCONTemplate.PayloadDigest, result.ExecutionResult.RCONTemplate.ConfirmationDigest, result.ExecutionResult.RCONTemplate.ConfirmationStatus) } - return fmt.Sprintf("%s|%d|%s|%s|%s|%s|%t|%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 := "" + if result.ExecutionResult.GuardedMutation != nil { + guardedMutationFingerprint = fmt.Sprintf("%s|%s|%s|%d|%s", result.ExecutionResult.GuardedMutation.ResultDigest, result.ExecutionResult.GuardedMutation.AssetDigest, result.ExecutionResult.GuardedMutation.PatchDigest, result.ExecutionResult.GuardedMutation.AffectedRows, result.ExecutionResult.GuardedMutation.ReadbackStatus) + } + return fmt.Sprintf("%s|%d|%s|%s|%s|%s|%t|%s|%s|%s|%s|%s|%s", result.State, result.Progress.Percent, result.ResultRef, result.Message, result.ErrorCode, result.Progress.Message, result.Retryable, result.ExecutionResult.Kind, result.ExecutionResult.Checksum, schemaProbeFingerprint, sqliteTemplateFingerprint, rconTemplateFingerprint, guardedMutationFingerprint) } func terminalMessage(result domain.RunJobResult) string { diff --git a/platform/service/job_channel_test.go b/platform/service/job_channel_test.go index db530a4..2d74ffd 100644 --- a/platform/service/job_channel_test.go +++ b/platform/service/job_channel_test.go @@ -284,6 +284,42 @@ func TestCoreServiceRunJobTypedRCONTemplateEnvelopeIsFencedToLease(t *testing.T) } } +func TestCoreServiceRunJobGuardedMutationEnvelopeIsFencedToLease(t *testing.T) { + svc, sessionToken := newRegisteredRunJobService(t) + request := scumGuardedMutationTestRequest() + createSCUMTemplateServerFixture(t, svc, request.Binding) + job, err := svc.CreateJob(domain.Job{ID: request.JobID, ServerInstanceID: request.Binding.ServerInstanceID, RunEndpointID: request.Binding.RunEndpointID, Capability: domain.JobCapabilityRemoteRunProtectedSQL, TargetKey: request.TargetKey, InputRef: "input://guarded-mutation/request-mutation", IdempotencyKey: request.IdempotencyKey, RetryPolicy: domain.JobRetryPolicy{MaxAttempts: 1, InitialBackoffSeconds: 1, MaxBackoffSeconds: 1}, ExecutionInput: domain.JobExecutionInput{RemoteAdapterKind: "protected-sql", RemoteAdapterKey: request.TargetKey, GuardedMutation: &request}}) + if err != nil { + t.Fatalf("create guarded mutation job: %v", err) + } + claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: request.Binding.RunEndpointID, SessionToken: sessionToken, Capabilities: []string{domain.JobCapabilityRemoteRunProtectedSQL}, Capacity: domain.RunCapacity{MaxJobs: 4}}) + if err != nil || !claim.HasJob || claim.Job.JobID != job.ID || claim.Job.ExecutionInput.GuardedMutation == nil || claim.Job.ExecutionInput.GuardedMutation.Payload["attributeKey"].(string) != "Strength" { + t.Fatalf("claim guarded mutation job: claim=%+v err=%v", claim, err) + } + result := scumGuardedMutationTestResult(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: "mutation complete"}, Message: "mutation complete", ExecutionResult: domain.JobExecutionResult{Kind: scumGuardedMutationExecutionKind, GuardedMutation: &result, AuditSummary: "redacted guarded mutation"}}); err != nil { + t.Fatalf("complete matching guarded mutation job: %v", err) + } + + badRequest := request + badRequest.RequestID = "request-mutation-bad" + badRequest.JobID = "job-mutation-bad" + badRequest.IdempotencyKey = "idem-mutation-bad" + if _, err := svc.CreateJob(domain.Job{ID: badRequest.JobID, ServerInstanceID: badRequest.Binding.ServerInstanceID, RunEndpointID: badRequest.Binding.RunEndpointID, Capability: domain.JobCapabilityRemoteRunProtectedSQL, TargetKey: badRequest.TargetKey, InputRef: "input://guarded-mutation/request-mutation-bad", IdempotencyKey: badRequest.IdempotencyKey, RetryPolicy: domain.JobRetryPolicy{MaxAttempts: 1, InitialBackoffSeconds: 1, MaxBackoffSeconds: 1}, ExecutionInput: domain.JobExecutionInput{RemoteAdapterKind: "protected-sql", RemoteAdapterKey: badRequest.TargetKey, GuardedMutation: &badRequest}}); err != nil { + t.Fatalf("create bad guarded mutation job: %v", err) + } + badClaim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: badRequest.Binding.RunEndpointID, SessionToken: sessionToken, Capabilities: []string{domain.JobCapabilityRemoteRunProtectedSQL}, Capacity: domain.RunCapacity{MaxJobs: 4}}) + if err != nil { + t.Fatalf("claim bad guarded mutation job: %v", err) + } + badResult := scumGuardedMutationTestResult(badRequest) + badResult.PatchDigest = "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: "mutation complete"}, Message: "mutation complete", ExecutionResult: domain.JobExecutionResult{Kind: scumGuardedMutationExecutionKind, GuardedMutation: &badResult, AuditSummary: "redacted guarded mutation"}}) + if err == nil || !strings.Contains(err.Error(), "digest") { + t.Fatalf("expected digest-fenced guarded mutation result rejection, got %v", err) + } +} + func TestCoreServiceRunJobReconcile(t *testing.T) { svc, sessionToken := newRegisteredRunJobService(t) createQueuedRunJob(t, svc, "job-1", "idem-1") @@ -314,7 +350,7 @@ func newRegisteredRunJobService(t *testing.T) (*CoreService, string) { t.Helper() svc := newTestCoreService() helloRequest := validRunControlHello() - helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, "process.start", domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunProtectedRCON) + helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, "process.start", domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunProtectedRCON, domain.JobCapabilityRemoteRunProtectedSQL) helloRequest.CapabilityReport.Fingerprint = "cap-jobs" hello, err := svc.RegisterRunHello(helloRequest) if err != nil { @@ -333,13 +369,17 @@ func scumTypedRCONTemplateTestRequest() domain.SCUMTypedRCONTemplateRequest { return domain.SCUMTypedRCONTemplateRequest{RequestID: "request-rcon", JobID: "job-rcon", Binding: scumTemplateTestBinding(), Capability: domain.SCUMDataCapabilityEconomyCommand, TransportKey: "scum-rcon", TargetKey: "scum-rcon", TemplateKey: "economy.fame.set.v1", AdapterVersion: "adapter-1", RequiredSchemaFingerprint: scumTemplateTestHash(), AssetDigest: scumTemplateTestHash(), PayloadDigest: scumTemplateTestHash(), ConfirmationDigest: scumTemplateTestHash(), TargetIdentityDigest: scumTemplateTestHash(), IdempotencyKey: "idem-rcon", Payload: map[string]any{"externalPlayerId": "player-redacted", "absoluteValue": 100.0}, ReviewReason: "operator reviewed absolute fame update", Bounds: domain.DefaultSCUMTypedRCONTemplateBounds(), RequestedAt: time.Now()} } +func scumGuardedMutationTestRequest() domain.SCUMGuardedMutationRequest { + return domain.SCUMGuardedMutationRequest{RequestID: "request-mutation", JobID: "job-mutation", Binding: scumTemplateTestBinding(), Capability: domain.SCUMDataCapabilityProfileXMLWrite, TargetKey: "scum-mutation-db", TemplateKey: "profile.attributes.patch.v1", AdapterVersion: "adapter-1", RequiredSchemaFingerprint: scumTemplateTestHash(), AssetDigest: scumTemplateTestHash(), TargetIdentityDigest: scumTemplateTestHash(), ExpectedRowDigest: scumTemplateTestHash(), ExpectedValueDigest: scumTemplateTestHash(), ExpectedXMLDigest: scumTemplateTestHash(), PatchDigest: scumTemplateTestHash(), BackupEvidenceDigest: scumTemplateTestHash(), OfflineEvidenceDigest: scumTemplateTestHash(), DangerConfirmationDigest: scumTemplateTestHash(), ReadbackExpectationDigest: scumTemplateTestHash(), IdempotencyKey: "idem-mutation", Payload: map[string]any{"attributeKey": "Strength", "absoluteValue": 8.5}, ReviewReason: "operator confirmed offline profile attribute patch", Bounds: domain.DefaultSCUMGuardedMutationBounds(), RequestedAt: time.Now()} +} + func scumTypedRCONSourcePlan() *domain.RuntimeSourceRCONPlan { return &domain.RuntimeSourceRCONPlan{Protocol: "source-rcon", ExtensionKey: "scum-rcon", ModKey: "scum_simple_rcon", ConfigRef: "ue4ss/Mods/scum_simple_rcon/config.ini", DeploymentStateRef: "runtime/ue4ss-dll/scum-rcon/release.json", Port: 27015} } func createSCUMTemplateServerFixture(t *testing.T, svc *CoreService, binding domain.SCUMBindingIdentity) { t.Helper() - if _, err := svc.CreateGamePlugin(domain.GamePlugin{ID: binding.PluginID, Name: "SCUM", Version: binding.PluginVersion, ServerType: "scum", ManifestRef: "artifact://manifests/game.scum/0.1.6", CreateFormSchemaRef: "artifact://schemas/game.scum/create-form/0.1.6", RequiredRunCapabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunProtectedRCON}, DeclaredPermissions: []string{"server.remote.access", "server.game-client.command"}, 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}, DeclaredPermissions: []string{"server.remote.access", "server.game-client.command", "server.game-client.maintenance"}, 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 { @@ -355,6 +395,10 @@ func scumTypedRCONTemplateTestResult(request domain.SCUMTypedRCONTemplateRequest return domain.SCUMTypedRCONTemplateResult{RequestID: request.RequestID, JobID: request.JobID, Binding: request.Binding, Status: domain.SCUMTerminalResultSucceeded, Capability: request.Capability, TransportKey: request.TransportKey, TargetKey: request.TargetKey, TemplateKey: request.TemplateKey, AdapterVersion: request.AdapterVersion, SchemaFingerprint: request.RequiredSchemaFingerprint, AssetDigest: request.AssetDigest, PayloadDigest: request.PayloadDigest, ConfirmationDigest: request.ConfirmationDigest, TargetIdentityDigest: request.TargetIdentityDigest, ObservedAt: time.Now(), ResultDigest: scumTemplateTestHash(), ResponseDigest: scumTemplateTestHash(), ConfirmationStatus: domain.SCUMRCONConfirmationConfirmed, ConfirmationDigestID: scumTemplateTestHash(), SafeSummary: "confirmed by declared readback", Limits: request.Bounds, SafeError: domain.SCUMSafeError{Code: domain.SCUMSafeErrorNone}} } +func scumGuardedMutationTestResult(request domain.SCUMGuardedMutationRequest) domain.SCUMGuardedMutationResult { + 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 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 3ba20fe..2adeb83 100644 --- a/platform/service/scum_live_data.go +++ b/platform/service/scum_live_data.go @@ -10,9 +10,10 @@ import ( ) const ( - scumSchemaProbeExecutionKind = "sqlite.schema-probe" - scumSQLiteTemplateExecutionKind = "sqlite.template-query" - scumRCONTemplateExecutionKind = "rcon.template-command" + scumSchemaProbeExecutionKind = "sqlite.schema-probe" + scumSQLiteTemplateExecutionKind = "sqlite.template-query" + scumRCONTemplateExecutionKind = "rcon.template-command" + scumGuardedMutationExecutionKind = "sqlite.guarded-mutation" ) func (svc *CoreService) RequestSCUMSchemaProbeForSession(sessionID, serverInstanceID, idempotencyKey string) (domain.SCUMSchemaProbeRequest, domain.RemoteAdapterResult, error) { @@ -196,6 +197,26 @@ func validateSCUMTypedRCONTemplateResultForJob(job domain.Job, result domain.SCU return nil } +func validateSCUMGuardedMutationResultForJob(job domain.Job, result domain.SCUMGuardedMutationResult) error { + if err := validator.ValidateSCUMGuardedMutationResult(result); err != nil { + return err + } + expected := job.ExecutionInput.GuardedMutation + if expected == nil { + return validationError("guarded mutation request is missing from leased job") + } + if result.JobID != job.ID || result.JobID != expected.JobID || result.RequestID != expected.RequestID { + return validationError("guarded mutation result does not match leased job identity") + } + if !sameSCUMSchemaProbeBinding(result.Binding, expected.Binding) { + return validationError("guarded mutation 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.TargetIdentityDigest != expected.TargetIdentityDigest || result.ExpectedRowDigest != expected.ExpectedRowDigest || result.ExpectedValueDigest != expected.ExpectedValueDigest || result.ExpectedXMLDigest != expected.ExpectedXMLDigest || result.PatchDigest != expected.PatchDigest || result.BackupEvidenceDigest != expected.BackupEvidenceDigest || result.OfflineEvidenceDigest != expected.OfflineEvidenceDigest || result.DangerConfirmationDigest != expected.DangerConfirmationDigest || result.ReadbackExpectationDigest != expected.ReadbackExpectationDigest { + return validationError("guarded mutation result does not match leased template, target, guard, digest, or readback 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 2f89e73..fdb4f4d 100644 --- a/platform/validator/job_channel.go +++ b/platform/validator/job_channel.go @@ -66,6 +66,11 @@ func ValidateRunJobResult(result domain.RunJobResult) error { violations = append(violations, "executionResult.rconTemplate: "+err.Error()) } } + if result.ExecutionResult.GuardedMutation != nil { + if err := ValidateSCUMGuardedMutationResult(*result.ExecutionResult.GuardedMutation); err != nil { + violations = append(violations, "executionResult.guardedMutation: "+err.Error()) + } + } return finish(violations) } diff --git a/platform/validator/resources.go b/platform/validator/resources.go index 0b8e55d..20a830f 100644 --- a/platform/validator/resources.go +++ b/platform/validator/resources.go @@ -1552,6 +1552,20 @@ func ValidateJob(job domain.Job) error { violations = append(violations, "executionInput.rconTemplate requires a protected rcon transport plan") } } + if job.ExecutionInput.GuardedMutation != nil { + if err := ValidateSCUMGuardedMutationRequest(*job.ExecutionInput.GuardedMutation); err != nil { + violations = append(violations, "executionInput.guardedMutation: "+err.Error()) + } + if job.Capability != domain.JobCapabilityRemoteRunProtectedSQL { + violations = append(violations, "executionInput.guardedMutation is allowed only for protected sql jobs") + } + if job.ExecutionInput.RemoteAdapterKind != "protected-sql" { + violations = append(violations, "executionInput.guardedMutation requires a protected sql transport plan") + } + if len(job.ExecutionInput.Inputs) != 0 || job.ExecutionInput.Content != "" { + violations = append(violations, "executionInput.guardedMutation must not persist raw adapter inputs") + } + } 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:") @@ -1577,6 +1591,11 @@ func ValidateJob(job domain.Job) error { violations = append(violations, "executionResult.rconTemplate: "+err.Error()) } } + if job.ExecutionResult.GuardedMutation != nil { + if err := ValidateSCUMGuardedMutationResult(*job.ExecutionResult.GuardedMutation); err != nil { + violations = append(violations, "executionResult.guardedMutation: "+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 666ef5a..031d501 100644 --- a/platform/validator/scum_live_data.go +++ b/platform/validator/scum_live_data.go @@ -24,6 +24,8 @@ const ( maxSCUMRCONPayloadBytes = 4096 maxSCUMRCONResponseBytes = 64 * 1024 maxSCUMRCONConfirmRecords = 128 + maxSCUMMutationPayloadBytes = 4096 + maxSCUMMutationReadbackBytes = 64 * 1024 ) var scumHashPattern = regexp.MustCompile(`^sha256:[a-fA-F0-9]{64}$|^[a-fA-F0-9]{16,128}$`) @@ -106,6 +108,41 @@ func ValidateSCUMTypedRCONTemplateRequest(request domain.SCUMTypedRCONTemplateRe return finish(violations) } +func ValidateSCUMGuardedMutationRequest(request domain.SCUMGuardedMutationRequest) error { + var violations []string + violations = appendRequired(violations, "requestId", request.RequestID) + violations = appendRequired(violations, "jobId", request.JobID) + violations = append(violations, validateSCUMBindingIdentity("binding", request.Binding)...) + if !validSCUMGuardedMutationCapability(request.Capability) { + violations = append(violations, "capability must be a guarded database/XML write 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") + } + for _, item := range scumGuardedMutationRequestDigests(request) { + if !validSCUMDigest(item.value) { + violations = append(violations, item.name+" must be sha256 digest") + } + } + violations = append(violations, validateSCUMTemplateKey("idempotencyKey", request.IdempotencyKey)...) + if strings.TrimSpace(request.ReviewReason) == "" || len(request.ReviewReason) > 320 || containsSCUMProtectedMaterial(request.ReviewReason) || containsSCUMRawXML(request.ReviewReason) { + violations = append(violations, "reviewReason is unsafe") + } + violations = append(violations, validateSCUMGuardedMutationBounds("bounds", request.Bounds)...) + violations = append(violations, validateSCUMGuardedMutationPayload("payload", request.Payload)...) + violations = append(violations, validateSCUMJSONSize("payload", request.Payload, request.Bounds.MaxPayloadBytes)...) + return finish(violations) +} + func ValidateSCUMSchemaProbeResult(result domain.SCUMSchemaProbeResult) error { var violations []string violations = appendRequired(violations, "requestId", result.RequestID) @@ -250,6 +287,75 @@ func ValidateSCUMTypedRCONTemplateResult(result domain.SCUMTypedRCONTemplateResu return finish(violations) } +func ValidateSCUMGuardedMutationResult(result domain.SCUMGuardedMutationResult) 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 !validSCUMGuardedMutationCapability(result.Capability) { + violations = append(violations, "capability must be a guarded database/XML write 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 !validSCUMFingerprint(result.SchemaFingerprint) { + violations = append(violations, "schemaFingerprint must be a digest/fingerprint") + } + if result.SourceFingerprint != "" && !validSCUMFingerprint(result.SourceFingerprint) { + violations = append(violations, "sourceFingerprint must be a digest/fingerprint") + } + for _, item := range scumGuardedMutationResultDigests(result) { + if !validSCUMDigest(item.value) { + violations = append(violations, item.name+" must be sha256 digest") + } + } + if !validSCUMMutationReadbackStatus(result.ReadbackStatus) { + violations = append(violations, "readbackStatus is invalid") + } + if len(result.SafeSummary) > 320 || containsSCUMProtectedMaterial(result.SafeSummary) || containsSCUMRawXML(result.SafeSummary) { + violations = append(violations, "safeSummary is unsafe") + } + violations = append(violations, validateSCUMSafeError("safeError", result.SafeError)...) + violations = append(violations, validateSCUMGuardedMutationBounds("limits", result.Limits)...) + if result.Status == domain.SCUMTerminalResultSucceeded { + if result.AffectedRows != 1 || result.Limits.MaxAffectedRows != 1 { + violations = append(violations, "succeeded result requires exactly one affected row") + } + if result.ReadbackStatus != domain.SCUMMutationReadbackConfirmed || result.SourceFingerprint == "" || result.BeforeDigest == "" || result.AfterDigest == "" || result.ReadbackDigest == "" { + violations = append(violations, "succeeded result requires conclusive before/after/readback digests") + } + if result.SafeError.Code != "" && result.SafeError.Code != domain.SCUMSafeErrorNone { + violations = append(violations, "succeeded result must not carry an error code") + } + } else if result.SafeError.Code == "" || result.SafeError.Code == domain.SCUMSafeErrorNone { + violations = append(violations, "non-succeeded result requires a safe error code") + } + return finish(violations) +} + +func scumGuardedMutationRequestDigests(request domain.SCUMGuardedMutationRequest) []struct{ name, value string } { + return []struct{ name, value string }{{"assetDigest", request.AssetDigest}, {"targetIdentityDigest", request.TargetIdentityDigest}, {"expectedRowDigest", request.ExpectedRowDigest}, {"expectedValueDigest", request.ExpectedValueDigest}, {"expectedXmlDigest", request.ExpectedXMLDigest}, {"patchDigest", request.PatchDigest}, {"backupEvidenceDigest", request.BackupEvidenceDigest}, {"offlineEvidenceDigest", request.OfflineEvidenceDigest}, {"dangerConfirmationDigest", request.DangerConfirmationDigest}, {"readbackExpectationDigest", request.ReadbackExpectationDigest}} +} + +func scumGuardedMutationResultDigests(result domain.SCUMGuardedMutationResult) []struct{ name, value string } { + items := []struct{ name, value string }{{"assetDigest", result.AssetDigest}, {"targetIdentityDigest", result.TargetIdentityDigest}, {"expectedRowDigest", result.ExpectedRowDigest}, {"expectedValueDigest", result.ExpectedValueDigest}, {"expectedXmlDigest", result.ExpectedXMLDigest}, {"patchDigest", result.PatchDigest}, {"backupEvidenceDigest", result.BackupEvidenceDigest}, {"offlineEvidenceDigest", result.OfflineEvidenceDigest}, {"dangerConfirmationDigest", result.DangerConfirmationDigest}, {"readbackExpectationDigest", result.ReadbackExpectationDigest}, {"resultDigest", result.ResultDigest}} + for _, item := range []struct{ name, value string }{{"beforeDigest", result.BeforeDigest}, {"afterDigest", result.AfterDigest}, {"readbackDigest", result.ReadbackDigest}} { + if item.value != "" { + items = append(items, item) + } + } + return items +} + func ValidateSCUMCapabilityEvidence(evidence domain.SCUMCapabilityEvidence) error { var violations []string if !validSCUMDataCapability(evidence.Capability) { @@ -441,6 +547,26 @@ func validateSCUMTypedRCONTemplateBounds(prefix string, value domain.SCUMTypedRC return violations } +func validateSCUMGuardedMutationBounds(prefix string, value domain.SCUMGuardedMutationBounds) []string { + var violations []string + if value.MaxPayloadBytes < 1 || value.MaxPayloadBytes > maxSCUMMutationPayloadBytes { + violations = append(violations, prefix+".maxPayloadBytes 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.MaxReadbackBytes < 1 || value.MaxReadbackBytes > maxSCUMMutationReadbackBytes { + violations = append(violations, prefix+".maxReadbackBytes is out of bounds") + } + if value.MaxAffectedRows != 1 { + violations = append(violations, prefix+".maxAffectedRows must be 1") + } + return violations +} + func validateSCUMTemplateKey(prefix, value string) []string { var violations []string violations = appendRequired(violations, prefix, value) @@ -466,6 +592,23 @@ func validateSCUMValueMap(prefix string, values map[string]any, maxItems int) [] return violations } +func validateSCUMGuardedMutationPayload(prefix string, values map[string]any) []string { + violations := validateSCUMValueMap(prefix, values, 64) + for key, value := range values { + loweredKey := strings.ToLower(key) + if strings.Contains(loweredKey, "sql") || strings.Contains(loweredKey, "xml") || strings.Contains(loweredKey, "path") || strings.Contains(loweredKey, "table") || strings.Contains(loweredKey, "column") || strings.Contains(loweredKey, "query") || strings.Contains(loweredKey, "raw") || strings.Contains(loweredKey, "855") { + violations = append(violations, prefix+" key is unsafe") + } + if text, ok := value.(string); ok { + trimmed := strings.TrimSpace(strings.ToLower(text)) + if containsSCUMRawXML(text) || trimmed == "855" || strings.Contains(trimmed, "fieldkey=855") || strings.Contains(trimmed, "prisoner.value") { + violations = append(violations, prefix+" value is unsafe") + } + } + } + return violations +} + func validateSCUMValue(prefix string, value any) []string { var violations []string switch item := value.(type) { @@ -586,6 +729,15 @@ func validSCUMRCONWriteCapability(value domain.SCUMDataCapability) bool { } } +func validSCUMGuardedMutationCapability(value domain.SCUMDataCapability) bool { + switch value { + case domain.SCUMDataCapabilityProfileXMLWrite: + return true + default: + return false + } +} + func validSCUMTerminalResultStatus(value domain.SCUMTerminalResultStatus) bool { switch value { case domain.SCUMTerminalResultSucceeded, domain.SCUMTerminalResultFailed, domain.SCUMTerminalResultCancelled: @@ -604,6 +756,15 @@ func validSCUMRCONConfirmationStatus(value domain.SCUMRCONConfirmationStatus) bo } } +func validSCUMMutationReadbackStatus(value domain.SCUMMutationReadbackStatus) bool { + switch value { + case domain.SCUMMutationReadbackConfirmed, domain.SCUMMutationReadbackFailed, domain.SCUMMutationReadbackConflict, domain.SCUMMutationReadbackUnknown: + return true + default: + return false + } +} + func validSCUMCapabilityEvidenceStatus(value domain.SCUMCapabilityEvidenceStatus) bool { switch value { case domain.SCUMCapabilityEvidenceMissing, domain.SCUMCapabilityEvidenceCompatible, domain.SCUMCapabilityEvidenceIncompatible, domain.SCUMCapabilityEvidenceFailed: @@ -624,13 +785,17 @@ 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, domain.SCUMSafeErrorTemplateMissing, domain.SCUMSafeErrorTemplateMismatch, domain.SCUMSafeErrorParameterInvalid, domain.SCUMSafeErrorRowLimitExceeded, domain.SCUMSafeErrorResultSchemaInvalid: + 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, domain.SCUMSafeErrorMutationGuardMismatch, domain.SCUMSafeErrorMutationBackupUnavailable, domain.SCUMSafeErrorMutationOfflineRequired, domain.SCUMSafeErrorMutationConfirmationMissing, domain.SCUMSafeErrorMutationPatchInvalid, domain.SCUMSafeErrorAffectedRowsMismatch, domain.SCUMSafeErrorReadbackMismatch, domain.SCUMSafeErrorRollbackFailed: return true default: return scumDataTargetSafeErrorPattern.MatchString(string(value)) } } +func containsSCUMRawXML(value string) bool { + return regexp.MustCompile(`<\s*/?\s*[A-Za-z][^>]*>`).MatchString(value) +} + func validSCUMFingerprint(value string) bool { return scumHashPattern.MatchString(value) } func validSCUMDigest(value string) bool { return regexp.MustCompile(`^sha256:[a-fA-F0-9]{64}$`).MatchString(value) diff --git a/platform/validator/scum_live_data_test.go b/platform/validator/scum_live_data_test.go index 14550b8..e1aa81c 100644 --- a/platform/validator/scum_live_data_test.go +++ b/platform/validator/scum_live_data_test.go @@ -163,6 +163,51 @@ func TestValidateSCUMTypedRCONTemplateResultRejectsUnconfirmedSuccessAndUnsafeSu } } +func TestValidateSCUMGuardedMutationRequestAllowsBoundedGenericTemplate(t *testing.T) { + request := validatorSCUMGuardedMutationRequest() + + if err := ValidateSCUMGuardedMutationRequest(request); err != nil { + t.Fatalf("expected valid guarded mutation request, got %v", err) + } +} + +func TestValidateSCUMGuardedMutationRequestRejectsRawXMLSQL855AndMissingGuards(t *testing.T) { + request := validatorSCUMGuardedMutationRequest() + request.Payload = map[string]any{"rawXml": "", "fieldKey855": "855"} + request.BackupEvidenceDigest = "" + request.OfflineEvidenceDigest = "" + request.DangerConfirmationDigest = "" + request.ReadbackExpectationDigest = "" + request.ReviewReason = "update sqlite:///private/tmp/SCUM.db directly" + request.Bounds.MaxAffectedRows = 2 + + err := ValidateSCUMGuardedMutationRequest(request) + if err == nil || !strings.Contains(err.Error(), "payload key is unsafe") || !strings.Contains(err.Error(), "payload value is unsafe") || !strings.Contains(err.Error(), "backupEvidenceDigest") || !strings.Contains(err.Error(), "offlineEvidenceDigest") || !strings.Contains(err.Error(), "dangerConfirmationDigest") || !strings.Contains(err.Error(), "readbackExpectationDigest") || !strings.Contains(err.Error(), "maxAffectedRows") || !strings.Contains(err.Error(), "reviewReason") { + t.Fatalf("expected guarded mutation safety violations, got %v", err) + } +} + +func TestValidateSCUMGuardedMutationResultAcceptsConfirmedSingleRowEnvelope(t *testing.T) { + result := validatorSCUMGuardedMutationResult() + + if err := ValidateSCUMGuardedMutationResult(result); err != nil { + t.Fatalf("expected valid guarded mutation result, got %v", err) + } +} + +func TestValidateSCUMGuardedMutationResultRejectsMultiRowMissingReadbackAndUnsafeSummary(t *testing.T) { + result := validatorSCUMGuardedMutationResult() + result.AffectedRows = 2 + result.ReadbackStatus = domain.SCUMMutationReadbackUnknown + result.ReadbackDigest = "" + result.SafeSummary = "raw leaked" + + err := ValidateSCUMGuardedMutationResult(result) + if err == nil || !strings.Contains(err.Error(), "exactly one affected row") || !strings.Contains(err.Error(), "readback") || !strings.Contains(err.Error(), "safeSummary") { + t.Fatalf("expected affected-row/readback/summary 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 { @@ -196,3 +241,12 @@ func validatorSCUMTypedRCONTemplateResult() domain.SCUMTypedRCONTemplateResult { request := validatorSCUMTypedRCONTemplateRequest() return domain.SCUMTypedRCONTemplateResult{RequestID: request.RequestID, JobID: request.JobID, Binding: request.Binding, Status: domain.SCUMTerminalResultSucceeded, Capability: request.Capability, TransportKey: request.TransportKey, TargetKey: request.TargetKey, TemplateKey: request.TemplateKey, AdapterVersion: request.AdapterVersion, SchemaFingerprint: request.RequiredSchemaFingerprint, AssetDigest: request.AssetDigest, PayloadDigest: request.PayloadDigest, ConfirmationDigest: request.ConfirmationDigest, TargetIdentityDigest: request.TargetIdentityDigest, ObservedAt: time.Now(), ResultDigest: scumProbeHash, ResponseDigest: scumProbeHash, ConfirmationStatus: domain.SCUMRCONConfirmationConfirmed, ConfirmationDigestID: scumProbeHash, SafeSummary: "confirmed by declared readback", Limits: request.Bounds, SafeError: domain.SCUMSafeError{Code: domain.SCUMSafeErrorNone}} } + +func validatorSCUMGuardedMutationRequest() domain.SCUMGuardedMutationRequest { + return domain.SCUMGuardedMutationRequest{RequestID: "request-mutation-1", JobID: "job-mutation-1", Binding: validatorSCUMBinding(), Capability: domain.SCUMDataCapabilityProfileXMLWrite, TargetKey: "scum-mutation-db", TemplateKey: "profile.attributes.patch.v1", AdapterVersion: "adapter-1", RequiredSchemaFingerprint: scumProbeHash, AssetDigest: scumProbeHash, TargetIdentityDigest: scumProbeHash, ExpectedRowDigest: scumProbeHash, ExpectedValueDigest: scumProbeHash, ExpectedXMLDigest: scumProbeHash, PatchDigest: scumProbeHash, BackupEvidenceDigest: scumProbeHash, OfflineEvidenceDigest: scumProbeHash, DangerConfirmationDigest: scumProbeHash, ReadbackExpectationDigest: scumProbeHash, IdempotencyKey: "idem-mutation-1", Payload: map[string]any{"attributeKey": "Strength", "absoluteValue": 8.5}, ReviewReason: "operator confirmed offline profile attribute patch", Bounds: domain.DefaultSCUMGuardedMutationBounds(), RequestedAt: time.Now()} +} + +func validatorSCUMGuardedMutationResult() domain.SCUMGuardedMutationResult { + request := validatorSCUMGuardedMutationRequest() + return domain.SCUMGuardedMutationResult{RequestID: request.RequestID, JobID: request.JobID, Binding: request.Binding, Status: domain.SCUMTerminalResultSucceeded, Capability: request.Capability, TargetKey: request.TargetKey, TemplateKey: request.TemplateKey, AdapterVersion: request.AdapterVersion, SchemaFingerprint: request.RequiredSchemaFingerprint, AssetDigest: request.AssetDigest, SourceFingerprint: scumProbeHash, TargetIdentityDigest: request.TargetIdentityDigest, ExpectedRowDigest: request.ExpectedRowDigest, ExpectedValueDigest: request.ExpectedValueDigest, ExpectedXMLDigest: request.ExpectedXMLDigest, PatchDigest: request.PatchDigest, BackupEvidenceDigest: request.BackupEvidenceDigest, OfflineEvidenceDigest: request.OfflineEvidenceDigest, DangerConfirmationDigest: request.DangerConfirmationDigest, ReadbackExpectationDigest: request.ReadbackExpectationDigest, ObservedAt: time.Now(), ResultDigest: scumProbeHash, BeforeDigest: scumProbeHash, AfterDigest: scumProbeHash, ReadbackDigest: scumProbeHash, AffectedRows: 1, ReadbackStatus: domain.SCUMMutationReadbackConfirmed, SafeSummary: "confirmed by declared readback", SafeError: domain.SCUMSafeError{Code: domain.SCUMSafeErrorNone}, Limits: request.Bounds} +}