From abbb4c1fc7bd9e39e5cae5c22a4dce9032c897f5 Mon Sep 17 00:00:00 2001 From: bayleafwalker Date: Tue, 25 Aug 2026 23:06:06 +0300 Subject: [PATCH 1/3] feat(external-runtime): persist and reconcile execution evidence --- Dockerfile | 3 +- README.md | 25 +- RELEASE.md | 7 +- charts/bindery-external-runtime/Chart.yaml | 4 +- .../templates/deployment.yaml | 23 + .../templates/pvc.yaml | 17 + charts/bindery-external-runtime/values.yaml | 20 +- cmd/bindery-external-runtime/allocator.go | 42 ++ .../allocator_test.go | 22 +- cmd/bindery-external-runtime/main.go | 17 +- contracts/externalruntime/v1/README.md | 11 +- .../v1/evidence-set.schema.json | 42 ++ .../v1/gate-definition.schema.json | 38 ++ .../v1/gate-result.schema.json | 16 + .../v1/observation.schema.json | 17 + contracts/externalruntime/v1/openapi.yaml | 49 ++ .../v1/public-capture.schema.json | 4 +- .../v1/public-execution.schema.json | 23 + .../v1/public-placement.schema.json | 31 ++ .../v1/public-session.schema.json | 23 +- .../v1/telemetry-event.schema.json | 4 +- docs/architecture/evidence-and-gates.md | 108 +++++ .../2026-08-25-ra2-vertical-slice.md | 145 ++++++ docs/roadmap/post-ra2-hardening.yaml | 73 +++ internal/capture/capture.go | 26 +- internal/capture/capture_test.go | 6 +- internal/capture/fixtures.go | 7 +- internal/externalruntime/erm204_test.go | 17 +- internal/externalruntime/fixtures.go | 18 + internal/externalruntime/http.go | 57 ++- internal/externalruntime/http_test.go | 40 ++ internal/externalruntime/placement.go | 27 +- internal/externalruntime/redaction_test.go | 4 + internal/externalruntime/service.go | 254 +++++++++- internal/externalruntime/service_test.go | 8 +- internal/externalruntime/state_store.go | 441 ++++++++++++++++++ internal/externalruntime/state_store_test.go | 134 ++++++ internal/externalruntime/types.go | 55 ++- pkg/evidencev1/evidence.go | 194 ++++++++ pkg/evidencev1/evidence_test.go | 79 ++++ pkg/gatev1/gate.go | 190 ++++++++ pkg/gatev1/gate_test.go | 87 ++++ 42 files changed, 2347 insertions(+), 61 deletions(-) create mode 100644 charts/bindery-external-runtime/templates/pvc.yaml create mode 100644 contracts/externalruntime/v1/evidence-set.schema.json create mode 100644 contracts/externalruntime/v1/gate-definition.schema.json create mode 100644 contracts/externalruntime/v1/gate-result.schema.json create mode 100644 contracts/externalruntime/v1/observation.schema.json create mode 100644 contracts/externalruntime/v1/public-execution.schema.json create mode 100644 contracts/externalruntime/v1/public-placement.schema.json create mode 100644 docs/architecture/evidence-and-gates.md create mode 100644 docs/assessments/2026-08-25-ra2-vertical-slice.md create mode 100644 docs/roadmap/post-ra2-hardening.yaml create mode 100644 internal/externalruntime/state_store.go create mode 100644 internal/externalruntime/state_store_test.go create mode 100644 pkg/evidencev1/evidence.go create mode 100644 pkg/evidencev1/evidence_test.go create mode 100644 pkg/gatev1/gate.go create mode 100644 pkg/gatev1/gate_test.go diff --git a/Dockerfile b/Dockerfile index f45e6a9..9a7a791 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,9 @@ FROM golang:1.23-bookworm AS build +ARG SOURCE_COMMIT=unknown WORKDIR /src COPY go.mod ./ COPY . . -RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o /out/bindery-external-runtime ./cmd/bindery-external-runtime \ +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w -X main.buildRevision=${SOURCE_COMMIT}" -o /out/bindery-external-runtime ./cmd/bindery-external-runtime \ && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o /out/bindery-udp-relay ./cmd/bindery-udp-relay FROM gcr.io/distroless/static-debian12:nonroot diff --git a/README.md b/README.md index f8dd29e..fd636e4 100644 --- a/README.md +++ b/README.md @@ -6,19 +6,32 @@ research reference service. The research pack under is immutable input. Runtime code imports only the promoted contracts under [`contracts/externalruntime/v1`](contracts/externalruntime/v1). -The first implementation slice is deliberately in-memory and synthetic. It -proves the public/authenticated DTO split, token-backed identity, session and -enrollment lifecycle, idempotent mutations, and public redaction before -PostgreSQL, object storage, Windows clients, or live UDP are introduced. +The RA2 external-runtime path has now been demonstrated end to end once. The +reference control plane persists identity, session, placement, execution, +enrollment, idempotency, and reconciled evidence records through a crash-safe +single-writer state store. The current file-backed mode is intentionally not a +multi-replica database. + +The evidence and gate boundary is described in +[`docs/architecture/evidence-and-gates.md`](docs/architecture/evidence-and-gates.md). +The dated RA2 result and its limits are recorded in +[`docs/assessments/2026-08-25-ra2-vertical-slice.md`](docs/assessments/2026-08-25-ra2-vertical-slice.md). ## Local verification ```sh go test ./... go vet ./... +BINDERY_RELAY_ENDPOINT=127.0.0.1:50001 \ +BINDERY_BUILD_REVISION="$(git rev-parse HEAD)" \ +BINDERY_STATE_PATH=/tmp/bindery-control-state.json \ go run ./cmd/bindery-external-runtime ``` -`GET /v1/sessions` is intentionally not implemented. Known session IDs are -public; discovery is not. +The `/tmp` path above is disposable local development state. Deployments mount +a persistent volume and run exactly one replica. Multiple replicas require a +shared relational store; changing the count alone is rejected by the chart. +`GET /v1/sessions` is intentionally not implemented. Known session IDs are +public; discovery is not. The same known-ID rule applies to placements, +executions, enrollments, and evidence sets. diff --git a/RELEASE.md b/RELEASE.md index 6641ff2..c1bd5a1 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -7,7 +7,12 @@ attached to its Sprintctl item: provenance attestation; - OCI Helm chart version and digest; - `go test -race ./...`, `go vet ./...`, Helm lint, and redaction receipts; +- a restart drill resolving identity, session, placement, execution, and + evidence-set IDs from the persisted state file; +- positive and negative calibration receipts for every consequential gate, + including gate version and implementation hash; +- an allocator implementation revision and configuration digest in the + placement fixture; - research-pack provenance and wave acceptance references; - operator approval for the local-inference exception, public data terms, network boundary, and baseline tunnel revision. - diff --git a/charts/bindery-external-runtime/Chart.yaml b/charts/bindery-external-runtime/Chart.yaml index cb7d77e..7774c29 100644 --- a/charts/bindery-external-runtime/Chart.yaml +++ b/charts/bindery-external-runtime/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: bindery-external-runtime description: Bindery external-runtime control-plane reference service type: application -version: 0.1.0 -appVersion: 0.1.0 +version: 0.2.0 +appVersion: 0.2.0 home: https://github.com/bayleafwalker/bindery-core sources: - https://github.com/bayleafwalker/bindery-core diff --git a/charts/bindery-external-runtime/templates/deployment.yaml b/charts/bindery-external-runtime/templates/deployment.yaml index 96b4866..5ca316f 100644 --- a/charts/bindery-external-runtime/templates/deployment.yaml +++ b/charts/bindery-external-runtime/templates/deployment.yaml @@ -5,7 +5,15 @@ metadata: labels: {{- include "bindery-external-runtime.labels" . | nindent 4 }} spec: + {{- if ne (int .Values.replicaCount) 1 }} + {{- fail "file-backed control state supports exactly one replica; configure a shared relational store before scaling" }} + {{- end }} + {{- if not .Values.persistence.enabled }} + {{- fail "durable control state is mandatory for the external-runtime service" }} + {{- end }} replicas: {{ .Values.replicaCount }} + strategy: + type: Recreate selector: matchLabels: app.kubernetes.io/name: {{ include "bindery-external-runtime.name" . }} @@ -29,6 +37,10 @@ spec: containerPort: 8080 protocol: TCP env: + - name: BINDERY_BUILD_REVISION + value: {{ .Values.buildRevision | quote }} + - name: BINDERY_STATE_PATH + value: {{ printf "%s/control-state.json" .Values.persistence.mountPath | quote }} {{- range $key, $value := .Values.env }} - name: {{ $key }} value: {{ $value | quote }} @@ -46,3 +58,14 @@ spec: httpGet: {path: /healthz, port: http} resources: {{- toYaml .Values.resources | nindent 12 }} + {{- if .Values.persistence.enabled }} + volumeMounts: + - name: control-state + mountPath: {{ .Values.persistence.mountPath | quote }} + {{- end }} + {{- if .Values.persistence.enabled }} + volumes: + - name: control-state + persistentVolumeClaim: + claimName: {{ default (printf "%s-state" (include "bindery-external-runtime.fullname" .)) .Values.persistence.existingClaim }} + {{- end }} diff --git a/charts/bindery-external-runtime/templates/pvc.yaml b/charts/bindery-external-runtime/templates/pvc.yaml new file mode 100644 index 0000000..8485b3e --- /dev/null +++ b/charts/bindery-external-runtime/templates/pvc.yaml @@ -0,0 +1,17 @@ +{{- if and .Values.persistence.enabled (not .Values.persistence.existingClaim) }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ printf "%s-state" (include "bindery-external-runtime.fullname" .) }} + labels: + {{- include "bindery-external-runtime.labels" . | nindent 4 }} +spec: + accessModes: + {{- toYaml .Values.persistence.accessModes | nindent 4 }} + {{- if .Values.persistence.storageClass }} + storageClassName: {{ .Values.persistence.storageClass | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.persistence.size | quote }} +{{- end }} diff --git a/charts/bindery-external-runtime/values.yaml b/charts/bindery-external-runtime/values.yaml index dd683e4..32ab14a 100644 --- a/charts/bindery-external-runtime/values.yaml +++ b/charts/bindery-external-runtime/values.yaml @@ -1,4 +1,10 @@ -replicaCount: 2 +# The file-backed v1 store is single-writer. Use a shared relational store +# before making this greater than one. +replicaCount: 1 + +# Full bindery-core Git commit embedded in every placement decision. The +# service refuses to start when this is not an exact 40-character revision. +buildRevision: "" image: repository: ghcr.io/bayleafwalker/bindery-core @@ -18,6 +24,10 @@ resources: {} podSecurityContext: runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + fsGroup: 65532 + fsGroupChangePolicy: OnRootMismatch seccompProfile: type: RuntimeDefault @@ -30,6 +40,14 @@ securityContext: env: BINDERY_EXTERNAL_RUNTIME_ADDR: ":8080" +persistence: + enabled: true + existingClaim: "" + mountPath: /var/lib/bindery + size: 1Gi + accessModes: [ReadWriteOnce] + storageClass: "" + secretEnv: [] serviceMonitor: diff --git a/cmd/bindery-external-runtime/allocator.go b/cmd/bindery-external-runtime/allocator.go index 037be8d..a60b665 100644 --- a/cmd/bindery-external-runtime/allocator.go +++ b/cmd/bindery-external-runtime/allocator.go @@ -2,10 +2,14 @@ package main import ( "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" "errors" "fmt" "net" "os" + "regexp" "slices" "strings" @@ -17,11 +21,22 @@ import ( // allocator refuses to serve anything else rather than falling back. const cncnetPrivateProviderID = "cncnet-private" +const allocatorRepository = "https://github.com/bayleafwalker/bindery-core" + +var revisionPattern = regexp.MustCompile(`^[0-9a-f]{40}$`) + +// buildRevision is set from the image build. go run/local tests may supply +// BINDERY_BUILD_REVISION instead; a supplied value may not contradict an exact +// revision already embedded in the binary. +var buildRevision = "unknown" + type allocatorConfig struct { Provider string Endpoint string Region string PolicyVersion string + Revision string + ConfigDigest string } // allocatorConfigFromEnv reads the deployment-owned placement policy. The @@ -34,6 +49,10 @@ func allocatorConfigFromEnv() (allocatorConfig, error) { Endpoint: os.Getenv("BINDERY_RELAY_ENDPOINT"), Region: envOrDefault("BINDERY_RELAY_REGION", "eu-north"), PolicyVersion: envOrDefault("BINDERY_PLACEMENT_POLICY_VERSION", "cncnet-private-lab-v1"), + Revision: strings.TrimSpace(os.Getenv("BINDERY_BUILD_REVISION")), + } + if config.Revision == "" { + config.Revision = buildRevision } if config.Provider != cncnetPrivateProviderID { return allocatorConfig{}, fmt.Errorf("this service serves only %q, not %q", cncnetPrivateProviderID, config.Provider) @@ -45,6 +64,23 @@ func allocatorConfigFromEnv() (allocatorConfig, error) { if err != nil || host == "" || port == "" { return allocatorConfig{}, fmt.Errorf("BINDERY_RELAY_ENDPOINT %q is not host:port", config.Endpoint) } + if !revisionPattern.MatchString(config.Revision) { + return allocatorConfig{}, errors.New("BINDERY_BUILD_REVISION must be the full 40-character Git commit") + } + if revisionPattern.MatchString(buildRevision) && config.Revision != buildRevision { + return allocatorConfig{}, errors.New("BINDERY_BUILD_REVISION does not match the revision embedded in the binary") + } + encoded, err := json.Marshal(struct { + Provider string `json:"provider"` + Endpoint string `json:"endpoint"` + Region string `json:"region"` + PolicyVersion string `json:"policy_version"` + }{config.Provider, config.Endpoint, config.Region, config.PolicyVersion}) + if err != nil { + return allocatorConfig{}, fmt.Errorf("encode allocator config: %w", err) + } + digest := sha256.Sum256(encoded) + config.ConfigDigest = "sha256:" + hex.EncodeToString(digest[:]) return config, nil } @@ -69,6 +105,12 @@ func newCncNetPrivateAllocator(config allocatorConfig) externalruntime.Placement RelayEndpoint: config.Endpoint, PolicyVersion: config.PolicyVersion, DecisionSummary: fmt.Sprintf("private CnCNet tunnel in %s; p95 intent %dms", config.Region, intent.LatencyP95MS), + Allocator: externalruntime.ImplementationIdentity{ + Implementation: cncnetPrivateProviderID, + Repository: allocatorRepository, + Revision: config.Revision, + ConfigDigest: config.ConfigDigest, + }, }, nil } } diff --git a/cmd/bindery-external-runtime/allocator_test.go b/cmd/bindery-external-runtime/allocator_test.go index bba228d..2f65bc2 100644 --- a/cmd/bindery-external-runtime/allocator_test.go +++ b/cmd/bindery-external-runtime/allocator_test.go @@ -1,7 +1,6 @@ package main import ( - "os" "testing" "github.com/bayleafwalker/bindery-core/internal/externalruntime" @@ -9,6 +8,7 @@ import ( ) func TestAllocatorConfigRequiresAnEndpoint(t *testing.T) { + t.Setenv("BINDERY_BUILD_REVISION", testBuildRevision) t.Setenv("BINDERY_RELAY_ENDPOINT", "") if _, err := allocatorConfigFromEnv(); err == nil { t.Fatal("expected a missing endpoint to be refused") @@ -16,6 +16,7 @@ func TestAllocatorConfigRequiresAnEndpoint(t *testing.T) { } func TestAllocatorConfigRejectsOtherProviders(t *testing.T) { + t.Setenv("BINDERY_BUILD_REVISION", testBuildRevision) t.Setenv("BINDERY_RELAY_ENDPOINT", "192.168.122.1:50001") t.Setenv("BINDERY_RELAY_PROVIDER", "cncnet-public") if _, err := allocatorConfigFromEnv(); err == nil { @@ -24,6 +25,7 @@ func TestAllocatorConfigRejectsOtherProviders(t *testing.T) { } func TestAllocatorConfigRejectsMalformedEndpoints(t *testing.T) { + t.Setenv("BINDERY_BUILD_REVISION", testBuildRevision) t.Setenv("BINDERY_RELAY_ENDPOINT", "192.168.122.1") if _, err := allocatorConfigFromEnv(); err == nil { t.Fatal("expected an endpoint without a port to be refused") @@ -31,8 +33,8 @@ func TestAllocatorConfigRejectsMalformedEndpoints(t *testing.T) { } func TestBothClientsReceiveTheSameEndpointWithDistinctAllocations(t *testing.T) { - os.Setenv("BINDERY_RELAY_ENDPOINT", "192.168.122.1:50001") - defer os.Unsetenv("BINDERY_RELAY_ENDPOINT") + t.Setenv("BINDERY_RELAY_ENDPOINT", "192.168.122.1:50001") + t.Setenv("BINDERY_BUILD_REVISION", testBuildRevision) config, err := allocatorConfigFromEnv() if err != nil { t.Fatalf("config: %v", err) @@ -61,10 +63,14 @@ func TestBothClientsReceiveTheSameEndpointWithDistinctAllocations(t *testing.T) if _, err := relayv1.PeekMustUUID(first.RelayAllocationID); err != nil { t.Fatalf("allocation id must be a canonical UUID: %v", err) } + if first.Allocator.Revision != testBuildRevision || first.Allocator.ConfigDigest == "" { + t.Fatalf("allocator identity is incomplete: %+v", first.Allocator) + } } func TestIntentWithoutTheServedRegionIsRefused(t *testing.T) { t.Setenv("BINDERY_RELAY_ENDPOINT", "192.168.122.1:50001") + t.Setenv("BINDERY_BUILD_REVISION", testBuildRevision) config, err := allocatorConfigFromEnv() if err != nil { t.Fatalf("config: %v", err) @@ -75,3 +81,13 @@ func TestIntentWithoutTheServedRegionIsRefused(t *testing.T) { t.Fatal("expected an unservable region to be refused rather than silently reassigned") } } + +func TestAllocatorConfigRequiresAnExactImplementationRevision(t *testing.T) { + t.Setenv("BINDERY_RELAY_ENDPOINT", "192.168.122.1:50001") + t.Setenv("BINDERY_BUILD_REVISION", "unknown") + if _, err := allocatorConfigFromEnv(); err == nil { + t.Fatal("expected an unresolved allocator revision to be refused") + } +} + +const testBuildRevision = "738e9f752ad1d892bdad8852cd4bd4e29182c16a" diff --git a/cmd/bindery-external-runtime/main.go b/cmd/bindery-external-runtime/main.go index cabcb04..00cf002 100644 --- a/cmd/bindery-external-runtime/main.go +++ b/cmd/bindery-external-runtime/main.go @@ -22,14 +22,27 @@ func main() { logger.Error("placement allocator is not configured", "error", err) os.Exit(2) } - service := externalruntime.NewServiceWithPlacementAllocator(newCncNetPrivateAllocator(config)) + statePath := os.Getenv("BINDERY_STATE_PATH") + store, err := externalruntime.NewFileStateStore(statePath) + if err != nil { + logger.Error("durable control state is not configured", "error", err) + os.Exit(2) + } + service, err := externalruntime.OpenPersistentService(newCncNetPrivateAllocator(config), store) + if err != nil { + logger.Error("durable control state could not be restored", "error", err) + os.Exit(2) + } server := &http.Server{Addr: addr, Handler: externalruntime.NewHandler(service)} logger.Info("bindery external-runtime reference service listening", "addr", addr, "relay_provider", config.Provider, "relay_endpoint", config.Endpoint, "region", config.Region, - "policy_version", config.PolicyVersion) + "policy_version", config.PolicyVersion, + "allocator_revision", config.Revision, + "allocator_config_digest", config.ConfigDigest, + "state_path", statePath) if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { logger.Error("server stopped", "error", err) os.Exit(1) diff --git a/contracts/externalruntime/v1/README.md b/contracts/externalruntime/v1/README.md index ea2a25e..48628c8 100644 --- a/contracts/externalruntime/v1/README.md +++ b/contracts/externalruntime/v1/README.md @@ -15,4 +15,13 @@ Invariants: lease tokens, and transport credentials are never public fields or logs; - identifiers are UUIDv7 values and wire timestamps are RFC 3339 UTC; - client classes in v1 are `player` and `observer`. - +- identity, session, placement and execution records survive a reference-service + restart and resolve by stable identifier; +- every placement names the allocator implementation repository, exact revision + and configuration digest that produced it; +- observations refer to an execution; evidence sets retain every compared + stream and record a reconciliation method and outcome; +- exact event-count equality is reconciliation policy #1. It establishes stream + consistency at that level only, not semantic truth; +- gate results use `PASS`, `FAIL`, `NOT_APPLICABLE`, `UNRESOLVED`, or `ERROR`; + consequential gates require known-pass and known-fail calibration evidence. diff --git a/contracts/externalruntime/v1/evidence-set.schema.json b/contracts/externalruntime/v1/evidence-set.schema.json new file mode 100644 index 0000000..8c0c7e2 --- /dev/null +++ b/contracts/externalruntime/v1/evidence-set.schema.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://bindery.dev/contracts/externalruntime/v1/evidence-set.schema.json", + "title": "Bindery reconciled evidence set", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "evidence_set_id", "execution_id", "observations", "reconciliation", "created_at"], + "properties": { + "schema_version": {"const": "1.0.0"}, + "evidence_set_id": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "execution_id": {"type": "string", "format": "uuid"}, + "observations": { + "type": "array", + "minItems": 2, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["observer_id", "execution_id", "stream_id", "event_count"], + "properties": { + "observer_id": {"type": "string", "format": "uuid"}, + "execution_id": {"type": "string", "format": "uuid"}, + "stream_id": {"type": "string", "minLength": 1, "maxLength": 256}, + "event_count": {"type": "integer", "minimum": 0}, + "ordered_hash": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"} + } + } + }, + "reconciliation": { + "type": "object", + "additionalProperties": false, + "required": ["method", "outcome", "compared_observers"], + "properties": { + "method": {"type": "string", "enum": ["exact-count", "ordered-hash", "semantic-equivalence", "quorum", "domain-specific"]}, + "outcome": {"type": "string", "enum": ["consistent", "inconsistent"]}, + "compared_observers": {"type": "integer", "minimum": 2}, + "distinct_counts": {"type": "array", "items": {"type": "integer", "minimum": 0}}, + "distinct_hashes": {"type": "array", "items": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}} + } + }, + "created_at": {"type": "string", "format": "date-time"} + } +} diff --git a/contracts/externalruntime/v1/gate-definition.schema.json b/contracts/externalruntime/v1/gate-definition.schema.json new file mode 100644 index 0000000..0d3b2c8 --- /dev/null +++ b/contracts/externalruntime/v1/gate-definition.schema.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://bindery.dev/contracts/externalruntime/v1/gate-definition.schema.json", + "title": "Bindery calibrated gate definition", + "type": "object", + "additionalProperties": false, + "required": ["gate_id", "version", "implementation_hash", "consequential", "applies_when"], + "properties": { + "gate_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "version": {"type": "string", "minLength": 1, "maxLength": 64}, + "implementation_hash": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "consequential": {"type": "boolean"}, + "applies_when": { + "type": "object", + "additionalProperties": false, + "properties": { + "phases": {"type": "array", "uniqueItems": true, "items": {"type": "string"}}, + "artifact_types": {"type": "array", "uniqueItems": true, "items": {"type": "string"}}, + "capabilities": {"type": "array", "uniqueItems": true, "items": {"type": "string"}} + } + }, + "calibration": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "fixture_id", "fixture_digest", "expected", "observed"], + "properties": { + "kind": {"type": "string", "enum": ["positive", "negative"]}, + "fixture_id": {"type": "string", "minLength": 1, "maxLength": 256}, + "fixture_digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "expected": {"type": "string", "enum": ["PASS", "FAIL"]}, + "observed": {"type": "string", "enum": ["PASS", "FAIL", "ERROR"]} + } + } + } + } +} diff --git a/contracts/externalruntime/v1/gate-result.schema.json b/contracts/externalruntime/v1/gate-result.schema.json new file mode 100644 index 0000000..b0ca088 --- /dev/null +++ b/contracts/externalruntime/v1/gate-result.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://bindery.dev/contracts/externalruntime/v1/gate-result.schema.json", + "title": "Bindery calibrated gate result", + "type": "object", + "additionalProperties": false, + "required": ["gate_id", "gate_version", "implementation_hash", "status", "reason", "calibration_valid"], + "properties": { + "gate_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "gate_version": {"type": "string", "minLength": 1, "maxLength": 64}, + "implementation_hash": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "status": {"type": "string", "enum": ["PASS", "FAIL", "NOT_APPLICABLE", "UNRESOLVED", "ERROR"]}, + "reason": {"type": "string", "minLength": 1, "maxLength": 2048}, + "calibration_valid": {"type": "boolean"} + } +} diff --git a/contracts/externalruntime/v1/observation.schema.json b/contracts/externalruntime/v1/observation.schema.json new file mode 100644 index 0000000..5154500 --- /dev/null +++ b/contracts/externalruntime/v1/observation.schema.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://bindery.dev/contracts/externalruntime/v1/observation.schema.json", + "title": "Bindery attributable observation", + "type": "object", + "additionalProperties": false, + "required": ["observation_id", "observer_id", "execution_id", "stream_id", "sequence", "observed_at", "event"], + "properties": { + "observation_id": {"type": "string", "format": "uuid"}, + "observer_id": {"type": "string", "format": "uuid"}, + "execution_id": {"type": "string", "format": "uuid"}, + "stream_id": {"type": "string", "minLength": 1, "maxLength": 256}, + "sequence": {"type": "integer", "minimum": 0}, + "observed_at": {"type": "string", "format": "date-time"}, + "event": {} + } +} diff --git a/contracts/externalruntime/v1/openapi.yaml b/contracts/externalruntime/v1/openapi.yaml index c4325a0..223b786 100644 --- a/contracts/externalruntime/v1/openapi.yaml +++ b/contracts/externalruntime/v1/openapi.yaml @@ -36,6 +36,36 @@ paths: parameters: [{in: path, name: session_id, required: true, schema: {type: string, format: uuid}}] responses: '200': {description: Public session, content: {application/json: {schema: {$ref: './public-session.schema.json'}}}} + /v1/placements/{placement_id}: + get: + operationId: getPlacement + parameters: [{in: path, name: placement_id, required: true, schema: {type: string, format: uuid}}] + responses: + '200': {description: Public placement and allocator implementation identity, content: {application/json: {schema: {$ref: './public-placement.schema.json'}}}} + /v1/executions/{execution_id}: + get: + operationId: getExecution + parameters: [{in: path, name: execution_id, required: true, schema: {type: string, format: uuid}}] + responses: + '200': {description: Public external execution, content: {application/json: {schema: {$ref: './public-execution.schema.json'}}}} + /v1/executions/{execution_id}/evidence-sets: + post: + operationId: reconcileExecutionEvidence + security: [{bearerAuth: []}] + parameters: + - {in: path, name: execution_id, required: true, schema: {type: string, format: uuid}} + - {in: header, name: Idempotency-Key, required: true, schema: {type: string, minLength: 1}} + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/ReconcileEvidenceRequest'}}} + responses: + '201': {description: Reconciled evidence set, content: {application/json: {schema: {$ref: './evidence-set.schema.json'}}}} + /v1/evidence-sets/{evidence_set_id}: + get: + operationId: getEvidenceSet + parameters: [{in: path, name: evidence_set_id, required: true, schema: {type: string, pattern: '^sha256:[0-9a-f]{64}$'}}] + responses: + '200': {description: Public reconciled evidence set, content: {application/json: {schema: {$ref: './evidence-set.schema.json'}}}} /v1/sessions/{session_id}/enrollments: post: operationId: enrollClient @@ -162,3 +192,22 @@ components: report_id: {type: string, format: uuid} kind: {type: string, enum: [ready, started, exited, failed, capture_degraded]} reason: {type: string, maxLength: 256} + ReconcileEvidenceRequest: + type: object + additionalProperties: false + required: [method, observations] + properties: + method: {type: string, enum: [exact-count, ordered-hash, semantic-equivalence, quorum, domain-specific]} + observations: + type: array + minItems: 2 + items: + type: object + additionalProperties: false + required: [observer_id, execution_id, stream_id, event_count] + properties: + observer_id: {type: string, format: uuid} + execution_id: {type: string, format: uuid} + stream_id: {type: string, minLength: 1, maxLength: 256} + event_count: {type: integer, minimum: 0} + ordered_hash: {type: string, pattern: '^sha256:[0-9a-f]{64}$'} diff --git a/contracts/externalruntime/v1/public-capture.schema.json b/contracts/externalruntime/v1/public-capture.schema.json index 4990be0..270c16a 100644 --- a/contracts/externalruntime/v1/public-capture.schema.json +++ b/contracts/externalruntime/v1/public-capture.schema.json @@ -4,10 +4,11 @@ "title": "Bindery public capture stream", "type": "object", "additionalProperties": false, - "required": ["capture_id", "session_id", "producer_client_id", "producer_class", "capture_method", "adapter_id", "adapter_version", "status", "created_at"], + "required": ["capture_id", "session_id", "execution_id", "producer_client_id", "producer_class", "capture_method", "adapter_id", "adapter_version", "status", "created_at"], "properties": { "capture_id": {"type": "string", "format": "uuid"}, "session_id": {"type": "string", "format": "uuid"}, + "execution_id": {"type": "string", "format": "uuid"}, "producer_client_id": {"type": "string", "format": "uuid"}, "producer_class": {"type": "string", "enum": ["player", "observer", "relay", "normalizer"]}, "capture_method": {"type": "string", "minLength": 1, "maxLength": 128}, @@ -20,4 +21,3 @@ "objects": {"type": "array", "items": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}} } } - diff --git a/contracts/externalruntime/v1/public-execution.schema.json b/contracts/externalruntime/v1/public-execution.schema.json new file mode 100644 index 0000000..d706445 --- /dev/null +++ b/contracts/externalruntime/v1/public-execution.schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://bindery.dev/contracts/externalruntime/v1/public-execution.schema.json", + "title": "Bindery external-runtime public execution", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "execution_id", "session_id", "phase", "created_at"], + "properties": { + "schema_version": {"const": "1.0.0"}, + "execution_id": {"type": "string", "format": "uuid"}, + "session_id": {"type": "string", "format": "uuid"}, + "placement_id": {"type": "string", "format": "uuid"}, + "phase": {"type": "string", "enum": ["prepared", "running", "ended", "failed", "expired"]}, + "created_at": {"type": "string", "format": "date-time"}, + "started_at": {"type": "string", "format": "date-time"}, + "ended_at": {"type": "string", "format": "date-time"}, + "evidence_set_ids": { + "type": "array", + "uniqueItems": true, + "items": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"} + } + } +} diff --git a/contracts/externalruntime/v1/public-placement.schema.json b/contracts/externalruntime/v1/public-placement.schema.json new file mode 100644 index 0000000..b47405d --- /dev/null +++ b/contracts/externalruntime/v1/public-placement.schema.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://bindery.dev/contracts/externalruntime/v1/public-placement.schema.json", + "title": "Bindery external-runtime public placement", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "placement_id", "session_id", "region", "relay_provider_id", "relay_allocation_id", "relay_endpoint", "policy_version", "allocator", "created_at"], + "properties": { + "schema_version": {"const": "1.0.0"}, + "placement_id": {"type": "string", "format": "uuid"}, + "session_id": {"type": "string", "format": "uuid"}, + "region": {"type": "string", "minLength": 1, "maxLength": 64}, + "relay_provider_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "relay_allocation_id": {"type": "string", "format": "uuid"}, + "relay_endpoint": {"type": "string", "minLength": 3, "maxLength": 256}, + "policy_version": {"type": "string", "minLength": 1, "maxLength": 64}, + "decision_summary": {"type": "string", "maxLength": 2048}, + "allocator": { + "type": "object", + "additionalProperties": false, + "required": ["implementation", "repository", "revision", "config_digest"], + "properties": { + "implementation": {"type": "string", "minLength": 1, "maxLength": 128}, + "repository": {"type": "string", "minLength": 1, "maxLength": 512}, + "revision": {"type": "string", "minLength": 1, "maxLength": 128}, + "config_digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"} + } + }, + "created_at": {"type": "string", "format": "date-time"} + } +} diff --git a/contracts/externalruntime/v1/public-session.schema.json b/contracts/externalruntime/v1/public-session.schema.json index 29c40cf..c81f2db 100644 --- a/contracts/externalruntime/v1/public-session.schema.json +++ b/contracts/externalruntime/v1/public-session.schema.json @@ -4,10 +4,12 @@ "title": "Bindery external-runtime public match session", "type": "object", "additionalProperties": false, - "required": ["schema_version", "session_id", "created_by_account_id", "created_at", "phase", "compatibility", "participant_policy", "enrollments", "capture_policy", "public_data_notice_version"], + "required": ["schema_version", "session_id", "execution_id", "created_by_account_id", "created_at", "phase", "compatibility", "participant_policy", "enrollments", "capture_policy", "public_data_notice_version"], "properties": { "schema_version": {"const": "1.0.0"}, "session_id": {"type": "string", "minLength": 36, "maxLength": 36}, + "execution_id": {"type": "string", "minLength": 36, "maxLength": 36}, + "placement_id": {"type": "string", "minLength": 36, "maxLength": 36}, "created_by_account_id": {"type": "string", "minLength": 36, "maxLength": 36}, "created_at": {"type": "string", "format": "date-time"}, "updated_at": {"type": "string", "format": "date-time"}, @@ -54,14 +56,29 @@ }, "publicPlacement": { "type": "object", "additionalProperties": false, - "required": ["region", "relay_provider_id", "relay_allocation_id", "relay_endpoint", "policy_version"], + "required": ["schema_version", "placement_id", "session_id", "region", "relay_provider_id", "relay_allocation_id", "relay_endpoint", "policy_version", "allocator", "created_at"], "properties": { + "schema_version": {"const": "1.0.0"}, + "placement_id": {"type": "string", "minLength": 36, "maxLength": 36}, + "session_id": {"type": "string", "minLength": 36, "maxLength": 36}, "region": {"type": "string", "minLength": 1, "maxLength": 64}, "relay_provider_id": {"type": "string", "minLength": 1, "maxLength": 128}, "relay_allocation_id": {"type": "string", "minLength": 36, "maxLength": 36}, "relay_endpoint": {"type": "string", "minLength": 3, "maxLength": 256}, "policy_version": {"type": "string", "minLength": 1, "maxLength": 64}, - "decision_summary": {"type": "string", "maxLength": 2048} + "decision_summary": {"type": "string", "maxLength": 2048}, + "allocator": { + "type": "object", + "additionalProperties": false, + "required": ["implementation", "repository", "revision", "config_digest"], + "properties": { + "implementation": {"type": "string", "minLength": 1, "maxLength": 128}, + "repository": {"type": "string", "minLength": 1, "maxLength": 512}, + "revision": {"type": "string", "minLength": 1, "maxLength": 128}, + "config_digest": {"$ref": "#/$defs/hash"} + } + }, + "created_at": {"type": "string", "format": "date-time"} } }, "publicEnrollment": { diff --git a/contracts/externalruntime/v1/telemetry-event.schema.json b/contracts/externalruntime/v1/telemetry-event.schema.json index 6a27e7d..d973530 100644 --- a/contracts/externalruntime/v1/telemetry-event.schema.json +++ b/contracts/externalruntime/v1/telemetry-event.schema.json @@ -4,12 +4,13 @@ "title": "Bindery public telemetry event envelope", "type": "object", "additionalProperties": false, - "required": ["schema", "schema_version", "event_id", "session_id", "capture_id", "producer_client_id", "producer_class", "capture_method", "adapter", "sequence", "received_at", "event_type", "payload"], + "required": ["schema", "schema_version", "event_id", "session_id", "execution_id", "capture_id", "producer_client_id", "producer_class", "capture_method", "adapter", "sequence", "received_at", "event_type", "payload"], "properties": { "schema": {"const": "bindery.telemetry.event"}, "schema_version": {"const": "1.0.0"}, "event_id": {"type": "string", "minLength": 36, "maxLength": 36}, "session_id": {"type": "string", "minLength": 36, "maxLength": 36}, + "execution_id": {"type": "string", "minLength": 36, "maxLength": 36}, "capture_id": {"type": "string", "minLength": 36, "maxLength": 36}, "producer_client_id": {"type": "string", "minLength": 36, "maxLength": 36}, "producer_class": {"type": "string", "enum": ["player", "observer", "relay", "normalizer"]}, @@ -26,4 +27,3 @@ "derivation": {"type": "object", "additionalProperties": false, "required": ["normalizer_id", "normalizer_version", "source_event_ids"], "properties": {"normalizer_id": {"type": "string"}, "normalizer_version": {"type": "string"}, "source_event_ids": {"type": "array", "minItems": 1, "items": {"type": "string"}}}} } } - diff --git a/docs/architecture/evidence-and-gates.md b/docs/architecture/evidence-and-gates.md new file mode 100644 index 0000000..b2958a3 --- /dev/null +++ b/docs/architecture/evidence-and-gates.md @@ -0,0 +1,108 @@ +# Evidence, reconciliation, and calibrated gates + +## Durable graph + +The external-runtime evidence graph is: + +```text +Identity <- Session <- Execution <- Observation <- EvidenceSet + ^ + | + Placement +``` + +All arrows are durable identifiers. Public records may embed a convenience +copy, but the identifier remains the referential boundary. + +- `Identity` says who controls a token-backed claim. +- `Session` says what was admitted and under which compatibility policy. +- `Placement` says which transport allocation and allocator implementation were + selected. +- `Execution` names the external run independently of admission state. +- `Observation` says what one observer reported or measured. +- `EvidenceSet` retains compared observations and the reconciliation performed + over them. + +An observation is never silently promoted to truth. Reconciliation reports +agreement or disagreement at the level promised by its method. + +## Reconciliation methods + +| Method | Meaning | Core status | +| --- | --- | --- | +| `exact-count` | All independent streams report the same event count | Implemented | +| `ordered-hash` | All independent streams report the same ordered-stream digest | Implemented | +| `semantic-equivalence` | Domain normalizer considers streams equivalent | Reserved | +| `quorum` | A declared observer quorum agrees | Reserved | +| `domain-specific` | Versioned adapter/domain policy | Reserved | + +Reserved methods fail explicitly as unsupported. They are not aliases for count +equality with more ambitious names. + +An evidence set requires at least two distinct observers. Two streams emitted +by one observer are useful redundancy but not independent evidence. + +## Gate evaluation order + +Gate evaluation has three separate questions: + +1. Does the gate apply to this phase, artifact type, and capability context? +2. Is this exact gate implementation calibrated against known-pass and + known-fail fixtures? +3. What did the calibrated evaluator observe for the subject? + +The order matters. Evaluating first and inventing applicability afterward is +how unrelated checks become impressive-looking blockers. + +Consequential gates carry: + +```text +gate_id +gate_version +implementation_hash +applies_when +positive_control +negative_control +``` + +The result status is one of `PASS`, `FAIL`, `NOT_APPLICABLE`, `UNRESOLVED`, or +`ERROR`. `FAIL` is reserved for an applicable, calibrated evaluator rejecting +the subject. Broken fixtures, missing evaluator context, and internal failures +have their own states. + +## Persistence boundary + +The reference store is an atomic JSON snapshot containing private verifier +material and public records. It is suitable only for one writer: + +- state file mode is `0600`; +- symlink targets are rejected; +- writes use a same-directory temporary file, file sync, atomic rename, and + directory sync; +- a failed durable write rolls the in-memory mutation back; +- startup rejects an unsupported schema or dangling identity/session/ + placement/execution/evidence reference. + +The Helm deployment enforces one replica and `Recreate` over one persistent +volume. PostgreSQL is the next store when multiple brokers become a real +requirement. Increasing `replicaCount` before that is not scaling; it is a +random-history generator. + +## Recovery checks + +Before accepting this slice: + +1. Create two identities, one session, one placement, one execution, two + enrollments, and one evidence set. +2. Stop the broker after all mutation responses have completed. +3. Start it against the same state file. +4. Resolve every public record by its stable ID. +5. Authenticate with the pre-restart account and client lease tokens. +6. Verify an injected persistence failure returns an error and leaves no + in-memory mutation. +7. Verify a positive-control failure yields gate `ERROR`, and a context mismatch + yields `NOT_APPLICABLE`. + +Rollback is the previous binary plus the pre-upgrade state snapshot. A new +binary must not rewrite an older snapshot in place until a versioned migration +and downgrade path exist. diff --git a/docs/assessments/2026-08-25-ra2-vertical-slice.md b/docs/assessments/2026-08-25-ra2-vertical-slice.md new file mode 100644 index 0000000..944020b --- /dev/null +++ b/docs/assessments/2026-08-25-ra2-vertical-slice.md @@ -0,0 +1,145 @@ +# RA2 external-runtime vertical slice — 2026-08-25 + +## Conclusion + +Bindery's external-runtime thesis is empirically demonstrated once. It is no +longer only an architecture whose pieces plausibly compose. + +The demonstrated path was: + +```text +control plane -> allocator -> placement -> isolated clients -> pinned transport + -> live game -> instrumentation -> cross-client evidence +``` + +The material result was not merely that Yuri's Revenge ran. Two independently +instrumented clients produced matching 6,651-event accounts of the execution. +That is stronger evidence than a zero exit code, one adapter verdict, or a +server observing two connections. + +This remains one external runtime and one run shape. The accurate claim is +therefore **demonstrated once**, not generalized, production-ready, or proven +across arbitrary games. + +## What the run established + +### External-runtime orchestration works + +The game process remained outside Bindery. Bindery still established identity, +allocated topology, launched role-specific participants, pinned transport, +collected observations, and evaluated the result. + +### Reproducible does not mean homogeneous + +Byte-identical appliance clones acquired distinct runtime identities and +roles. `IsSpectator`, AI houses, and launch policy were session inputs rather +than image mutations. The appliance remained reproducible while the execution +topology varied. + +### Independent observation is a correctness primitive + +Neither client was treated as authoritative. Their observations remained +attributable and were reconciled. Equal event counts establish consistency at +the count level; they do not establish semantic equivalence. Bindery Core now +models that distinction as an `EvidenceSet` with an explicit reconciliation +method and outcome. + +The 6,651/6,651 result is retained as the regression fixture for +`exact-count`, the first and deliberately weakest policy. + +## What failed usefully + +### A deterministic gate was repeatedly unrelated to truth + +The adapter interpreted a diagnostic string as a verdict and reported both +successful runs as failures and failed runs as successes across four +iterations. Repetition made the answer stable, not correct. + +Design rule: + +> Consequential gates require calibration evidence, not merely implementation +> evidence. + +A consequential gate must carry its version and implementation hash, plus at +least one known-pass and one known-fail control. A verifier that fails its +positive control is `ERROR`; it does not become reassuringly strict. + +### Some gates were evaluated in the wrong context + +Oracle tracing and Kctl checks were applied where their phase, artifact type, +or capabilities did not make them applicable. Gate outcomes are therefore +five-state: + +- `PASS` +- `FAIL` +- `NOT_APPLICABLE` +- `UNRESOLVED` +- `ERROR` + +Missing applicability evidence is `UNRESOLVED`. A known context mismatch is +`NOT_APPLICABLE`. Neither silently becomes `FAIL`. + +### Control-plane state could outlive neither restart nor replica boundaries + +The service held identity, session, placement, and enrollment records in +process maps while the chart requested two replicas. That allowed two brokers +to expose mutually unrelated histories behind one Service. Persistence was not +the only missing property; there was no coherent writer. + +The reference service now uses a crash-safe, file-backed snapshot as a bounded +single-instance implementation. A mutation is acknowledged only after the +snapshot is written, synced, renamed, and the directory entry synced. Failed +writes restore the previous in-memory snapshot. The chart enforces one replica, +`Recreate`, and a persistent volume. + +This is not a substitute for PostgreSQL. It is the smallest implementation that +makes the ontology survive restart without pretending to support concurrent +writers. Moving back to multiple replicas requires a shared relational store +and transactional mutation semantics. + +### The allocator lacked durable implementation identity + +The allocator was behaviorally indispensable but had previously existed +outside Git. Every new placement now records: + +```text +implementation +repository +revision +config_digest +``` + +The reference allocator refuses to start without a full Git revision. A +historical question such as “which allocator produced this placement?” is now +answerable from the placement record rather than the operator's current +checkout. + +## Historical evidence limitation + +The successful run happened before these durable core types existed. Its +reported chain, 39-test adapter result, and matching 6,651-event observations +are valid historical findings, but they must not be backfilled with invented +`execution_id`, `placement_id`, or `evidence_set_id` values. + +The next run through the hardened control plane should create those records +natively. That run is the recovery path from narrative provenance to +referentially complete provenance. + +## Next boundary + +Do not add richer RA2 behavior until the durability, reconciliation, +applicability, and calibration changes pass repository CI and one restart drill. +After that, run an external runtime that is not RA2. The purpose is abstraction +pressure, not another game-specific trophy: + +- the runtime must use a materially different integration mechanism; +- only the adapter may change; +- `Session -> Placement -> Execution -> Observation -> EvidenceSet` must remain + intact; +- every behaviorally significant component must resolve to an implementation + identity; +- the run must include both a process restart and a known-fail calibration + control. + +RA2 has supplied depth. The next runtime must test whether Bindery has an +abstraction rather than a particularly well-documented CnCNet harness. diff --git a/docs/roadmap/post-ra2-hardening.yaml b/docs/roadmap/post-ra2-hardening.yaml new file mode 100644 index 0000000..05d6acc --- /dev/null +++ b/docs/roadmap/post-ra2-hardening.yaml @@ -0,0 +1,73 @@ +schema: bindery.external-runtime-roadmap/v1 +recorded_at: 2026-08-25 +trigger: ra2-two-client-vertical-slice +claim: empirically-demonstrated-once +evidence: + external_runtime: ra2-yr-cncnet-v0.2 + participants: 2 + matching_event_count_per_participant: 6651 + adapter_tests_reported: 39 + historical_limit: >- + The run predates durable execution, placement, and evidence-set IDs. Do not + fabricate those identifiers retroactively; repeat through the hardened + control plane. +work: + - id: ERH-001 + title: Persist the control-plane identity graph + status: implemented-pending-ci + scope: [identity, session, placement, execution, enrollment, idempotency] + acceptance: + - Every evidence reference resolves after a broker restart. + - A failed durable write rolls back the in-memory mutation. + - The file-backed mode has exactly one writer. + - id: ERH-002 + title: Promote observation reconciliation into Bindery Core + status: implemented-pending-ci + depends_on: [ERH-001] + acceptance: + - Two distinct observers with counts 6651 and 6651 reconcile as consistent. + - Unequal counts remain attributable and reconcile as inconsistent. + - Unsupported methods fail explicitly. + - id: ERH-003 + title: Make gate applicability a typed result + status: implemented-pending-ci + acceptance: + - Outcomes are PASS, FAIL, NOT_APPLICABLE, UNRESOLVED, or ERROR. + - Missing context cannot silently become FAIL. + - id: ERH-004 + title: Require verifier calibration evidence + status: implemented-pending-ci + depends_on: [ERH-003] + acceptance: + - Consequential gates carry version and implementation hash. + - Known-pass and known-fail controls both match expectation. + - An always-failing verifier is ERROR, not a strict gate. + - id: ERH-005 + title: Resolve allocator implementation identity + status: implemented-pending-ci + depends_on: [ERH-001] + acceptance: + - Every placement records implementation, repository, revision, and config digest. + - The reference service refuses an unresolved build revision. + - id: ERH-006 + title: Repeat the RA2 run through durable IDs + status: pending + depends_on: [ERH-001, ERH-002, ERH-003, ERH-004, ERH-005] + acceptance: + - The evidence set resolves to execution, session, placement, and identities after restart. + - Exact-count reconciliation records the two client streams without adapter-owned adjudication. + - id: ERH-007 + title: Run a non-RA2 external runtime + status: pending + depends_on: [ERH-006] + constraints: + - materially-different-integration-mechanism + - adapter-swap-only + - no-new-core-game-specific-fields + acceptance: + - Session, placement, execution, observation, and evidence contracts survive unchanged. + - A second adapter implementation is sufficient. + - A restart drill and positive/negative gate controls are included. +ordering_rule: >- + Do not spend the next slice on richer RA2 behavior. Close ERH-001 through + ERH-006, then use ERH-007 to test abstraction. diff --git a/internal/capture/capture.go b/internal/capture/capture.go index 78b4725..8bd5192 100644 --- a/internal/capture/capture.go +++ b/internal/capture/capture.go @@ -20,6 +20,7 @@ var ( type RawEvent struct { EventID string `json:"event_id"` SessionID string `json:"session_id"` + ExecutionID string `json:"execution_id"` CaptureID string `json:"capture_id"` ProducerClientID string `json:"producer_client_id"` ProducerClass string `json:"producer_class"` @@ -37,6 +38,7 @@ type RawEvent struct { type Batch struct { CaptureID string `json:"capture_id"` + ExecutionID string `json:"execution_id"` ProducerClientID string `json:"producer_client_id"` FirstSequence uint64 `json:"first_sequence"` LastSequence uint64 `json:"last_sequence"` @@ -46,6 +48,7 @@ type Batch struct { type Receipt struct { CaptureID string `json:"capture_id"` + ExecutionID string `json:"execution_id"` ProducerClientID string `json:"producer_client_id"` FirstSequence uint64 `json:"first_sequence"` LastSequence uint64 `json:"last_sequence"` @@ -56,6 +59,7 @@ type Receipt struct { } type StreamClose struct { + ExecutionID string `json:"execution_id"` FinalSequence uint64 `json:"final_sequence"` ObservedGaps [][2]uint64 `json:"observed_gaps,omitempty"` LocalDrops uint64 `json:"local_drops"` @@ -65,6 +69,7 @@ type StreamClose struct { type CompletenessManifest struct { CaptureID string `json:"capture_id"` + ExecutionID string `json:"execution_id"` ProducerClientID string `json:"producer_client_id"` ExpectedThrough *uint64 `json:"expected_through,omitempty"` ObservedRanges [][2]uint64 `json:"observed_ranges"` @@ -77,6 +82,7 @@ type CompletenessManifest struct { } type stream struct { + executionID string events map[uint64]RawEvent batches map[string]string objects []string @@ -104,9 +110,12 @@ func (s *Store) Ingest(batch Batch) (Receipt, error) { defer s.mu.Unlock() state := s.streams[key] if state == nil { - state = &stream{events: make(map[uint64]RawEvent), batches: make(map[string]string)} + state = &stream{executionID: batch.ExecutionID, events: make(map[uint64]RawEvent), batches: make(map[string]string)} s.streams[key] = state } + if state.executionID != batch.ExecutionID { + return Receipt{}, ErrSequenceConflict + } if previous, ok := state.batches[batchKey]; ok { if previous != batch.ContentHash { return Receipt{}, ErrSequenceConflict @@ -132,10 +141,10 @@ func (s *Store) Close(captureID, producerID string, close StreamClose) error { defer s.mu.Unlock() state := s.streams[key] if state == nil { - state = &stream{events: make(map[uint64]RawEvent), batches: make(map[string]string)} + state = &stream{executionID: close.ExecutionID, events: make(map[uint64]RawEvent), batches: make(map[string]string)} s.streams[key] = state } - if close.EndReason == "" { + if close.ExecutionID == "" || close.EndReason == "" || state.executionID != close.ExecutionID { return ErrBatchInvalid } state.close = &close @@ -151,8 +160,7 @@ func (s *Store) AppendDerivation(captureID, producerID, derivationID string) err key := streamKey(captureID, producerID) state := s.streams[key] if state == nil { - state = &stream{events: make(map[uint64]RawEvent), batches: make(map[string]string)} - s.streams[key] = state + return errors.New("capture stream not found") } state.derivations = append(state.derivations, derivationID) return nil @@ -171,7 +179,7 @@ func (s *Store) Manifest(captureID, producerID string) (CompletenessManifest, er } sort.Slice(sequences, func(i, j int) bool { return sequences[i] < sequences[j] }) ranges := toRanges(sequences) - manifest := CompletenessManifest{CaptureID: captureID, ProducerClientID: producerID, ObservedRanges: ranges, MissingRanges: missingRanges(sequences), RawObjectHashes: unique(state.objects), DerivationIDs: append([]string(nil), state.derivations...)} + manifest := CompletenessManifest{CaptureID: captureID, ExecutionID: state.executionID, ProducerClientID: producerID, ObservedRanges: ranges, MissingRanges: missingRanges(sequences), RawObjectHashes: unique(state.objects), DerivationIDs: append([]string(nil), state.derivations...)} if state.close != nil { expected := state.close.FinalSequence manifest.ExpectedThrough = &expected @@ -184,18 +192,18 @@ func (s *Store) Manifest(captureID, producerID string) (CompletenessManifest, er } func (s *Store) receiptLocked(batch Batch, state *stream, duplicate bool) Receipt { - return Receipt{CaptureID: batch.CaptureID, ProducerClientID: batch.ProducerClientID, FirstSequence: batch.FirstSequence, LastSequence: batch.LastSequence, AcknowledgedThrough: acknowledgedThrough(state.events), MissingRanges: missingRangesFrom(state.events), RawObjectHash: batch.ContentHash, Duplicate: duplicate} + return Receipt{CaptureID: batch.CaptureID, ExecutionID: batch.ExecutionID, ProducerClientID: batch.ProducerClientID, FirstSequence: batch.FirstSequence, LastSequence: batch.LastSequence, AcknowledgedThrough: acknowledgedThrough(state.events), MissingRanges: missingRangesFrom(state.events), RawObjectHash: batch.ContentHash, Duplicate: duplicate} } func validateBatch(batch Batch) error { - if batch.CaptureID == "" || batch.ProducerClientID == "" || len(batch.Events) == 0 || batch.FirstSequence > batch.LastSequence { + if batch.CaptureID == "" || batch.ExecutionID == "" || batch.ProducerClientID == "" || len(batch.Events) == 0 || batch.FirstSequence > batch.LastSequence { return ErrBatchInvalid } if uint64(len(batch.Events)) != batch.LastSequence-batch.FirstSequence+1 { return ErrBatchInvalid } for index, event := range batch.Events { - if event.CaptureID != batch.CaptureID || event.ProducerClientID != batch.ProducerClientID || event.Sequence != batch.FirstSequence+uint64(index) || len(event.Payload) == 0 || !json.Valid(event.Payload) { + if event.CaptureID != batch.CaptureID || event.ExecutionID != batch.ExecutionID || event.ProducerClientID != batch.ProducerClientID || event.Sequence != batch.FirstSequence+uint64(index) || len(event.Payload) == 0 || !json.Valid(event.Payload) { return ErrBatchInvalid } } diff --git a/internal/capture/capture_test.go b/internal/capture/capture_test.go index 429abd2..5b203bb 100644 --- a/internal/capture/capture_test.go +++ b/internal/capture/capture_test.go @@ -36,7 +36,7 @@ func TestBatchIngestIsAtLeastOnceWithGapsAndLateAppend(t *testing.T) { if _, err := store.Ingest(testBatchWithPayload("capture-1", "player-1", 3, 3, `{"different":true}`)); !errors.Is(err, ErrSequenceConflict) { t.Fatalf("conflicting sequence = %v", err) } - if err := store.Close("capture-1", "player-1", StreamClose{FinalSequence: 3, LocalDrops: 1, EndReason: "client-exit", ClosedAt: time.Now().UTC()}); err != nil { + if err := store.Close("capture-1", "player-1", StreamClose{ExecutionID: "execution-1", FinalSequence: 3, LocalDrops: 1, EndReason: "client-exit", ClosedAt: time.Now().UTC()}); err != nil { t.Fatal(err) } manifest, err := store.Manifest("capture-1", "player-1") @@ -72,7 +72,7 @@ func testBatch(captureID, producerID string, first, last uint64) Batch { func testBatchWithPayload(captureID, producerID string, first, last uint64, payload string) Batch { events := make([]RawEvent, 0, last-first+1) for sequence := first; sequence <= last; sequence++ { - events = append(events, RawEvent{EventID: captureID + "-" + string(rune('a'+sequence)), SessionID: "session-1", CaptureID: captureID, ProducerClientID: producerID, ProducerClass: "player", CaptureMethod: "test", AdapterID: "adapter", AdapterVersion: "1", Sequence: sequence, ReceivedAt: time.Now().UTC(), EventType: "game.event", Payload: []byte(payload)}) + events = append(events, RawEvent{EventID: captureID + "-" + string(rune('a'+sequence)), SessionID: "session-1", ExecutionID: "execution-1", CaptureID: captureID, ProducerClientID: producerID, ProducerClass: "player", CaptureMethod: "test", AdapterID: "adapter", AdapterVersion: "1", Sequence: sequence, ReceivedAt: time.Now().UTC(), EventType: "game.event", Payload: []byte(payload)}) } - return Batch{CaptureID: captureID, ProducerClientID: producerID, FirstSequence: first, LastSequence: last, Events: events} + return Batch{CaptureID: captureID, ExecutionID: "execution-1", ProducerClientID: producerID, FirstSequence: first, LastSequence: last, Events: events} } diff --git a/internal/capture/fixtures.go b/internal/capture/fixtures.go index 4d13e26..fbbd28a 100644 --- a/internal/capture/fixtures.go +++ b/internal/capture/fixtures.go @@ -8,9 +8,10 @@ import ( func fixtureCaptureBatch(captureID, producerID string, first, last uint64) Batch { events := make([]RawEvent, 0, last-first+1) for sequence := first; sequence <= last; sequence++ { - events = append(events, RawEvent{ + events = append(events, RawEvent{ EventID: fmt.Sprintf("%s-%s-event-%d", captureID, producerID, sequence), SessionID: "session-fixture", + ExecutionID: "execution-fixture", CaptureID: captureID, ProducerClientID: producerID, ProducerClass: "player", @@ -24,9 +25,9 @@ func fixtureCaptureBatch(captureID, producerID string, first, last uint64) Batch Payload: []byte(fmt.Sprintf(`{"sequence":%d}`, sequence)), }) } - return Batch{CaptureID: captureID, ProducerClientID: producerID, FirstSequence: first, LastSequence: last, Events: events} + return Batch{CaptureID: captureID, ExecutionID: "execution-fixture", ProducerClientID: producerID, FirstSequence: first, LastSequence: last, Events: events} } func fixtureCaptureClose(finalSequence uint64) StreamClose { - return StreamClose{FinalSequence: finalSequence, ObservedGaps: [][2]uint64{{2, 2}}, LocalDrops: 1, EndReason: "client-exit", ClosedAt: time.Date(2026, 8, 24, 12, 1, 0, 0, time.UTC)} + return StreamClose{ExecutionID: "execution-fixture", FinalSequence: finalSequence, ObservedGaps: [][2]uint64{{2, 2}}, LocalDrops: 1, EndReason: "client-exit", ClosedAt: time.Date(2026, 8, 24, 12, 1, 0, 0, time.UTC)} } diff --git a/internal/externalruntime/erm204_test.go b/internal/externalruntime/erm204_test.go index 76a82b8..2faff81 100644 --- a/internal/externalruntime/erm204_test.go +++ b/internal/externalruntime/erm204_test.go @@ -18,6 +18,7 @@ func TestERM204PublishesCoordinatorPlacement(t *testing.T) { RelayEndpoint: "127.0.0.1:40000", PolicyVersion: "relay-placement/v1", DecisionSummary: "lowest-p95 eligible relay", + Allocator: fixtureAllocatorIdentity(), }, nil }) identity := mustIdentity(t, service, "erm204-owner") @@ -30,9 +31,17 @@ func TestERM204PublishesCoordinatorPlacement(t *testing.T) { t.Fatalf("allocator intent = %+v, want %+v", received, request.Placement) } placement := created.PublicSession.Placement - if placement == nil || placement.RelayAllocationID != erm204AllocationID || placement.RelayEndpoint != "127.0.0.1:40000" || placement.PolicyVersion != "relay-placement/v1" { + if placement == nil || placement.PlacementID == "" || placement.SessionID != created.PublicSession.SessionID || placement.RelayAllocationID != erm204AllocationID || placement.RelayEndpoint != "127.0.0.1:40000" || placement.PolicyVersion != "relay-placement/v1" || placement.Allocator.Revision == "" { t.Fatalf("public placement = %+v", placement) } + resolved, err := service.GetPlacement(placement.PlacementID) + if err != nil || resolved.PlacementID != created.PublicSession.PlacementID { + t.Fatalf("stable placement did not resolve: %+v, error = %v", resolved, err) + } + execution, err := service.GetExecution(created.PublicSession.ExecutionID) + if err != nil || execution.SessionID != created.PublicSession.SessionID || execution.PlacementID != placement.PlacementID { + t.Fatalf("stable execution did not resolve: %+v, error = %v", execution, err) + } encoded, err := json.Marshal(created.PublicSession) if err != nil { t.Fatal(err) @@ -47,9 +56,9 @@ func TestERM204RejectsInvalidCoordinatorPlacementBeforeSession(t *testing.T) { name string placement PublicPlacement }{ - {name: "missing provider", placement: PublicPlacement{Region: "eu-north", RelayAllocationID: erm204AllocationID, RelayEndpoint: "127.0.0.1:40000", PolicyVersion: "relay-placement/v1"}}, - {name: "invalid allocation", placement: PublicPlacement{Region: "eu-north", RelayProviderID: "bindery-native", RelayAllocationID: "not-a-uuid", RelayEndpoint: "127.0.0.1:40000", PolicyVersion: "relay-placement/v1"}}, - {name: "invalid endpoint", placement: PublicPlacement{Region: "eu-north", RelayProviderID: "bindery-native", RelayAllocationID: erm204AllocationID, RelayEndpoint: "not-an-endpoint", PolicyVersion: "relay-placement/v1"}}, + {name: "missing provider", placement: PublicPlacement{Region: "eu-north", RelayAllocationID: erm204AllocationID, RelayEndpoint: "127.0.0.1:40000", PolicyVersion: "relay-placement/v1", Allocator: fixtureAllocatorIdentity()}}, + {name: "invalid allocation", placement: PublicPlacement{Region: "eu-north", RelayProviderID: "bindery-native", RelayAllocationID: "not-a-uuid", RelayEndpoint: "127.0.0.1:40000", PolicyVersion: "relay-placement/v1", Allocator: fixtureAllocatorIdentity()}}, + {name: "invalid endpoint", placement: PublicPlacement{Region: "eu-north", RelayProviderID: "bindery-native", RelayAllocationID: erm204AllocationID, RelayEndpoint: "not-an-endpoint", PolicyVersion: "relay-placement/v1", Allocator: fixtureAllocatorIdentity()}}, } for index, test := range cases { t.Run(test.name, func(t *testing.T) { diff --git a/internal/externalruntime/fixtures.go b/internal/externalruntime/fixtures.go index be1ebb1..d65d5b6 100644 --- a/internal/externalruntime/fixtures.go +++ b/internal/externalruntime/fixtures.go @@ -36,12 +36,27 @@ func fixtureIdentityRecord() identityRecord { } func fixtureRelayPlacement() *PublicPlacement { + now := time.Date(2026, 8, 23, 12, 0, 0, 0, time.UTC) return &PublicPlacement{ + SchemaVersion: SchemaVersion, + PlacementID: "0198c2c3-4d5e-7f60-8123-456789abcdea", + SessionID: "0198c2c3-4d5e-7f60-8123-456789abcdeb", Region: "eu-north", RelayProviderID: "bindery-native", RelayAllocationID: "0198c2c3-4d5e-7f60-8123-456789abcdef", RelayEndpoint: "127.0.0.1:40000", PolicyVersion: "relay-placement/v1", + Allocator: fixtureAllocatorIdentity(), + CreatedAt: now, + } +} + +func fixtureAllocatorIdentity() ImplementationIdentity { + return ImplementationIdentity{ + Implementation: "bindery-native", + Repository: "https://github.com/bayleafwalker/bindery-core", + Revision: "738e9f752ad1d892bdad8852cd4bd4e29182c16a", + ConfigDigest: "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", } } @@ -81,6 +96,8 @@ func fixtureSessionWithEnrollments() PublicSession { return PublicSession{ SchemaVersion: SchemaVersion, SessionID: "session-fixture", + ExecutionID: "execution-fixture", + PlacementID: "0198c2c3-4d5e-7f60-8123-456789abcdea", CreatedByAccountID: "acct-player-a", CreatedAt: now, Phase: SessionReady, @@ -103,6 +120,7 @@ func fixtureExpiredSession() PublicSession { return PublicSession{ SchemaVersion: SchemaVersion, SessionID: "session-expired-fixture", + ExecutionID: "execution-expired-fixture", CreatedByAccountID: "acct-player-a", CreatedAt: now, Phase: SessionExpired, diff --git a/internal/externalruntime/http.go b/internal/externalruntime/http.go index 9f95988..1c658a6 100644 --- a/internal/externalruntime/http.go +++ b/internal/externalruntime/http.go @@ -32,7 +32,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.fail(w, requestID, http.StatusNotFound, "NOT_FOUND", "resource was not found") return } - h.service.Converge(h.service.now()) + if err := h.service.Converge(h.service.now()); err != nil { + h.fail(w, requestID, http.StatusServiceUnavailable, "STATE_PERSISTENCE_FAILED", "durable control state is unavailable") + return + } path := strings.TrimPrefix(r.URL.Path, "/v1/") parts := strings.Split(strings.Trim(path, "/"), "/") switch { @@ -49,6 +52,14 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.getSession(w, requestID, parts[1]) case len(parts) == 2 && parts[0] == "enrollments" && r.Method == http.MethodGet: h.getEnrollment(w, requestID, parts[1]) + case len(parts) == 2 && parts[0] == "placements" && r.Method == http.MethodGet: + h.getPlacement(w, requestID, parts[1]) + case len(parts) == 2 && parts[0] == "executions" && r.Method == http.MethodGet: + h.getExecution(w, requestID, parts[1]) + case len(parts) == 3 && parts[0] == "executions" && parts[2] == "evidence-sets" && r.Method == http.MethodPost: + h.createEvidenceSet(w, r, requestID, parts[1]) + case len(parts) == 2 && parts[0] == "evidence-sets" && r.Method == http.MethodGet: + h.getEvidenceSet(w, requestID, parts[1]) case len(parts) == 3 && parts[0] == "sessions" && parts[2] == "enrollments" && r.Method == http.MethodPost: h.enroll(w, r, requestID, parts[1]) case len(parts) == 3 && parts[0] == "enrollments" && parts[2] == "reports" && r.Method == http.MethodPost: @@ -113,6 +124,46 @@ func (h *Handler) getEnrollment(w http.ResponseWriter, requestID, clientID strin writeJSON(w, http.StatusOK, enrollment) } +func (h *Handler) getPlacement(w http.ResponseWriter, requestID, placementID string) { + placement, err := h.service.GetPlacement(placementID) + if err != nil { + h.writeDomainError(w, requestID, err) + return + } + writeJSON(w, http.StatusOK, placement) +} + +func (h *Handler) getExecution(w http.ResponseWriter, requestID, executionID string) { + execution, err := h.service.GetExecution(executionID) + if err != nil { + h.writeDomainError(w, requestID, err) + return + } + writeJSON(w, http.StatusOK, execution) +} + +func (h *Handler) createEvidenceSet(w http.ResponseWriter, r *http.Request, requestID, executionID string) { + var req ReconcileEvidenceRequest + if !decodeJSON(w, r, &req, requestID) { + return + } + set, err := h.service.CreateEvidenceSet(bearer(r), executionID, r.Header.Get("Idempotency-Key"), req) + if err != nil { + h.writeDomainError(w, requestID, err) + return + } + writeJSON(w, http.StatusCreated, set) +} + +func (h *Handler) getEvidenceSet(w http.ResponseWriter, requestID, evidenceSetID string) { + set, err := h.service.GetEvidenceSet(evidenceSetID) + if err != nil { + h.writeDomainError(w, requestID, err) + return + } + writeJSON(w, http.StatusOK, set) +} + func (h *Handler) enroll(w http.ResponseWriter, r *http.Request, requestID, sessionID string) { var req EnrollmentRequest if !decodeJSON(w, r, &req, requestID) { @@ -178,12 +229,14 @@ func (h *Handler) writeDomainError(w http.ResponseWriter, requestID string, err switch domain.Code { case "TOKEN_INVALID", "JOIN_CREDENTIAL_INVALID", "IDENTITY_SUSPENDED": status = http.StatusUnauthorized - case "IDENTITY_NOT_FOUND", "SESSION_NOT_FOUND", "ENROLLMENT_NOT_FOUND": + case "IDENTITY_NOT_FOUND", "SESSION_NOT_FOUND", "ENROLLMENT_NOT_FOUND", "PLACEMENT_NOT_FOUND", "EXECUTION_NOT_FOUND", "EVIDENCE_SET_NOT_FOUND": status = http.StatusNotFound case "HANDLE_TAKEN", "IDEMPOTENCY_CONFLICT": status = http.StatusConflict case "SESSION_NOT_ADMITTING", "LEASE_EXPIRED": status = http.StatusGone + case "STATE_PERSISTENCE_FAILED": + status = http.StatusServiceUnavailable } h.fail(w, requestID, status, domain.Code, domain.Message) return diff --git a/internal/externalruntime/http_test.go b/internal/externalruntime/http_test.go index c01e4fd..75d012e 100644 --- a/internal/externalruntime/http_test.go +++ b/internal/externalruntime/http_test.go @@ -122,3 +122,43 @@ func TestHTTPKnownEnrollmentReadIsPublicAndNotDiscoverable(t *testing.T) { t.Fatalf("enrollment discovery status = %d, want 404", discoveryResponse.Code) } } + +func TestHTTPReconcilesAndPublishesExecutionEvidence(t *testing.T) { + service := NewServiceWithPlacementAllocator(testPersistentAllocator) + handler := NewHandler(service) + owner := mustIdentity(t, service, "evidence-owner") + peer := mustIdentity(t, service, "evidence-peer") + created, err := service.CreateSession(owner.AccountToken, "evidence-session", testSessionRequest()) + if err != nil { + t.Fatal(err) + } + a := mustEnroll(t, service, owner.AccountToken, created.SessionJoinCredential, created.PublicSession.SessionID, "evidence-a", ClientPlayer) + b := mustEnroll(t, service, peer.AccountToken, created.SessionJoinCredential, created.PublicSession.SessionID, "evidence-b", ClientPlayer) + + body := `{"method":"exact-count","observations":[` + + `{"observer_id":"` + a.id + `","execution_id":"` + created.PublicSession.ExecutionID + `","stream_id":"telemetry-a","event_count":6651},` + + `{"observer_id":"` + b.id + `","execution_id":"` + created.PublicSession.ExecutionID + `","stream_id":"telemetry-b","event_count":6651}]}` + request := httptest.NewRequest(http.MethodPost, "/v1/executions/"+created.PublicSession.ExecutionID+"/evidence-sets", strings.NewReader(body)) + request.Header.Set("Authorization", "Bearer "+owner.AccountToken) + request.Header.Set("Idempotency-Key", "evidence-reconcile") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusCreated { + t.Fatalf("evidence status = %d, body = %s", response.Code, response.Body.String()) + } + var set struct { + EvidenceSetID string `json:"evidence_set_id"` + } + if err := json.NewDecoder(response.Body).Decode(&set); err != nil { + t.Fatal(err) + } + publicRequest := httptest.NewRequest(http.MethodGet, "/v1/evidence-sets/"+set.EvidenceSetID, nil) + publicResponse := httptest.NewRecorder() + handler.ServeHTTP(publicResponse, publicRequest) + if publicResponse.Code != http.StatusOK || !strings.Contains(publicResponse.Body.String(), `"outcome":"consistent"`) { + t.Fatalf("public evidence status = %d, body = %s", publicResponse.Code, publicResponse.Body.String()) + } + if strings.Contains(publicResponse.Body.String(), owner.AccountToken) { + t.Fatal("public evidence response leaked account token") + } +} diff --git a/internal/externalruntime/placement.go b/internal/externalruntime/placement.go index 42008c6..3f4585b 100644 --- a/internal/externalruntime/placement.go +++ b/internal/externalruntime/placement.go @@ -4,6 +4,7 @@ import ( "net" "strconv" "strings" + "time" "unicode" "github.com/bayleafwalker/bindery-core/pkg/relayv1" @@ -12,7 +13,7 @@ import ( // resolvePlacement is the mechanical implementation beneath the // coordinator-frozen allocator seam. The request carries intent only; the // allocator owns the public allocation identity and endpoint. -func resolvePlacement(allocator PlacementAllocator, intent PlacementIntent) (*PublicPlacement, error) { +func resolvePlacement(allocator PlacementAllocator, intent PlacementIntent, sessionID string, now time.Time) (*PublicPlacement, error) { if allocator == nil { return nil, nil } @@ -20,6 +21,14 @@ func resolvePlacement(allocator PlacementAllocator, intent PlacementIntent) (*Pu if err != nil { return nil, err } + placementID, err := newUUIDv7(now) + if err != nil { + return nil, err + } + placement.SchemaVersion = SchemaVersion + placement.PlacementID = placementID + placement.SessionID = sessionID + placement.CreatedAt = now if err := validatePublicPlacement(placement); err != nil { return nil, err } @@ -27,12 +36,26 @@ func resolvePlacement(allocator PlacementAllocator, intent PlacementIntent) (*Pu } func validatePublicPlacement(placement PublicPlacement) error { - if blankOrControl(placement.Region) || len(placement.Region) > 64 || + if placement.SchemaVersion != SchemaVersion || + blankOrControl(placement.PlacementID) || blankOrControl(placement.SessionID) || placement.CreatedAt.IsZero() || + blankOrControl(placement.Region) || len(placement.Region) > 64 || blankOrControl(placement.RelayProviderID) || len(placement.RelayProviderID) > 128 || blankOrControl(placement.PolicyVersion) || len(placement.PolicyVersion) > 64 || len(placement.DecisionSummary) > 2048 || hasControl(placement.DecisionSummary) { return domainError("PLACEMENT_INVALID", "placement metadata is invalid") } + if blankOrControl(placement.Allocator.Implementation) || len(placement.Allocator.Implementation) > 128 || + blankOrControl(placement.Allocator.Repository) || len(placement.Allocator.Repository) > 512 || + blankOrControl(placement.Allocator.Revision) || len(placement.Allocator.Revision) > 128 || + !hashPattern.MatchString(placement.Allocator.ConfigDigest) { + return domainError("PLACEMENT_INVALID", "allocator implementation identity is invalid") + } + if _, err := relayv1.PeekMustUUID(placement.PlacementID); err != nil { + return domainError("PLACEMENT_INVALID", "placement id must be a canonical UUID") + } + if _, err := relayv1.PeekMustUUID(placement.SessionID); err != nil { + return domainError("PLACEMENT_INVALID", "placement session id must be a canonical UUID") + } if _, err := relayv1.PeekMustUUID(placement.RelayAllocationID); err != nil { return domainError("PLACEMENT_INVALID", "relay allocation id must be a canonical UUID") } diff --git a/internal/externalruntime/redaction_test.go b/internal/externalruntime/redaction_test.go index 9642826..8ddc6cb 100644 --- a/internal/externalruntime/redaction_test.go +++ b/internal/externalruntime/redaction_test.go @@ -27,11 +27,15 @@ func TestRedactionOracleRejectsSecretFieldsAndFixtures(t *testing.T) { func TestRedactionOracleAllowsPublishedRelayPlacementEndpoint(t *testing.T) { public, err := json.Marshal(PublicSession{Placement: &PublicPlacement{ + SchemaVersion: SchemaVersion, + PlacementID: "0198c2c3-4d5e-7f60-8123-456789abcdea", + SessionID: "0198c2c3-4d5e-7f60-8123-456789abcdeb", Region: "eu-north", RelayProviderID: "bindery-native", RelayAllocationID: "0198c2c3-4d5e-7f60-8123-456789abcdef", RelayEndpoint: "127.0.0.1:40000", PolicyVersion: "relay-placement/v1", + Allocator: fixtureAllocatorIdentity(), }}) if err != nil { t.Fatal(err) diff --git a/internal/externalruntime/service.go b/internal/externalruntime/service.go index 145c370..94df2e4 100644 --- a/internal/externalruntime/service.go +++ b/internal/externalruntime/service.go @@ -9,6 +9,8 @@ import ( "strings" "sync" "time" + + "github.com/bayleafwalker/bindery-core/pkg/evidencev1" ) var handlePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{2,31}$`) @@ -40,20 +42,30 @@ type enrollmentCreateReplay struct { ExpiresAt time.Time } +type evidenceCreateReplay struct { + RequestHash string + Public evidencev1.EvidenceSet +} + type Service struct { mu sync.RWMutex clock func() time.Time placementAllocator PlacementAllocator + stateStore StateStore identities map[string]*identityRecord handles map[string]string sessions map[string]*sessionRecord enrollments map[string]*enrollmentRecord + placements map[string]PublicPlacement + executions map[string]PublicExecution + evidenceSets map[string]evidencev1.EvidenceSet identityIdempotency map[string]identityCreateReplay sessionIdempotency map[string]sessionCreateReplay enrollmentIdempotency map[string]enrollmentCreateReplay + evidenceIdempotency map[string]evidenceCreateReplay } func NewService() *Service { @@ -71,9 +83,13 @@ func NewServiceWithPlacementAllocator(allocator PlacementAllocator) *Service { handles: make(map[string]string), sessions: make(map[string]*sessionRecord), enrollments: make(map[string]*enrollmentRecord), + placements: make(map[string]PublicPlacement), + executions: make(map[string]PublicExecution), + evidenceSets: make(map[string]evidencev1.EvidenceSet), identityIdempotency: make(map[string]identityCreateReplay), sessionIdempotency: make(map[string]sessionCreateReplay), enrollmentIdempotency: make(map[string]enrollmentCreateReplay), + evidenceIdempotency: make(map[string]evidenceCreateReplay), } } @@ -108,6 +124,7 @@ func (s *Service) CreateIdentity(req CreateIdentityRequest, idempotencyKey strin if _, exists := s.handles[req.Handle]; exists { return CreateIdentityResponse{}, domainError("HANDLE_TAKEN", "handle is already claimed") } + before := s.snapshotLocked() now := s.now() accountID, err := newUUIDv7(now) if err != nil { @@ -127,6 +144,9 @@ func (s *Service) CreateIdentity(req CreateIdentityRequest, idempotencyKey strin s.handles[req.Handle] = accountID response := CreateIdentityResponse{PublicIdentity: public, AccountToken: token, Recovery: "none"} s.identityIdempotency[idempotencyKey] = identityCreateReplay{RequestHash: hash, Public: public} + if err := s.commitLocked(before); err != nil { + return CreateIdentityResponse{}, err + } return response, nil } @@ -162,21 +182,27 @@ func (s *Service) CreateSession(accountToken, idempotencyKey string, req CreateS } return CreateSessionResponse{PublicSession: clonePublicSession(replay.Public), ExpiresAt: replay.ExpiresAt}, nil } - placement, err := resolvePlacement(s.placementAllocator, req.Placement) - if err != nil { - return CreateSessionResponse{}, err - } + before := s.snapshotLocked() now := s.now() sessionID, err := newUUIDv7(now) if err != nil { return CreateSessionResponse{}, fmt.Errorf("create session id: %w", err) } + executionID, err := newUUIDv7(now) + if err != nil { + return CreateSessionResponse{}, fmt.Errorf("create execution id: %w", err) + } + placement, err := resolvePlacement(s.placementAllocator, req.Placement, sessionID, now) + if err != nil { + return CreateSessionResponse{}, err + } join, joinVerifier, err := newCredential() if err != nil { return CreateSessionResponse{}, fmt.Errorf("create join credential: %w", err) } public := PublicSession{ SchemaVersion: SchemaVersion, SessionID: sessionID, + ExecutionID: executionID, CreatedByAccountID: accountID, CreatedAt: now, UpdatedAt: now, Phase: SessionCreated, Compatibility: req.Compatibility, ParticipantPolicy: req.ParticipantPolicy, CapturePolicy: req.Capture, @@ -184,23 +210,43 @@ func (s *Service) CreateSession(accountToken, idempotencyKey string, req CreateS Enrollments: []PublicEnrollment{}, Transitions: []PublicTransition{}, PublicDataNoticeVersion: "1.0", } + if placement != nil { + public.PlacementID = placement.PlacementID + } appendTransition(&public, nil, string(SessionCreated), "match-broker", "session-created", now) record := &sessionRecord{ PublicSession: public, joinVerifier: joinVerifier, expiresAt: now.Add(15 * time.Minute), creatorID: accountID, + executionID: executionID, placementID: public.PlacementID, placementIntent: req.Placement, enrollments: make(map[string]*enrollmentRecord), createRequestHash: hash, } s.sessions[sessionID] = record + if placement != nil { + s.placements[placement.PlacementID] = *placement + } + s.executions[executionID] = PublicExecution{ + SchemaVersion: SchemaVersion, + ExecutionID: executionID, + SessionID: sessionID, + PlacementID: public.PlacementID, + Phase: ExecutionPrepared, + CreatedAt: now, + } response := CreateSessionResponse{PublicSession: clonePublicSession(public), SessionJoinCredential: join, ExpiresAt: record.expiresAt} s.sessionIdempotency[replayKey] = sessionCreateReplay{RequestHash: hash, Public: public, ExpiresAt: record.expiresAt} + if err := s.commitLocked(before); err != nil { + return CreateSessionResponse{}, err + } return response, nil } func (s *Service) GetSession(sessionID string) (PublicSession, error) { s.mu.Lock() defer s.mu.Unlock() - s.convergeLocked(s.now()) + if err := s.convergeAndPersistLocked(s.now()); err != nil { + return PublicSession{}, err + } session, ok := s.sessions[sessionID] if !ok { return PublicSession{}, domainError("SESSION_NOT_FOUND", "session was not found") @@ -212,7 +258,9 @@ func (s *Service) GetSession(sessionID string) (PublicSession, error) { func (s *Service) GetEnrollment(clientID string) (PublicEnrollment, error) { s.mu.Lock() defer s.mu.Unlock() - s.convergeLocked(s.now()) + if err := s.convergeAndPersistLocked(s.now()); err != nil { + return PublicEnrollment{}, err + } enrollment, ok := s.enrollments[clientID] if !ok { return PublicEnrollment{}, domainError("ENROLLMENT_NOT_FOUND", "enrollment was not found") @@ -220,6 +268,94 @@ func (s *Service) GetEnrollment(clientID string) (PublicEnrollment, error) { return enrollment.PublicEnrollment, nil } +func (s *Service) GetPlacement(placementID string) (PublicPlacement, error) { + s.mu.RLock() + defer s.mu.RUnlock() + placement, ok := s.placements[placementID] + if !ok { + return PublicPlacement{}, domainError("PLACEMENT_NOT_FOUND", "placement was not found") + } + return placement, nil +} + +func (s *Service) GetExecution(executionID string) (PublicExecution, error) { + s.mu.RLock() + defer s.mu.RUnlock() + execution, ok := s.executions[executionID] + if !ok { + return PublicExecution{}, domainError("EXECUTION_NOT_FOUND", "execution was not found") + } + return clonePublicExecution(execution), nil +} + +func (s *Service) GetEvidenceSet(evidenceSetID string) (evidencev1.EvidenceSet, error) { + s.mu.RLock() + defer s.mu.RUnlock() + set, ok := s.evidenceSets[evidenceSetID] + if !ok { + return evidencev1.EvidenceSet{}, domainError("EVIDENCE_SET_NOT_FOUND", "evidence set was not found") + } + return cloneEvidenceSet(set), nil +} + +func (s *Service) CreateEvidenceSet(accountToken, executionID, idempotencyKey string, req ReconcileEvidenceRequest) (evidencev1.EvidenceSet, error) { + if idempotencyKey == "" { + return evidencev1.EvidenceSet{}, domainError("IDEMPOTENCY_KEY_REQUIRED", "an idempotency key is required") + } + s.mu.Lock() + defer s.mu.Unlock() + if err := s.convergeAndPersistLocked(s.now()); err != nil { + return evidencev1.EvidenceSet{}, err + } + accountID, err := s.authenticateAccountLocked(accountToken) + if err != nil { + return evidencev1.EvidenceSet{}, err + } + execution, ok := s.executions[executionID] + if !ok { + return evidencev1.EvidenceSet{}, domainError("EXECUTION_NOT_FOUND", "execution was not found") + } + session, ok := s.sessions[execution.SessionID] + if !ok { + return evidencev1.EvidenceSet{}, domainError("STATE_INTEGRITY_ERROR", "execution does not resolve to a session") + } + if session.creatorID != accountID { + return evidencev1.EvidenceSet{}, domainError("TOKEN_INVALID", "only the session creator may reconcile execution evidence") + } + hash := requestHash(req) + replayKey := accountID + ":" + executionID + ":" + idempotencyKey + if replay, exists := s.evidenceIdempotency[replayKey]; exists { + if replay.RequestHash != hash { + return evidencev1.EvidenceSet{}, domainError("IDEMPOTENCY_CONFLICT", "idempotency key was reused with a different request") + } + return cloneEvidenceSet(replay.Public), nil + } + for _, observation := range req.Observations { + enrollment, exists := session.enrollments[observation.ObserverID] + if !exists || enrollment.sessionID != session.SessionID { + return evidencev1.EvidenceSet{}, domainError("OBSERVER_NOT_ENROLLED", "every observer must be enrolled in the execution session") + } + } + set, err := evidencev1.Reconcile(evidencev1.ReconcileRequest{ + ExecutionID: executionID, + Method: req.Method, + Observations: req.Observations, + CreatedAt: s.now(), + }) + if err != nil { + return evidencev1.EvidenceSet{}, domainError("RECONCILIATION_INVALID", err.Error()) + } + before := s.snapshotLocked() + s.evidenceSets[set.EvidenceSetID] = cloneEvidenceSet(set) + execution.EvidenceSetIDs = appendUnique(execution.EvidenceSetIDs, set.EvidenceSetID) + s.executions[executionID] = execution + s.evidenceIdempotency[replayKey] = evidenceCreateReplay{RequestHash: hash, Public: cloneEvidenceSet(set)} + if err := s.commitLocked(before); err != nil { + return evidencev1.EvidenceSet{}, err + } + return set, nil +} + func (s *Service) Enroll(accountToken, sessionJoinCredential, sessionID, idempotencyKey string, req EnrollmentRequest) (EnrollmentCreateResponse, error) { if idempotencyKey == "" { return EnrollmentCreateResponse{}, domainError("IDEMPOTENCY_KEY_REQUIRED", "an idempotency key is required") @@ -239,6 +375,9 @@ func (s *Service) Enroll(accountToken, sessionJoinCredential, sessionID, idempot s.mu.Lock() defer s.mu.Unlock() + if err := s.convergeAndPersistLocked(s.now()); err != nil { + return EnrollmentCreateResponse{}, err + } accountID, err := s.authenticateAccountLocked(accountToken) if err != nil { return EnrollmentCreateResponse{}, err @@ -247,7 +386,6 @@ func (s *Service) Enroll(accountToken, sessionJoinCredential, sessionID, idempot if !ok { return EnrollmentCreateResponse{}, domainError("SESSION_NOT_FOUND", "session was not found") } - s.convergeLocked(s.now()) if session.Phase != SessionCreated && session.Phase != SessionAdmitting && session.Phase != SessionReady { return EnrollmentCreateResponse{}, domainError("SESSION_NOT_ADMITTING", "session phase does not accept enrollments") } @@ -282,6 +420,7 @@ func (s *Service) Enroll(accountToken, sessionJoinCredential, sessionID, idempot } return EnrollmentCreateResponse{PublicEnrollment: replay.Public, ExpiresAt: replay.ExpiresAt}, nil } + before := s.snapshotLocked() now := s.now() clientID, err := newUUIDv7(now) if err != nil { @@ -305,6 +444,9 @@ func (s *Service) Enroll(accountToken, sessionJoinCredential, sessionID, idempot s.refreshPublicEnrollmentsLocked(session) response := EnrollmentCreateResponse{PublicEnrollment: public, ClientLeaseToken: lease, TransportCredential: transport, ExpiresAt: enrollment.expiresAt} s.enrollmentIdempotency[replayKey] = enrollmentCreateReplay{RequestHash: hash, Public: public, ExpiresAt: enrollment.expiresAt} + if err := s.commitLocked(before); err != nil { + return EnrollmentCreateResponse{}, err + } return response, nil } @@ -314,6 +456,9 @@ func (s *Service) Report(clientLease, clientID, idempotencyKey string, req Lifec } s.mu.Lock() defer s.mu.Unlock() + if err := s.convergeAndPersistLocked(s.now()); err != nil { + return LifecycleReportResponse{}, err + } enrollment, ok := s.enrollments[clientID] if !ok { return LifecycleReportResponse{}, domainError("ENROLLMENT_NOT_FOUND", "enrollment was not found") @@ -331,21 +476,29 @@ func (s *Service) Report(clientLease, clientID, idempotencyKey string, req Lifec return LifecycleReportResponse{PublicSession: clonePublicSession(session.PublicSession), PublicEnrollment: enrollment.PublicEnrollment}, nil } if enrollment.expiresAt.Before(s.now()) { - s.convergeLocked(s.now()) return LifecycleReportResponse{}, domainError("LEASE_EXPIRED", "client lease has expired") } session := s.sessions[enrollment.sessionID] - if err := applyReport(session, enrollment, req, s.now()); err != nil { + before := s.snapshotLocked() + now := s.now() + if err := applyReport(session, enrollment, req, now); err != nil { return LifecycleReportResponse{}, err } + s.syncExecutionLocked(session, now) enrollment.reportIDs[idempotencyKey] = hash s.refreshPublicEnrollmentsLocked(session) + if err := s.commitLocked(before); err != nil { + return LifecycleReportResponse{}, err + } return LifecycleReportResponse{PublicSession: clonePublicSession(session.PublicSession), PublicEnrollment: enrollment.PublicEnrollment}, nil } func (s *Service) Heartbeat(clientLease, clientID string) (HeartbeatResponse, error) { s.mu.Lock() defer s.mu.Unlock() + if err := s.convergeAndPersistLocked(s.now()); err != nil { + return HeartbeatResponse{}, err + } enrollment, ok := s.enrollments[clientID] if !ok { return HeartbeatResponse{}, domainError("ENROLLMENT_NOT_FOUND", "enrollment was not found") @@ -357,20 +510,34 @@ func (s *Service) Heartbeat(clientLease, clientID string) (HeartbeatResponse, er if enrollment.expiresAt.Before(now) || enrollment.Phase == EnrollmentDeparted || enrollment.Phase == EnrollmentLost || enrollment.Phase == EnrollmentExpired { return HeartbeatResponse{}, domainError("LEASE_EXPIRED", "client lease has expired") } + before := s.snapshotLocked() enrollment.expiresAt = now.Add(2 * time.Minute) + if err := s.commitLocked(before); err != nil { + return HeartbeatResponse{}, err + } return HeartbeatResponse{ClientID: clientID, ExpiresAt: enrollment.expiresAt}, nil } -func (s *Service) Converge(now time.Time) { +func (s *Service) Converge(now time.Time) error { s.mu.Lock() defer s.mu.Unlock() - s.convergeLocked(now.UTC()) + return s.convergeAndPersistLocked(now.UTC()) +} + +func (s *Service) convergeAndPersistLocked(now time.Time) error { + before := s.snapshotLocked() + if !s.convergeLocked(now) { + return nil + } + return s.commitLocked(before) } -func (s *Service) convergeLocked(now time.Time) { +func (s *Service) convergeLocked(now time.Time) bool { + changed := false for _, session := range s.sessions { if (session.Phase == SessionCreated || session.Phase == SessionAdmitting || session.Phase == SessionReady) && session.expiresAt.Before(now) { transitionSession(session, SessionExpired, "lease-converger", "session-admission-expired", now) + changed = true } for _, enrollment := range session.enrollments { if enrollment.expiresAt.After(now) || enrollment.Phase == EnrollmentDeparted || enrollment.Phase == EnrollmentLost || enrollment.Phase == EnrollmentExpired { @@ -384,13 +551,47 @@ func (s *Service) convergeLocked(now time.Time) { } if from != enrollment.Phase { transitionEnrollment(enrollment, from, enrollment.Phase, "lease-converger", "client-lease-expired", now) + changed = true } if enrollment.ClientClass == ClientPlayer && session.Phase == SessionRunning { transitionSession(session, SessionFailed, "lease-converger", "required-player-lease-expired", now) + changed = true } } s.refreshPublicEnrollmentsLocked(session) + s.syncExecutionLocked(session, now) + } + return changed +} + +func (s *Service) syncExecutionLocked(session *sessionRecord, now time.Time) { + execution, ok := s.executions[session.executionID] + if !ok { + return + } + switch session.Phase { + case SessionRunning: + execution.Phase = ExecutionRunning + if execution.StartedAt == nil { + execution.StartedAt = timePtr(now) + } + case SessionEnded, SessionPublished: + execution.Phase = ExecutionEnded + if execution.EndedAt == nil { + execution.EndedAt = timePtr(now) + } + case SessionFailed: + execution.Phase = ExecutionFailed + if execution.EndedAt == nil { + execution.EndedAt = timePtr(now) + } + case SessionExpired: + execution.Phase = ExecutionExpired + if execution.EndedAt == nil { + execution.EndedAt = timePtr(now) + } } + s.executions[session.executionID] = execution } func (s *Service) authenticateAccountLocked(token string) (string, error) { @@ -487,6 +688,35 @@ func clonePublicSession(value PublicSession) PublicSession { return value } +func clonePublicExecution(value PublicExecution) PublicExecution { + value.EvidenceSetIDs = append([]string(nil), value.EvidenceSetIDs...) + if value.StartedAt != nil { + started := *value.StartedAt + value.StartedAt = &started + } + if value.EndedAt != nil { + ended := *value.EndedAt + value.EndedAt = &ended + } + return value +} + +func cloneEvidenceSet(value evidencev1.EvidenceSet) evidencev1.EvidenceSet { + value.Observations = append([]evidencev1.ObservationSummary(nil), value.Observations...) + value.Reconciliation.DistinctCounts = append([]uint64(nil), value.Reconciliation.DistinctCounts...) + value.Reconciliation.DistinctHashes = append([]string(nil), value.Reconciliation.DistinctHashes...) + return value +} + +func appendUnique(values []string, value string) []string { + for _, existing := range values { + if existing == value { + return values + } + } + return append(values, value) +} + func appendTransition(session *PublicSession, from *SessionPhase, to, source, reason string, now time.Time) { transitionID, _ := newUUIDv7(now) var fromValue *string diff --git a/internal/externalruntime/service_test.go b/internal/externalruntime/service_test.go index a470784..01b8779 100644 --- a/internal/externalruntime/service_test.go +++ b/internal/externalruntime/service_test.go @@ -96,6 +96,10 @@ func TestSessionEnrollmentLifecycleAndObserverDegradation(t *testing.T) { if ended.PublicSession.Phase != SessionEnded { t.Fatalf("phase after players exit = %s", ended.PublicSession.Phase) } + execution, err := service.GetExecution(created.PublicSession.ExecutionID) + if err != nil || execution.Phase != ExecutionEnded || execution.StartedAt == nil || execution.EndedAt == nil { + t.Fatalf("execution lifecycle = %+v, error = %v", execution, err) + } publicBytes, err := json.Marshal(ended.PublicSession) if err != nil { @@ -119,7 +123,9 @@ func TestLeaseConvergenceExpiresAdmission(t *testing.T) { t.Fatal(err) } now = created.ExpiresAt.Add(time.Second) - service.Converge(now) + if err := service.Converge(now); err != nil { + t.Fatal(err) + } public, err := service.GetSession(created.PublicSession.SessionID) if err != nil { t.Fatal(err) diff --git a/internal/externalruntime/state_store.go b/internal/externalruntime/state_store.go new file mode 100644 index 0000000..f98be52 --- /dev/null +++ b/internal/externalruntime/state_store.go @@ -0,0 +1,441 @@ +package externalruntime + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "time" + + "github.com/bayleafwalker/bindery-core/pkg/evidencev1" +) + +const stateSnapshotVersion = "bindery.externalruntime.state/v1" + +// StateStore is the single-writer durability boundary for the reference +// control plane. The file implementation is intentionally not a multi-replica +// coordination mechanism. +type StateStore interface { + Load() (serviceSnapshot, error) + Save(serviceSnapshot) error +} + +type storedIdentity struct { + Public PublicIdentity `json:"public"` + TokenVerifier []byte `json:"token_verifier"` +} + +type storedSession struct { + Public PublicSession `json:"public"` + JoinVerifier []byte `json:"join_verifier"` + ExpiresAt time.Time `json:"expires_at"` + CreatorID string `json:"creator_id"` + ExecutionID string `json:"execution_id"` + PlacementID string `json:"placement_id,omitempty"` + PlacementIntent PlacementIntent `json:"placement_intent"` + EnrollmentIDs []string `json:"enrollment_ids"` + CreateRequestHash string `json:"create_request_hash"` +} + +type storedEnrollment struct { + Public PublicEnrollment `json:"public"` + SessionID string `json:"session_id"` + ClientInstanceID string `json:"client_instance_id"` + LeaseVerifier []byte `json:"lease_verifier"` + TransportVerifier []byte `json:"transport_verifier"` + ExpiresAt time.Time `json:"expires_at"` + ReportIDs map[string]string `json:"report_ids"` + RequestHash string `json:"request_hash"` +} + +type storedIdentityReplay struct { + RequestHash string `json:"request_hash"` + Public PublicIdentity `json:"public"` +} + +type storedSessionReplay struct { + RequestHash string `json:"request_hash"` + Public PublicSession `json:"public"` + ExpiresAt time.Time `json:"expires_at"` +} + +type storedEnrollmentReplay struct { + RequestHash string `json:"request_hash"` + Public PublicEnrollment `json:"public"` + ExpiresAt time.Time `json:"expires_at"` +} + +type storedEvidenceReplay struct { + RequestHash string `json:"request_hash"` + Public evidencev1.EvidenceSet `json:"public"` +} + +type serviceSnapshot struct { + SchemaVersion string `json:"schema_version"` + Identities map[string]storedIdentity `json:"identities"` + Handles map[string]string `json:"handles"` + Sessions map[string]storedSession `json:"sessions"` + Enrollments map[string]storedEnrollment `json:"enrollments"` + Placements map[string]PublicPlacement `json:"placements"` + Executions map[string]PublicExecution `json:"executions"` + EvidenceSets map[string]evidencev1.EvidenceSet `json:"evidence_sets"` + IdentityIdempotency map[string]storedIdentityReplay `json:"identity_idempotency"` + SessionIdempotency map[string]storedSessionReplay `json:"session_idempotency"` + EnrollmentIdempotency map[string]storedEnrollmentReplay `json:"enrollment_idempotency"` + EvidenceIdempotency map[string]storedEvidenceReplay `json:"evidence_idempotency"` +} + +type FileStateStore struct { + path string +} + +func NewFileStateStore(path string) (*FileStateStore, error) { + if path == "" { + return nil, errors.New("state path is required") + } + absolute, err := filepath.Abs(path) + if err != nil { + return nil, fmt.Errorf("resolve state path: %w", err) + } + if err := os.MkdirAll(filepath.Dir(absolute), 0o700); err != nil { + return nil, fmt.Errorf("create state directory: %w", err) + } + return &FileStateStore{path: absolute}, nil +} + +func (s *FileStateStore) Load() (serviceSnapshot, error) { + info, err := os.Lstat(s.path) + if errors.Is(err, os.ErrNotExist) { + return emptyServiceSnapshot(), nil + } + if err != nil { + return serviceSnapshot{}, fmt.Errorf("inspect state file: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return serviceSnapshot{}, errors.New("state path must be a regular file, not a symlink") + } + if info.Mode().Perm()&0o077 != 0 { + return serviceSnapshot{}, fmt.Errorf("state file permissions %04o expose private verifier material", info.Mode().Perm()) + } + file, err := os.Open(s.path) + if err != nil { + return serviceSnapshot{}, fmt.Errorf("open state file: %w", err) + } + defer file.Close() + decoder := json.NewDecoder(io.LimitReader(file, 64<<20)) + decoder.DisallowUnknownFields() + var snapshot serviceSnapshot + if err := decoder.Decode(&snapshot); err != nil { + return serviceSnapshot{}, fmt.Errorf("decode state snapshot: %w", err) + } + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + return serviceSnapshot{}, errors.New("state file contains more than one JSON value") + } + return snapshot, nil +} + +func (s *FileStateStore) Save(snapshot serviceSnapshot) error { + if info, err := os.Lstat(s.path); err == nil { + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return errors.New("state path must be a regular file, not a symlink") + } + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("inspect state file: %w", err) + } + encoded, err := json.Marshal(snapshot) + if err != nil { + return fmt.Errorf("encode state snapshot: %w", err) + } + encoded = append(encoded, '\n') + directory := filepath.Dir(s.path) + temporary, err := os.CreateTemp(directory, ".bindery-state-*") + if err != nil { + return fmt.Errorf("create state transaction: %w", err) + } + temporaryPath := temporary.Name() + defer os.Remove(temporaryPath) + if err := temporary.Chmod(0o600); err != nil { + temporary.Close() + return fmt.Errorf("protect state transaction: %w", err) + } + if _, err := temporary.Write(encoded); err != nil { + temporary.Close() + return fmt.Errorf("write state transaction: %w", err) + } + if err := temporary.Sync(); err != nil { + temporary.Close() + return fmt.Errorf("sync state transaction: %w", err) + } + if err := temporary.Close(); err != nil { + return fmt.Errorf("close state transaction: %w", err) + } + if err := os.Rename(temporaryPath, s.path); err != nil { + return fmt.Errorf("commit state transaction: %w", err) + } + directoryHandle, err := os.Open(directory) + if err != nil { + return fmt.Errorf("open state directory: %w", err) + } + defer directoryHandle.Close() + if err := directoryHandle.Sync(); err != nil { + return fmt.Errorf("sync state directory: %w", err) + } + return nil +} + +func OpenPersistentService(allocator PlacementAllocator, store StateStore) (*Service, error) { + if store == nil { + return nil, errors.New("persistent service requires a state store") + } + service := NewServiceWithPlacementAllocator(allocator) + service.stateStore = store + snapshot, err := store.Load() + if err != nil { + return nil, err + } + service.mu.Lock() + defer service.mu.Unlock() + if err := service.restoreSnapshotLocked(snapshot); err != nil { + return nil, fmt.Errorf("restore control state: %w", err) + } + return service, nil +} + +func emptyServiceSnapshot() serviceSnapshot { + return serviceSnapshot{ + SchemaVersion: stateSnapshotVersion, + Identities: make(map[string]storedIdentity), + Handles: make(map[string]string), + Sessions: make(map[string]storedSession), + Enrollments: make(map[string]storedEnrollment), + Placements: make(map[string]PublicPlacement), + Executions: make(map[string]PublicExecution), + EvidenceSets: make(map[string]evidencev1.EvidenceSet), + IdentityIdempotency: make(map[string]storedIdentityReplay), + SessionIdempotency: make(map[string]storedSessionReplay), + EnrollmentIdempotency: make(map[string]storedEnrollmentReplay), + EvidenceIdempotency: make(map[string]storedEvidenceReplay), + } +} + +func (s *Service) snapshotLocked() serviceSnapshot { + snapshot := emptyServiceSnapshot() + for id, identity := range s.identities { + snapshot.Identities[id] = storedIdentity{Public: clonePublicIdentity(identity.PublicIdentity), TokenVerifier: append([]byte(nil), identity.tokenVerifier...)} + } + for handle, id := range s.handles { + snapshot.Handles[handle] = id + } + for id, session := range s.sessions { + enrollmentIDs := make([]string, 0, len(session.enrollments)) + for enrollmentID := range session.enrollments { + enrollmentIDs = append(enrollmentIDs, enrollmentID) + } + sort.Strings(enrollmentIDs) + snapshot.Sessions[id] = storedSession{ + Public: clonePublicSession(session.PublicSession), + JoinVerifier: append([]byte(nil), session.joinVerifier...), + ExpiresAt: session.expiresAt, + CreatorID: session.creatorID, + ExecutionID: session.executionID, + PlacementID: session.placementID, + PlacementIntent: PlacementIntent{AllowedRegions: append([]string(nil), session.placementIntent.AllowedRegions...), LatencyP95MS: session.placementIntent.LatencyP95MS}, + EnrollmentIDs: enrollmentIDs, + CreateRequestHash: session.createRequestHash, + } + } + for id, enrollment := range s.enrollments { + reportIDs := make(map[string]string, len(enrollment.reportIDs)) + for key, value := range enrollment.reportIDs { + reportIDs[key] = value + } + snapshot.Enrollments[id] = storedEnrollment{ + Public: enrollment.PublicEnrollment, + SessionID: enrollment.sessionID, + ClientInstanceID: enrollment.clientInstanceID, + LeaseVerifier: append([]byte(nil), enrollment.leaseVerifier...), + TransportVerifier: append([]byte(nil), enrollment.transportVerifier...), + ExpiresAt: enrollment.expiresAt, + ReportIDs: reportIDs, + RequestHash: enrollment.requestHash, + } + } + for id, placement := range s.placements { + snapshot.Placements[id] = placement + } + for id, execution := range s.executions { + snapshot.Executions[id] = clonePublicExecution(execution) + } + for id, set := range s.evidenceSets { + snapshot.EvidenceSets[id] = cloneEvidenceSet(set) + } + for key, replay := range s.identityIdempotency { + snapshot.IdentityIdempotency[key] = storedIdentityReplay{RequestHash: replay.RequestHash, Public: clonePublicIdentity(replay.Public)} + } + for key, replay := range s.sessionIdempotency { + snapshot.SessionIdempotency[key] = storedSessionReplay{RequestHash: replay.RequestHash, Public: clonePublicSession(replay.Public), ExpiresAt: replay.ExpiresAt} + } + for key, replay := range s.enrollmentIdempotency { + snapshot.EnrollmentIdempotency[key] = storedEnrollmentReplay{RequestHash: replay.RequestHash, Public: replay.Public, ExpiresAt: replay.ExpiresAt} + } + for key, replay := range s.evidenceIdempotency { + snapshot.EvidenceIdempotency[key] = storedEvidenceReplay{RequestHash: replay.RequestHash, Public: cloneEvidenceSet(replay.Public)} + } + return snapshot +} + +func (s *Service) restoreSnapshotLocked(snapshot serviceSnapshot) error { + if snapshot.SchemaVersion != stateSnapshotVersion { + return fmt.Errorf("unsupported state schema %q", snapshot.SchemaVersion) + } + identities := make(map[string]*identityRecord, len(snapshot.Identities)) + for id, stored := range snapshot.Identities { + if id == "" || stored.Public.AccountID != id || len(stored.TokenVerifier) != 32 { + return fmt.Errorf("identity %q is invalid", id) + } + identities[id] = &identityRecord{PublicIdentity: clonePublicIdentity(stored.Public), tokenVerifier: append([]byte(nil), stored.TokenVerifier...)} + } + handles := make(map[string]string, len(snapshot.Handles)) + for handle, id := range snapshot.Handles { + identity, ok := identities[id] + if !ok || identity.Handle != handle { + return fmt.Errorf("handle %q does not resolve to its identity", handle) + } + handles[handle] = id + } + placements := make(map[string]PublicPlacement, len(snapshot.Placements)) + for id, placement := range snapshot.Placements { + if placement.PlacementID != id { + return fmt.Errorf("placement %q has a different identity", id) + } + if err := validatePublicPlacement(placement); err != nil { + return fmt.Errorf("placement %q: %w", id, err) + } + placements[id] = placement + } + executions := make(map[string]PublicExecution, len(snapshot.Executions)) + for id, execution := range snapshot.Executions { + if execution.ExecutionID != id || execution.SessionID == "" { + return fmt.Errorf("execution %q is invalid", id) + } + if execution.PlacementID != "" { + if _, ok := placements[execution.PlacementID]; !ok { + return fmt.Errorf("execution %q has dangling placement %q", id, execution.PlacementID) + } + } + executions[id] = clonePublicExecution(execution) + } + enrollments := make(map[string]*enrollmentRecord, len(snapshot.Enrollments)) + for id, stored := range snapshot.Enrollments { + if stored.Public.ClientID != id || stored.SessionID == "" || len(stored.LeaseVerifier) != 32 || len(stored.TransportVerifier) != 32 { + return fmt.Errorf("enrollment %q is invalid", id) + } + reportIDs := make(map[string]string, len(stored.ReportIDs)) + for key, value := range stored.ReportIDs { + reportIDs[key] = value + } + enrollments[id] = &enrollmentRecord{ + PublicEnrollment: stored.Public, + sessionID: stored.SessionID, clientInstanceID: stored.ClientInstanceID, + leaseVerifier: append([]byte(nil), stored.LeaseVerifier...), transportVerifier: append([]byte(nil), stored.TransportVerifier...), + expiresAt: stored.ExpiresAt, reportIDs: reportIDs, requestHash: stored.RequestHash, + } + } + sessions := make(map[string]*sessionRecord, len(snapshot.Sessions)) + for id, stored := range snapshot.Sessions { + if stored.Public.SessionID != id || stored.Public.ExecutionID != stored.ExecutionID { + return fmt.Errorf("session %q is invalid", id) + } + if _, ok := identities[stored.CreatorID]; !ok { + return fmt.Errorf("session %q has dangling creator %q", id, stored.CreatorID) + } + execution, ok := executions[stored.ExecutionID] + if !ok || execution.SessionID != id { + return fmt.Errorf("session %q has dangling execution %q", id, stored.ExecutionID) + } + if stored.PlacementID != "" { + placement, ok := placements[stored.PlacementID] + if !ok || placement.SessionID != id || stored.Public.PlacementID != stored.PlacementID { + return fmt.Errorf("session %q has dangling placement %q", id, stored.PlacementID) + } + } + record := &sessionRecord{ + PublicSession: clonePublicSession(stored.Public), + joinVerifier: append([]byte(nil), stored.JoinVerifier...), expiresAt: stored.ExpiresAt, + creatorID: stored.CreatorID, executionID: stored.ExecutionID, placementID: stored.PlacementID, + placementIntent: PlacementIntent{AllowedRegions: append([]string(nil), stored.PlacementIntent.AllowedRegions...), LatencyP95MS: stored.PlacementIntent.LatencyP95MS}, + enrollments: make(map[string]*enrollmentRecord), createRequestHash: stored.CreateRequestHash, + } + for _, enrollmentID := range stored.EnrollmentIDs { + enrollment, ok := enrollments[enrollmentID] + if !ok || enrollment.sessionID != id { + return fmt.Errorf("session %q has dangling enrollment %q", id, enrollmentID) + } + record.enrollments[enrollmentID] = enrollment + } + sessions[id] = record + } + for id, enrollment := range enrollments { + if _, ok := sessions[enrollment.sessionID]; !ok { + return fmt.Errorf("enrollment %q has dangling session %q", id, enrollment.sessionID) + } + } + evidenceSets := make(map[string]evidencev1.EvidenceSet, len(snapshot.EvidenceSets)) + for id, set := range snapshot.EvidenceSets { + if set.EvidenceSetID != id { + return fmt.Errorf("evidence set %q has a different identity", id) + } + if _, ok := executions[set.ExecutionID]; !ok { + return fmt.Errorf("evidence set %q has dangling execution %q", id, set.ExecutionID) + } + evidenceSets[id] = cloneEvidenceSet(set) + } + for executionID, execution := range executions { + for _, evidenceSetID := range execution.EvidenceSetIDs { + set, ok := evidenceSets[evidenceSetID] + if !ok || set.ExecutionID != executionID { + return fmt.Errorf("execution %q has dangling evidence set %q", executionID, evidenceSetID) + } + } + } + + s.identities, s.handles, s.sessions, s.enrollments = identities, handles, sessions, enrollments + s.placements, s.executions, s.evidenceSets = placements, executions, evidenceSets + s.identityIdempotency = make(map[string]identityCreateReplay, len(snapshot.IdentityIdempotency)) + for key, replay := range snapshot.IdentityIdempotency { + s.identityIdempotency[key] = identityCreateReplay{RequestHash: replay.RequestHash, Public: clonePublicIdentity(replay.Public)} + } + s.sessionIdempotency = make(map[string]sessionCreateReplay, len(snapshot.SessionIdempotency)) + for key, replay := range snapshot.SessionIdempotency { + s.sessionIdempotency[key] = sessionCreateReplay{RequestHash: replay.RequestHash, Public: clonePublicSession(replay.Public), ExpiresAt: replay.ExpiresAt} + } + s.enrollmentIdempotency = make(map[string]enrollmentCreateReplay, len(snapshot.EnrollmentIdempotency)) + for key, replay := range snapshot.EnrollmentIdempotency { + s.enrollmentIdempotency[key] = enrollmentCreateReplay{RequestHash: replay.RequestHash, Public: replay.Public, ExpiresAt: replay.ExpiresAt} + } + s.evidenceIdempotency = make(map[string]evidenceCreateReplay, len(snapshot.EvidenceIdempotency)) + for key, replay := range snapshot.EvidenceIdempotency { + s.evidenceIdempotency[key] = evidenceCreateReplay{RequestHash: replay.RequestHash, Public: cloneEvidenceSet(replay.Public)} + } + for _, session := range s.sessions { + s.refreshPublicEnrollmentsLocked(session) + } + return nil +} + +func (s *Service) commitLocked(before serviceSnapshot) error { + if s.stateStore == nil { + return nil + } + if err := s.stateStore.Save(s.snapshotLocked()); err != nil { + if restoreErr := s.restoreSnapshotLocked(before); restoreErr != nil { + return domainError("STATE_INTEGRITY_ERROR", "control-state persistence failed and the in-memory rollback could not be verified") + } + return domainError("STATE_PERSISTENCE_FAILED", "control-state persistence failed; the mutation was rolled back") + } + return nil +} diff --git a/internal/externalruntime/state_store_test.go b/internal/externalruntime/state_store_test.go new file mode 100644 index 0000000..61dafe5 --- /dev/null +++ b/internal/externalruntime/state_store_test.go @@ -0,0 +1,134 @@ +package externalruntime + +import ( + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/bayleafwalker/bindery-core/pkg/evidencev1" +) + +func TestPersistentServiceRestoresResolvableControlAndEvidenceGraph(t *testing.T) { + path := filepath.Join(t.TempDir(), "control-state.json") + store, err := NewFileStateStore(path) + if err != nil { + t.Fatal(err) + } + service, err := OpenPersistentService(testPersistentAllocator, store) + if err != nil { + t.Fatal(err) + } + now := time.Date(2026, 8, 25, 19, 0, 0, 0, time.UTC) + service.clock = func() time.Time { return now } + + playerA := mustIdentity(t, service, "persistent-alpha") + playerB := mustIdentity(t, service, "persistent-bravo") + created, err := service.CreateSession(playerA.AccountToken, "persistent-session", testSessionRequest()) + if err != nil { + t.Fatal(err) + } + a := mustEnroll(t, service, playerA.AccountToken, created.SessionJoinCredential, created.PublicSession.SessionID, "persistent-client-a", ClientPlayer) + b := mustEnroll(t, service, playerB.AccountToken, created.SessionJoinCredential, created.PublicSession.SessionID, "persistent-client-b", ClientPlayer) + reportReady(t, service, a, "persistent-ready-a") + reportReady(t, service, b, "persistent-ready-b") + mustReport(t, service, a, "persistent-start-a", "started") + mustReport(t, service, b, "persistent-start-b", "started") + + set, err := service.CreateEvidenceSet(playerA.AccountToken, created.PublicSession.ExecutionID, "persistent-evidence", ReconcileEvidenceRequest{ + Method: evidencev1.MethodExactCount, + Observations: []evidencev1.ObservationSummary{ + {ObserverID: a.id, ExecutionID: created.PublicSession.ExecutionID, StreamID: "telemetry-a", EventCount: 6651}, + {ObserverID: b.id, ExecutionID: created.PublicSession.ExecutionID, StreamID: "telemetry-b", EventCount: 6651}, + }, + }) + if err != nil { + t.Fatal(err) + } + + reopenedStore, err := NewFileStateStore(path) + if err != nil { + t.Fatal(err) + } + reopened, err := OpenPersistentService(testPersistentAllocator, reopenedStore) + if err != nil { + t.Fatal(err) + } + reopened.clock = func() time.Time { return now.Add(time.Second) } + + identity, err := reopened.GetIdentity(playerA.PublicIdentity.AccountID) + if err != nil || identity.Handle != "persistent-alpha" { + t.Fatalf("identity after restart = %+v, error = %v", identity, err) + } + session, err := reopened.GetSession(created.PublicSession.SessionID) + if err != nil || session.ExecutionID != created.PublicSession.ExecutionID || session.PlacementID == "" { + t.Fatalf("session after restart = %+v, error = %v", session, err) + } + placement, err := reopened.GetPlacement(session.PlacementID) + if err != nil || placement.SessionID != session.SessionID || placement.Allocator.Revision == "" { + t.Fatalf("placement after restart = %+v, error = %v", placement, err) + } + execution, err := reopened.GetExecution(session.ExecutionID) + if err != nil || execution.Phase != ExecutionRunning || len(execution.EvidenceSetIDs) != 1 { + t.Fatalf("execution after restart = %+v, error = %v", execution, err) + } + restoredSet, err := reopened.GetEvidenceSet(set.EvidenceSetID) + if err != nil || restoredSet.ExecutionID != execution.ExecutionID || restoredSet.Reconciliation.Outcome != evidencev1.OutcomeConsistent { + t.Fatalf("evidence after restart = %+v, error = %v", restoredSet, err) + } + if _, err := reopened.CreateSession(playerA.AccountToken, "after-restart", testSessionRequest()); err != nil { + t.Fatalf("persisted account verifier no longer authenticates: %v", err) + } + if _, err := reopened.Heartbeat(a.lease, a.id); err != nil { + t.Fatalf("persisted client lease no longer authenticates: %v", err) + } + + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("state permissions = %04o, want 0600", info.Mode().Perm()) + } +} + +func TestPersistenceFailureRollsBackMutation(t *testing.T) { + store := &failingStateStore{snapshot: emptyServiceSnapshot(), fail: true} + service, err := OpenPersistentService(nil, store) + if err != nil { + t.Fatal(err) + } + if _, err := service.CreateIdentity(CreateIdentityRequest{Handle: "rolled-back"}, "rollback-id"); err == nil { + t.Fatal("expected persistence failure") + } + if len(service.identities) != 0 || len(service.handles) != 0 || len(service.identityIdempotency) != 0 { + t.Fatalf("failed mutation remained in memory: identities=%d handles=%d idempotency=%d", len(service.identities), len(service.handles), len(service.identityIdempotency)) + } +} + +func testPersistentAllocator(PlacementIntent) (PublicPlacement, error) { + return PublicPlacement{ + Region: "eu-north", + RelayProviderID: "cncnet-private", + RelayAllocationID: "0198c2c3-4d5e-7f60-8123-456789abcdef", + RelayEndpoint: "192.0.2.10:50001", + PolicyVersion: "cncnet-private-lab-v1", + Allocator: fixtureAllocatorIdentity(), + }, nil +} + +type failingStateStore struct { + snapshot serviceSnapshot + fail bool +} + +func (s *failingStateStore) Load() (serviceSnapshot, error) { return s.snapshot, nil } + +func (s *failingStateStore) Save(snapshot serviceSnapshot) error { + if s.fail { + return errors.New("injected persistence failure") + } + s.snapshot = snapshot + return nil +} diff --git a/internal/externalruntime/types.go b/internal/externalruntime/types.go index 8ae3f39..40305c8 100644 --- a/internal/externalruntime/types.go +++ b/internal/externalruntime/types.go @@ -1,6 +1,10 @@ package externalruntime -import "time" +import ( + "time" + + "github.com/bayleafwalker/bindery-core/pkg/evidencev1" +) const SchemaVersion = "1.0.0" @@ -77,13 +81,52 @@ type CapturePolicy struct { ObserverPreferred bool `json:"observer_preferred"` } +// ImplementationIdentity makes a behaviorally significant component +// resolvable after the process and checkout that executed it are gone. +type ImplementationIdentity struct { + Implementation string `json:"implementation"` + Repository string `json:"repository"` + Revision string `json:"revision"` + ConfigDigest string `json:"config_digest"` +} + type PublicPlacement struct { + SchemaVersion string `json:"schema_version"` + PlacementID string `json:"placement_id"` + SessionID string `json:"session_id"` Region string `json:"region"` RelayProviderID string `json:"relay_provider_id"` RelayAllocationID string `json:"relay_allocation_id"` RelayEndpoint string `json:"relay_endpoint"` PolicyVersion string `json:"policy_version"` DecisionSummary string `json:"decision_summary,omitempty"` + Allocator ImplementationIdentity `json:"allocator"` + CreatedAt time.Time `json:"created_at"` +} + +type ExecutionPhase string + +const ( + ExecutionPrepared ExecutionPhase = "prepared" + ExecutionRunning ExecutionPhase = "running" + ExecutionEnded ExecutionPhase = "ended" + ExecutionFailed ExecutionPhase = "failed" + ExecutionExpired ExecutionPhase = "expired" +) + +// PublicExecution is deliberately distinct from a session. A session carries +// admission intent and participants; an execution is the externally run thing +// to which observations and evidence sets refer. +type PublicExecution struct { + SchemaVersion string `json:"schema_version"` + ExecutionID string `json:"execution_id"` + SessionID string `json:"session_id"` + PlacementID string `json:"placement_id,omitempty"` + Phase ExecutionPhase `json:"phase"` + CreatedAt time.Time `json:"created_at"` + StartedAt *time.Time `json:"started_at,omitempty"` + EndedAt *time.Time `json:"ended_at,omitempty"` + EvidenceSetIDs []string `json:"evidence_set_ids,omitempty"` } type PublicTransition struct { @@ -121,6 +164,7 @@ type PublicEnrollment struct { type PublicCapture struct { CaptureID string `json:"capture_id"` SessionID string `json:"session_id"` + ExecutionID string `json:"execution_id"` ProducerClientID string `json:"producer_client_id"` ProducerClass string `json:"producer_class"` CaptureMethod string `json:"capture_method"` @@ -133,6 +177,8 @@ type PublicCapture struct { type PublicSession struct { SchemaVersion string `json:"schema_version"` SessionID string `json:"session_id"` + ExecutionID string `json:"execution_id"` + PlacementID string `json:"placement_id,omitempty"` CreatedByAccountID string `json:"created_by_account_id"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at,omitempty"` @@ -219,6 +265,11 @@ type HeartbeatResponse struct { ExpiresAt time.Time `json:"expires_at"` } +type ReconcileEvidenceRequest struct { + Method evidencev1.Method `json:"method"` + Observations []evidencev1.ObservationSummary `json:"observations"` +} + type ErrorResponse struct { Code string `json:"code"` Message string `json:"message"` @@ -235,6 +286,8 @@ type sessionRecord struct { joinVerifier []byte expiresAt time.Time creatorID string + executionID string + placementID string placementIntent PlacementIntent enrollments map[string]*enrollmentRecord createRequestHash string diff --git a/pkg/evidencev1/evidence.go b/pkg/evidencev1/evidence.go new file mode 100644 index 0000000..051ce8c --- /dev/null +++ b/pkg/evidencev1/evidence.go @@ -0,0 +1,194 @@ +// Package evidencev1 defines provenance-preserving observations and the +// smallest generic reconciliation policies used by external runtimes. +package evidencev1 + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "regexp" + "sort" + "time" +) + +var digestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) + +type Method string + +const ( + MethodExactCount Method = "exact-count" + MethodOrderedHash Method = "ordered-hash" + MethodSemanticEquivalent Method = "semantic-equivalence" + MethodQuorum Method = "quorum" + MethodDomainSpecific Method = "domain-specific" +) + +type Outcome string + +const ( + OutcomeConsistent Outcome = "consistent" + OutcomeInconsistent Outcome = "inconsistent" +) + +// Observation is one attributable event. Capture stores may retain these in +// full; reconciliation normally consumes bounded stream summaries instead. +type Observation struct { + ObservationID string `json:"observation_id"` + ObserverID string `json:"observer_id"` + ExecutionID string `json:"execution_id"` + StreamID string `json:"stream_id"` + Sequence uint64 `json:"sequence"` + ObservedAt time.Time `json:"observed_at"` + Event json.RawMessage `json:"event"` +} + +// ObservationSummary is a compact claim about one independently produced +// stream. It never becomes truth merely because reconciliation accepts it. +type ObservationSummary struct { + ObserverID string `json:"observer_id"` + ExecutionID string `json:"execution_id"` + StreamID string `json:"stream_id"` + EventCount uint64 `json:"event_count"` + OrderedHash string `json:"ordered_hash,omitempty"` +} + +type Reconciliation struct { + Method Method `json:"method"` + Outcome Outcome `json:"outcome"` + ComparedObservers int `json:"compared_observers"` + DistinctCounts []uint64 `json:"distinct_counts,omitempty"` + DistinctHashes []string `json:"distinct_hashes,omitempty"` +} + +type EvidenceSet struct { + SchemaVersion string `json:"schema_version"` + EvidenceSetID string `json:"evidence_set_id"` + ExecutionID string `json:"execution_id"` + Observations []ObservationSummary `json:"observations"` + Reconciliation Reconciliation `json:"reconciliation"` + CreatedAt time.Time `json:"created_at"` +} + +type ReconcileRequest struct { + ExecutionID string + Method Method + Observations []ObservationSummary + CreatedAt time.Time +} + +var ErrUnsupportedMethod = errors.New("reconciliation method is not implemented") + +// Reconcile compares independent observation streams without changing or +// discarding any of the claims. exact-count is intentionally policy #1: it +// captures the RA2 vertical slice's two matching 6,651-event accounts without +// pretending that equal counts prove semantic identity. +func Reconcile(request ReconcileRequest) (EvidenceSet, error) { + if request.ExecutionID == "" || request.CreatedAt.IsZero() { + return EvidenceSet{}, errors.New("execution id and reconciliation time are required") + } + if len(request.Observations) < 2 { + return EvidenceSet{}, errors.New("at least two independent observations are required") + } + + observations := append([]ObservationSummary(nil), request.Observations...) + observers := make(map[string]struct{}, len(observations)) + streams := make(map[string]struct{}, len(observations)) + for _, observation := range observations { + if observation.ObserverID == "" || observation.StreamID == "" || observation.ExecutionID != request.ExecutionID { + return EvidenceSet{}, errors.New("every observation must name its observer, stream, and requested execution") + } + if _, duplicate := streams[observation.StreamID]; duplicate { + return EvidenceSet{}, fmt.Errorf("duplicate observation stream %q", observation.StreamID) + } + observers[observation.ObserverID] = struct{}{} + streams[observation.StreamID] = struct{}{} + } + if len(observers) < 2 { + return EvidenceSet{}, errors.New("reconciliation requires at least two distinct observers") + } + sort.Slice(observations, func(i, j int) bool { + if observations[i].ObserverID == observations[j].ObserverID { + return observations[i].StreamID < observations[j].StreamID + } + return observations[i].ObserverID < observations[j].ObserverID + }) + + reconciliation := Reconciliation{Method: request.Method, ComparedObservers: len(observers)} + switch request.Method { + case MethodExactCount: + reconciliation.DistinctCounts = distinctCounts(observations) + reconciliation.Outcome = outcome(len(reconciliation.DistinctCounts) == 1) + case MethodOrderedHash: + for _, observation := range observations { + if !digestPattern.MatchString(observation.OrderedHash) { + return EvidenceSet{}, errors.New("ordered-hash reconciliation requires a sha256 digest for every stream") + } + reconciliation.DistinctHashes = distinctHashes(observations) + reconciliation.Outcome = outcome(len(reconciliation.DistinctHashes) == 1) + case MethodSemanticEquivalent, MethodQuorum, MethodDomainSpecific: + return EvidenceSet{}, fmt.Errorf("%w: %s", ErrUnsupportedMethod, request.Method) + default: + return EvidenceSet{}, fmt.Errorf("unknown reconciliation method %q", request.Method) + } + + id, err := evidenceSetID(request.ExecutionID, request.Method, observations) + if err != nil { + return EvidenceSet{}, err + } + return EvidenceSet{ + SchemaVersion: "1.0.0", + EvidenceSetID: id, + ExecutionID: request.ExecutionID, + Observations: observations, + Reconciliation: reconciliation, + CreatedAt: request.CreatedAt.UTC(), + }, nil +} + +func outcome(consistent bool) Outcome { + if consistent { + return OutcomeConsistent + } + return OutcomeInconsistent +} + +func distinctCounts(observations []ObservationSummary) []uint64 { + seen := make(map[uint64]struct{}, len(observations)) + for _, observation := range observations { + seen[observation.EventCount] = struct{}{} + } + result := make([]uint64, 0, len(seen)) + for count := range seen { + result = append(result, count) + } + sort.Slice(result, func(i, j int) bool { return result[i] < result[j] }) + return result +} + +func distinctHashes(observations []ObservationSummary) []string { + seen := make(map[string]struct{}, len(observations)) + for _, observation := range observations { + seen[observation.OrderedHash] = struct{}{} + } + result := make([]string, 0, len(seen)) + for hash := range seen { + result = append(result, hash) + } + sort.Strings(result) + return result +} + +func evidenceSetID(executionID string, method Method, observations []ObservationSummary) (string, error) { + canonical, err := json.Marshal(struct { + ExecutionID string `json:"execution_id"` + Method Method `json:"method"` + Observations []ObservationSummary `json:"observations"` + }{executionID, method, observations}) + if err != nil { + return "", fmt.Errorf("encode evidence identity: %w", err) + } + digest := sha256.Sum256(canonical) + return "sha256:" + hex.EncodeToString(digest[:]), nil +} diff --git a/pkg/evidencev1/evidence_test.go b/pkg/evidencev1/evidence_test.go new file mode 100644 index 0000000..3f84bf8 --- /dev/null +++ b/pkg/evidencev1/evidence_test.go @@ -0,0 +1,79 @@ +package evidencev1 + +import ( + "errors" + "testing" + "time" +) + +func TestExactCountReconcilesIndependentRA2Streams(t *testing.T) { + set, err := Reconcile(ReconcileRequest{ + ExecutionID: "execution-ra2-vertical-slice", + Method: MethodExactCount, + CreatedAt: time.Date(2026, 8, 25, 19, 0, 0, 0, time.UTC), + Observations: []ObservationSummary{ + {ObserverID: "client-b", ExecutionID: "execution-ra2-vertical-slice", StreamID: "telemetry-b", EventCount: 6651}, + {ObserverID: "client-a", ExecutionID: "execution-ra2-vertical-slice", StreamID: "telemetry-a", EventCount: 6651}, + }, + }) + if err != nil { + t.Fatal(err) + } + if set.Reconciliation.Outcome != OutcomeConsistent { + t.Fatalf("outcome = %s, want consistent", set.Reconciliation.Outcome) + } + if len(set.Reconciliation.DistinctCounts) != 1 || set.Reconciliation.DistinctCounts[0] != 6651 { + t.Fatalf("distinct counts = %v", set.Reconciliation.DistinctCounts) + } + if set.Observations[0].ObserverID != "client-a" || set.EvidenceSetID == "" { + t.Fatalf("evidence set is not canonical: %+v", set) + } +} + +func TestExactCountRetainsDisagreement(t *testing.T) { + set, err := Reconcile(ReconcileRequest{ + ExecutionID: "execution-1", + Method: MethodExactCount, + CreatedAt: time.Now(), + Observations: []ObservationSummary{ + {ObserverID: "a", ExecutionID: "execution-1", StreamID: "a-stream", EventCount: 6651}, + {ObserverID: "b", ExecutionID: "execution-1", StreamID: "b-stream", EventCount: 6650}, + }, + }) + if err != nil { + t.Fatal(err) + } + if set.Reconciliation.Outcome != OutcomeInconsistent || len(set.Observations) != 2 { + t.Fatalf("disagreement was not preserved: %+v", set) + } +} + +func TestReconciliationRequiresIndependentObservers(t *testing.T) { + _, err := Reconcile(ReconcileRequest{ + ExecutionID: "execution-1", + Method: MethodExactCount, + CreatedAt: time.Now(), + Observations: []ObservationSummary{ + {ObserverID: "same", ExecutionID: "execution-1", StreamID: "stream-1", EventCount: 10}, + {ObserverID: "same", ExecutionID: "execution-1", StreamID: "stream-2", EventCount: 10}, + }, + }) + if err == nil { + t.Fatal("one observer with two streams was treated as independent evidence") + } +} + +func TestUnimplementedPoliciesRemainExplicit(t *testing.T) { + _, err := Reconcile(ReconcileRequest{ + ExecutionID: "execution-1", + Method: MethodSemanticEquivalent, + CreatedAt: time.Now(), + Observations: []ObservationSummary{ + {ObserverID: "a", ExecutionID: "execution-1", StreamID: "stream-a"}, + {ObserverID: "b", ExecutionID: "execution-1", StreamID: "stream-b"}, + }, + }) + if !errors.Is(err, ErrUnsupportedMethod) { + t.Fatalf("error = %v, want unsupported method", err) + } +} diff --git a/pkg/gatev1/gate.go b/pkg/gatev1/gate.go new file mode 100644 index 0000000..76631c0 --- /dev/null +++ b/pkg/gatev1/gate.go @@ -0,0 +1,190 @@ +// Package gatev1 defines calibrated, context-aware verification gates. +package gatev1 + +import ( + "errors" + "fmt" + "regexp" + "slices" +) + +var digestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) + +type Status string + +const ( + StatusPass Status = "PASS" + StatusFail Status = "FAIL" + StatusNotApplicable Status = "NOT_APPLICABLE" + StatusUnresolved Status = "UNRESOLVED" + StatusError Status = "ERROR" +) + +type AppliesWhen struct { + Phases []string `json:"phases,omitempty"` + ArtifactTypes []string `json:"artifact_types,omitempty"` + Capabilities []string `json:"capabilities,omitempty"` +} + +type Context struct { + Phase string `json:"phase,omitempty"` + ArtifactType string `json:"artifact_type,omitempty"` + Capabilities []string `json:"capabilities,omitempty"` + CapabilitiesKnown bool `json:"capabilities_known"` +} + +type ControlKind string + +const ( + ControlPositive ControlKind = "positive" + ControlNegative ControlKind = "negative" +) + +type CalibrationControl struct { + Kind ControlKind `json:"kind"` + FixtureID string `json:"fixture_id"` + FixtureDigest string `json:"fixture_digest"` + Expected Status `json:"expected"` + Observed Status `json:"observed"` +} + +type Definition struct { + GateID string `json:"gate_id"` + Version string `json:"version"` + ImplementationHash string `json:"implementation_hash"` + Consequential bool `json:"consequential"` + AppliesWhen AppliesWhen `json:"applies_when"` + Calibration []CalibrationControl `json:"calibration,omitempty"` +} + +type Result struct { + GateID string `json:"gate_id"` + GateVersion string `json:"gate_version"` + ImplementationHash string `json:"implementation_hash"` + Status Status `json:"status"` + Reason string `json:"reason"` + CalibrationValid bool `json:"calibration_valid"` +} + +type Evaluator func() (Status, error) + +// Evaluate separates applicability and verifier health from the fact being +// checked. A broken or uncalibrated verifier is ERROR; it is never evidence +// that the subject failed. +func Evaluate(definition Definition, context Context, evaluator Evaluator) Result { + result := Result{ + GateID: definition.GateID, + GateVersion: definition.Version, + ImplementationHash: definition.ImplementationHash, + } + if err := validateDefinition(definition); err != nil { + result.Status = StatusError + result.Reason = err.Error() + return result + } + + status, reason := applicability(definition.AppliesWhen, context) + if status != StatusPass { + result.Status = status + result.Reason = reason + return result + } + + if err := validateCalibration(definition); err != nil { + result.Status = StatusError + result.Reason = err.Error() + return result + } + result.CalibrationValid = true + if evaluator == nil { + result.Status = StatusError + result.Reason = "gate evaluator is not configured" + return result + } + observed, err := evaluator() + if err != nil { + result.Status = StatusError + result.Reason = err.Error() + return result + } + if observed != StatusPass && observed != StatusFail { + result.Status = StatusError + result.Reason = fmt.Sprintf("evaluator returned invalid terminal status %q", observed) + return result + } + result.Status = observed + result.Reason = "gate evaluated in an applicable, calibrated context" + return result +} + +func validateDefinition(definition Definition) error { + if definition.GateID == "" || definition.Version == "" { + return errors.New("gate id and version are required") + } + if !digestPattern.MatchString(definition.ImplementationHash) { + return errors.New("gate implementation hash must be a sha256 digest") + } + return nil +} +func applicability(rule AppliesWhen, context Context) (Status, string) { + if len(rule.Phases) > 0 { + if context.Phase == "" { + return StatusUnresolved, "phase is required to establish gate applicability" + } + if !slices.Contains(rule.Phases, context.Phase) { + return StatusNotApplicable, "gate does not apply to this phase" + } + } + if len(rule.ArtifactTypes) > 0 { + if context.ArtifactType == "" { + return StatusUnresolved, "artifact type is required to establish gate applicability" + } + if !slices.Contains(rule.ArtifactTypes, context.ArtifactType) { + return StatusNotApplicable, "gate does not apply to this artifact type" + } + } + if len(rule.Capabilities) > 0 { + if !context.CapabilitiesKnown { + return StatusUnresolved, "capabilities are required to establish gate applicability" + } + for _, required := range rule.Capabilities { + if !slices.Contains(context.Capabilities, required) { + return StatusNotApplicable, fmt.Sprintf("gate requires capability %q", required) + } + } + } + return StatusPass, "gate applies" +} + +func validateCalibration(definition Definition) error { + if !definition.Consequential { + return nil + } + positive, negative := false, false + for _, control := range definition.Calibration { + if control.FixtureID == "" || !digestPattern.MatchString(control.FixtureDigest) { + return errors.New("every calibration control requires an id and sha256 fixture digest") + } + if control.Observed != control.Expected { + return fmt.Errorf("%s control %q observed %s, expected %s", control.Kind, control.FixtureID, control.Observed, control.Expected) + } + switch control.Kind { + case ControlPositive: + if control.Expected != StatusPass { + return errors.New("positive control must expect PASS") + } + positive = true + case ControlNegative: + if control.Expected != StatusFail { + return errors.New("negative control must expect FAIL") + } + negative = true + default: + return fmt.Errorf("unknown calibration control kind %q", control.Kind) + } + } + if !positive || !negative { + return errors.New("consequential gate requires passing positive and negative calibration controls") + } + return nil +} diff --git a/pkg/gatev1/gate_test.go b/pkg/gatev1/gate_test.go new file mode 100644 index 0000000..9534280 --- /dev/null +++ b/pkg/gatev1/gate_test.go @@ -0,0 +1,87 @@ +package gatev1 + +import ( + "errors" + "testing" +) + +const testDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +func TestWrongContextIsNotFailure(t *testing.T) { + called := false + result := Evaluate(calibratedDefinition(), Context{ + Phase: "run", + ArtifactType: "task-packet", + Capabilities: []string{"oracle-read-tracing"}, + CapabilitiesKnown: true, + }, func() (Status, error) { + called = true + return StatusFail, nil + }) + if result.Status != StatusNotApplicable || called { + t.Fatalf("result = %+v, evaluator called = %t", result, called) + } +} + +func TestUnknownApplicabilityIsUnresolved(t *testing.T) { + result := Evaluate(calibratedDefinition(), Context{ + Phase: "freeze", + ArtifactType: "task-packet", + }, func() (Status, error) { return StatusPass, nil }) + if result.Status != StatusUnresolved { + t.Fatalf("status = %s, want UNRESOLVED", result.Status) + } +} + +func TestAlwaysFailingValidatorIsCalibrationError(t *testing.T) { + definition := calibratedDefinition() + definition.Calibration[0].Observed = StatusFail + called := false + result := Evaluate(definition, applicableContext(), func() (Status, error) { + called = true + return StatusFail, nil + }) + if result.Status != StatusError || called { + t.Fatalf("result = %+v, evaluator called = %t", result, called) + } +} + +func TestCalibratedApplicableGateCanPassOrFail(t *testing.T) { + for _, expected := range []Status{StatusPass, StatusFail} { + result := Evaluate(calibratedDefinition(), applicableContext(), func() (Status, error) { return expected, nil }) + if result.Status != expected || !result.CalibrationValid { + t.Fatalf("result = %+v, want %s", result, expected) + } + } + result := Evaluate(calibratedDefinition(), applicableContext(), func() (Status, error) { return StatusError, errors.New("fixture unreadable") }) + if result.Status != StatusError { + t.Fatalf("evaluator error became %s", result.Status) + } +} + +func calibratedDefinition() Definition { + return Definition{ + GateID: "oracle-read-tracing", + Version: "1.0.0", + ImplementationHash: testDigest, + Consequential: true, + AppliesWhen: AppliesWhen{ + Phases: []string{"freeze"}, + ArtifactTypes: []string{"task-packet"}, + Capabilities: []string{"oracle-read-tracing"}, + }, + Calibration: []CalibrationControl{ + {Kind: ControlPositive, FixtureID: "known-pass", FixtureDigest: testDigest, Expected: StatusPass, Observed: StatusPass}, + {Kind: ControlNegative, FixtureID: "known-fail", FixtureDigest: testDigest, Expected: StatusFail, Observed: StatusFail}, + }, + } +} + +func applicableContext() Context { + return Context{ + Phase: "freeze", + ArtifactType: "task-packet", + Capabilities: []string{"oracle-read-tracing"}, + CapabilitiesKnown: true, + } +} From 05e0505322d3dcd15c4b0d66fe4dcfcbf201b1dd Mon Sep 17 00:00:00 2001 From: bayleafwalker Date: Tue, 25 Aug 2026 23:08:39 +0300 Subject: [PATCH 2/3] fix(evidence): close ordered-hash validation loop --- pkg/evidencev1/evidence.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/evidencev1/evidence.go b/pkg/evidencev1/evidence.go index 051ce8c..f0ba2db 100644 --- a/pkg/evidencev1/evidence.go +++ b/pkg/evidencev1/evidence.go @@ -125,6 +125,7 @@ func Reconcile(request ReconcileRequest) (EvidenceSet, error) { if !digestPattern.MatchString(observation.OrderedHash) { return EvidenceSet{}, errors.New("ordered-hash reconciliation requires a sha256 digest for every stream") } + } reconciliation.DistinctHashes = distinctHashes(observations) reconciliation.Outcome = outcome(len(reconciliation.DistinctHashes) == 1) case MethodSemanticEquivalent, MethodQuorum, MethodDomainSpecific: From 30168fa7acaba726a84e204703eb3dd8c6e01ab2 Mon Sep 17 00:00:00 2001 From: bayleafwalker Date: Tue, 25 Aug 2026 23:11:07 +0300 Subject: [PATCH 3/3] fix(ci): fetch history for secret scanning --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a45144..d533287 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + # Gitleaks compares the full pushed/PR commit range. A depth-1 + # checkout cannot resolve the parent once the branch has >1 commit. + fetch-depth: 0 - uses: actions/setup-go@v5 with: go-version-file: go.mod @@ -71,4 +75,3 @@ jobs: with: subject-name: ghcr.io/bayleafwalker/bindery-core subject-digest: ${{ steps.build.outputs.digest }} -