Skip to content

[formal-spec] otel-observability-spec.md — Formal model & test suite — 2026-08-02 #49807

Description

@github-actions

Summary

This formalizes the specs/otel-observability-spec.md (v0.4.0) OpenTelemetry Observability Specification for gh-aw. The spec defines the observability.otlp configuration surface, endpoint/header normalization, direct/collector export modes, W3C trace-context propagation, resource identity, span/metric/log contracts, and security/cardinality controls. This run extends the existing formalization (otel_observability_formal_test.go, 15 predicates) with 6 additional predicates (P16–P21) covering resource-attribute secret-reference rejection, attribute/resource-attribute independence, merge precedence semantics, and two forward-looking stub predicates for metric cardinality bounds and instrumentation scope naming that have no concrete implementation yet.

Specification

  • File: specs/otel-observability-spec.md
  • Focus area: OTLP configuration validation, attribute/resource-attribute handling, metric cardinality, instrumentation scope naming
  • Formal notation used: TLA+ / Z3-style guard conjunction / F* pre-post contracts

Formal Model

Predicates and invariants (illustrative notation)

P16 — SecretRefResourceAttributeRejected (§5.3, §8)

Source: "Headers MUST be classified as secrets..." / "observability.otlp.resource-attributes.%s must not reference secrets.* or vars.*"

∀ (key, value) ∈ resource_attributes(workflow):
    SecretRefPattern.matches(value) ⟹ ValidateResourceAttributes(workflow) = Err(msg)

F*-style contract:

val validateOTLPResourceAttributes: workflow:WorkflowData -> Pure (result unit)
  (requires True)
  (ensures fun r -> (exists k v. resourceAttrs workflow k v /\ isSecretRef v) ==> isErr r)

P17 — CustomAttributesResourceAttributesIndependent (§8.1)

Source: "Stable resource attributes include..." vs custom span attributes; the two fields are parsed from disjoint frontmatter keys (attributes vs resource-attributes).

collectOTLPCustomAttributes(fm) ∩ collectOTLPResourceAttributes(fm) semantics ⟹
    ∀ fm: customAttrs(fm) = extract(fm, "attributes") ∧
          resourceAttrs(fm) = extract(fm, "resource-attributes") ∧
          customAttrs(fm) ≠ resourceAttrs(fm) when contents differ

P18 — MergePrecedenceBaseWinsOverOverride (§5.3, layered config semantics)

Source: "Top-level headers MUST apply only to the string endpoint form. Object and array forms MUST use per-entry headers." — generalized as a base/override precedence invariant used by mergeOTLPStringMaps.

∀ k ∈ dom(base) ∩ dom(override): merge(base, override)[k] = base[k]
∀ k ∈ dom(base) \ dom(override): merge(base, override)[k] = base[k]
∀ k ∈ dom(override) \ dom(base): merge(base, override)[k] = override[k]

P19 — MergeOfEmptyMapsYieldsNil (defensive allocation invariant)

base = ∅ ∧ override = ∅ ⟹ merge(base, override) = nil

P20 — MetricResourceCardinalityBound (§8.3) — stub, no concrete implementation yet

Source: "Workflow run IDs, job run IDs, trace IDs, span IDs, commit SHAs, pull-request or issue numbers, actor IDs, item URLs, and conversation IDs MUST NOT be metric resource attributes or metric dimensions by default."

∀ d ∈ HighCardinalityDimensions: IsAllowedByDefault(d) = false
∀ d ∈ BoundedCardinalityDimensions: IsAllowedByDefault(d) = true
HighCardinalityDimensions = {run.id, job.run.id, trace.id, span.id, commit.sha,
                              pull_request.id, issue.id, actor.id, item.url, conversation.id}

P21 — InstrumentationScopeNaming (§8.4) — stub, no concrete implementation yet

Source: "Telemetry emitted by the core runtime MUST use instrumentation scope name gh-aw... Gateway telemetry SHOULD use a gateway-specific scope such as gh-aw-mcpg."

CoreScope = "gh-aw"
GatewayScope = "gh-aw-mcpg"
CoreScope ≠ GatewayScope

Behavioral Coverage Map

Predicate / Invariant Test Function Description
SecretRefResourceAttributeRejected (P16) TestFormal_SecretRefResourceAttributeRejected Rejects resource-attributes values referencing secrets.*/vars.*; accepts literals and nil workflow data
CustomAttributesResourceAttributesIndependent (P17) TestFormal_CustomAttributesResourceAttributesIndependent Confirms attributes and resource-attributes are parsed and stored independently
MergePrecedenceBaseWinsOverOverride (P18) TestFormal_MergePrecedenceBaseWinsOverOverride Verifies base-map values win on key collision; disjoint keys are unioned
MergeOfEmptyMapsYieldsNil (P19) TestFormal_MergeOfEmptyMapsYieldsNil Confirms merging nil/empty maps yields nil, not an empty allocated map
MetricResourceCardinalityBound (P20) TestFormal_MetricResourceCardinalityBound Ensures high-cardinality identifiers are excluded from default metric dimensions; bounded ones are allowed (stub interface)
InstrumentationScopeNaming (P21) TestFormal_InstrumentationScopeNaming Confirms core scope is gh-aw, gateway scope is gh-aw-mcpg, and the two remain distinct (stub interface)

Generated Test Suite

📄 pkg/workflow/otel_observability_formal_v2_test.go
package workflow

// This file formalizes additional predicates from
// specs/otel-observability-spec.md (v0.4.0) that are not yet covered by
// otel_observability_formal_test.go. It complements that file rather than
// duplicating its predicates.
//
// Formal predicates encoded here (see issue body for full TLA+/Z3/F* notation):
//   P16 SecretRefResourceAttributeRejected  (§5.3 / §8: resource-attributes MUST NOT reference secrets/vars)
//   P17 CustomAttributesResourceAttributesIndependent (§8.1: attributes and resource-attributes are distinct maps)
//   P18 MergePrecedenceBaseWinsOverOverride (§5.3-adjacent merge semantics used for header/attribute layering)
//   P19 MergeOfEmptyMapsYieldsNil (defensive merge invariant: no spurious allocation)
//   P20 MetricResourceCardinalityBound (§8.3: run/job/trace/span/commit/actor/item IDs MUST NOT be metric dimensions by default) — stub, no concrete implementation exists yet
//   P21 InstrumentationScopeNaming (§8.4: core scope MUST be "gh-aw"; gateway scope SHOULD be "gh-aw-mcpg") — stub, no concrete implementation exists yet

import (
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

// TestFormal_SecretRefResourceAttributeRejected encodes P16: §8/§5 requires that
// observability.otlp.resource-attributes values MUST NOT reference secrets.* or
// vars.* because resource attributes are exported to tracing backends and are
// not treated as secret values.
func TestFormal_SecretRefResourceAttributeRejected(t *testing.T) {
	tests := []struct {
		name      string
		workflow  *WorkflowData
		expectErr bool
	}{
		{
			name: "secrets reference is rejected",
			workflow: &WorkflowData{
				RawFrontmatter: map[string]any{
					"observability": map[string]any{
						"otlp": map[string]any{
							"resource-attributes": map[string]any{
								"team": "${{ secrets.TEAM_NAME }}",
							},
						},
					},
				},
			},
			expectErr: true,
		},
		{
			name: "vars reference is rejected",
			workflow: &WorkflowData{
				RawFrontmatter: map[string]any{
					"observability": map[string]any{
						"otlp": map[string]any{
							"resource-attributes": map[string]any{
								"env": "${{ vars.DEPLOY_ENV }}",
							},
						},
					},
				},
			},
			expectErr: true,
		},
		{
			name: "plain literal value is accepted",
			workflow: &WorkflowData{
				RawFrontmatter: map[string]any{
					"observability": map[string]any{
						"otlp": map[string]any{
							"resource-attributes": map[string]any{
								"team": "platform-eng",
							},
						},
					},
				},
			},
			expectErr: false,
		},
		{
			name:      "nil workflow data produces no error",
			workflow:  nil,
			expectErr: false,
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			err := validateOTLPResourceAttributes(tc.workflow)
			if tc.expectErr {
				require.Error(t, err, "resource-attributes referencing secrets/vars MUST be rejected per §8/§5.3")
				assert.Contains(t, err.Error(), "must not reference secrets.* or vars.*",
					"error message must explain why the value is disallowed")
			} else {
				assert.NoError(t, err, "literal resource-attribute values or nil workflow data MUST NOT produce an error")
			}
		})
	}
}

// TestFormal_CustomAttributesResourceAttributesIndependent encodes P17: §8.1
// requires that `observability.otlp.attributes` (span/custom attributes) and
// `observability.otlp.resource-attributes` (resource identity) are collected
// independently and MUST NOT be conflated.
func TestFormal_CustomAttributesResourceAttributesIndependent(t *testing.T) {
	frontmatter := map[string]any{
		"observability": map[string]any{
			"otlp": map[string]any{
				"attributes": map[string]any{
					"session.id": "abc123",
				},
				"resource-attributes": map[string]any{
					"deployment.environment.name": "prod",
				},
			},
		},
	}

	customAttrs := collectOTLPCustomAttributes(frontmatter)
	resourceAttrs := collectOTLPResourceAttributes(frontmatter)

	require.NotNil(t, customAttrs, "custom attributes map must be collected when present")
	require.NotNil(t, resourceAttrs, "resource attributes map must be collected when present")
	assert.Equal(t, map[string]string{"session.id": "abc123"}, customAttrs,
		"custom attributes MUST only contain the `attributes` field contents")
	assert.Equal(t, map[string]string{"deployment.environment.name": "prod"}, resourceAttrs,
		"resource attributes MUST only contain the `resource-attributes` field contents")
	assert.NotEqual(t, customAttrs, resourceAttrs, "the two attribute maps MUST remain independent")
}

// TestFormal_MergePrecedenceBaseWinsOverOverride encodes P18: the layered
// attribute/header merge helper used across the OTLP configuration pipeline
// MUST let the more specific ("base") map win over the less specific
// ("override") map on key collision, matching the documented per-entry vs
// top-level precedence rules in §5.3.
func TestFormal_MergePrecedenceBaseWinsOverOverride(t *testing.T) {
	tests := []struct {
		name     string
		base     map[string]string
		override map[string]string
		want     map[string]string
	}{
		{
			name:     "base value wins on key collision",
			base:     map[string]string{"k": "base-value", "only-base": "b"},
			override: map[string]string{"k": "override-value", "only-override": "o"},
			want:     map[string]string{"k": "base-value", "only-base": "b", "only-override": "o"},
		},
		{
			name:     "disjoint keys are unioned",
			base:     map[string]string{"a": "1"},
			override: map[string]string{"b": "2"},
			want:     map[string]string{"a": "1", "b": "2"},
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			got := mergeOTLPStringMaps(tc.base, tc.override)
			assert.Equal(t, tc.want, got, "base map values MUST take precedence over override map values on key collision")
		})
	}
}

// TestFormal_MergeOfEmptyMapsYieldsNil encodes P19: merging two empty (or nil)
// maps MUST return nil rather than an allocated empty map, so downstream code
// that checks len(...) == 0 or nil-ness behaves consistently.
func TestFormal_MergeOfEmptyMapsYieldsNil(t *testing.T) {
	assert.Nil(t, mergeOTLPStringMaps(nil, nil), "merging two nil maps MUST yield nil")
	assert.Nil(t, mergeOTLPStringMaps(map[string]string{}, nil), "merging an empty map and nil MUST yield nil")
	assert.Nil(t, mergeOTLPStringMaps(nil, map[string]string{}), "merging nil and an empty map MUST yield nil")
}

// metricAttributeRegistry is a stub — replace with real implementation.
// It represents the set of dimensions a conforming metric provider is
// permitted to attach by default, per §8.3.
type metricAttributeRegistry struct {
	forbiddenByDefault map[string]bool
}

// stub — replace with real implementation.
func newMetricAttributeRegistry() *metricAttributeRegistry {
	return &metricAttributeRegistry{
		forbiddenByDefault: map[string]bool{
			"gh-aw.run.id":        true,
			"github.run_id":       true,
			"trace_id":            true,
			"span_id":             true,
			"commit.sha":          true,
			"pull_request.id":     true,
			"issue.id":            true,
			"actor.id":            true,
			"item.url":            true,
			"conversation.id":     true,
			"gh-aw.workflow.name": false,
			"service.name":        false,
		},
	}
}

// stub — replace with real implementation.
func (r *metricAttributeRegistry) isAllowedByDefault(dimension string) bool {
	forbidden, known := r.forbiddenByDefault[dimension]
	if !known {
		// Unknown dimensions are conservatively allowed in this stub; a real
		// implementation MUST classify every emitted dimension explicitly.
		return true
	}
	return !forbidden
}

// TestFormal_MetricResourceCardinalityBound encodes P20 (§8.3): a metric
// provider MUST NOT attach unique run, job, trace, span, commit, actor, item,
// or conversation identifiers as metric dimensions by default. No concrete
// gh-aw metrics-cardinality-filter implementation currently exists in
// pkg/workflow, so this test exercises a minimal stub interface that captures
// the required behavior; it should be replaced with the real filter once
// implemented.
func TestFormal_MetricResourceCardinalityBound(t *testing.T) {
	registry := newMetricAttributeRegistry()

	highCardinality := []string{
		"gh-aw.run.id", "github.run_id", "trace_id", "span_id",
		"commit.sha", "pull_request.id", "issue.id", "actor.id",
		"item.url", "conversation.id",
	}
	for _, dim := range highCardinality {
		assert.False(t, registry.isAllowedByDefault(dim),
			"high-cardinality dimension %q MUST NOT be a default metric attribute per §8.3", dim)
	}

	boundedCardinality := []string{"gh-aw.workflow.name", "service.name"}
	for _, dim := range boundedCardinality {
		assert.True(t, registry.isAllowedByDefault(dim),
			"bounded-cardinality dimension %q MAY be a default metric attribute", dim)
	}
}

// instrumentationScopeResolver is a stub — replace with real implementation.
// §8.4 requires scope name "gh-aw" for core runtime telemetry and a
// gateway-specific scope such as "gh-aw-mcpg" for gateway telemetry.
type instrumentationScopeResolver struct{}

// stub — replace with real implementation.
func (instrumentationScopeResolver) coreScopeName() string { return "gh-aw" }

// stub — replace with real implementation.
func (instrumentationScopeResolver) gatewayScopeName() string { return "gh-aw-mcpg" }

// TestFormal_InstrumentationScopeNaming encodes P21 (§8.4): core runtime
// telemetry MUST use instrumentation scope "gh-aw"; gateway telemetry SHOULD
// use a gateway-specific scope such as "gh-aw-mcpg". No concrete scope-naming
// implementation currently exists in pkg/workflow, so this test exercises a
// minimal stub interface capturing the required naming contract.
func TestFormal_InstrumentationScopeNaming(t *testing.T) {
	resolver := instrumentationScopeResolver{}

	assert.Equal(t, "gh-aw", resolver.coreScopeName(),
		"core runtime instrumentation scope MUST be named \"gh-aw\" per §8.4")
	assert.Equal(t, "gh-aw-mcpg", resolver.gatewayScopeName(),
		"gateway instrumentation scope SHOULD be named \"gh-aw-mcpg\" per §8.4")
	assert.NotEqual(t, resolver.coreScopeName(), resolver.gatewayScopeName(),
		"core and gateway scopes MUST remain distinct to avoid conflating telemetry sources")
}

Usage

  1. Copy the test file to the appropriate package directory (pkg/workflow/).
  2. Replace the metricAttributeRegistry and instrumentationScopeResolver stub interfaces (P20, P21) with real implementations once a metrics-cardinality filter and instrumentation-scope resolver exist in pkg/workflow.
  3. Run: go test ./pkg/workflow/... -run Formal

Context

Warning

Firewall blocked 1 domain

The following domain was blocked by the firewall during workflow execution:

  • proxy.golang.org

To allow these domains, add them to the network.allowed list in your workflow frontmatter:

network:
  allowed:
    - defaults
    - "proxy.golang.org"

See Network Configuration for more information.

Generated by 🔬 Daily Formal Spec Verifier · auto · 83.2 AIC · ⌖ 6.12 AIC · ⊞ 10K ·

  • expires on Aug 9, 2026, 7:52 AM UTC-08:00

Metadata

Metadata

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions