You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@causeway.apache.org by da...@apache.org on 2026/10/06 07:23:36 UTC

(causeway) branch CAUSEWAY-4068-v4 created (now 670607d175f)

This is an automated email from the ASF dual-hosted git repository.

danhaywood pushed a change to branch CAUSEWAY-4068-v4
in repository https://gitbox.apache.org/repos/asf/causeway.git


      at 670607d175f CAUSEWAY-4068: classify entry spans and correlate root interactions

This branch includes the following new commits:

     new 7f9edf85b7a CAUSEWAY-4068: propose observation classification and correlation
     new 670607d175f CAUSEWAY-4068: classify entry spans and correlate root interactions

The 2 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(causeway) 02/02: CAUSEWAY-4068: classify entry spans and correlate root interactions

Posted by da...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

danhaywood pushed a commit to branch CAUSEWAY-4068-v4
in repository https://gitbox.apache.org/repos/asf/causeway.git

commit 670607d175f33a77123f94ba04e6592f05c4b710
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Tue Oct 6 08:08:52 2026 +0100

    CAUSEWAY-4068: classify entry spans and correlate root interactions
---
 .../ROOT/pages/2026/4.0.0-M3/otel-howto.adoc       |  80 ++++++++-
 .../modules/ROOT/pages/observability.adoc          |  48 +++++-
 .../observation/CausewayExecutionPolicy.java       |  36 ++++
 .../CausewayObservationAutoConfiguration.java      |  23 ++-
 .../observation/CausewayTraceClassifier.java       |  45 +++++
 .../observation/CausewayTraceClassifierTest.java   | 107 ++++++++++++
 .../metamodel/execution/InteractionLayerStack.java |  19 ++-
 .../ia/InteractionServiceObservationTest.java      |  64 ++++++-
 core/webapp/src/main/java/module-info.java         |   1 +
 .../core/webapp/CausewayModuleCoreWebapp.java      |   2 +
 .../observation/CausewayForegroundTraceFilter.java |  49 ++++++
 .../observation/WebObservationConfiguration.java   |  47 ++++++
 .../CausewayForegroundTraceFilterTest.java         |  70 ++++++++
 .../applib/job/RunBackgroundCommandsJob.java       |   7 +
 ...ckgroundCommandsJobTraceClassificationTest.java |  65 ++++++++
 .../classify-and-correlate-observations/design.md  |  10 +-
 .../proposal.md                                    |   2 +-
 .../classify-and-correlate-observations/tasks.md   |  24 +--
 .../validation.md                                  |  67 ++++++++
 .../planned-changes/otel-forward-port-roadmap.md   |   6 +-
 regressiontests/tracing-compatibility/pom.xml      |  15 ++
 .../tracing/EntryPointTracingFixture.java          | 185 +++++++++++++++++++++
 .../tracing/MicrometerTracingAgentFixture.java     |   7 +-
 .../MicrometerTracingCompatibilityTest.java        |  57 ++++++-
 .../main/resources/application-agent.properties    |   3 +-
 .../petclinic/AgentObservationConfiguration.java   |   7 +-
 26 files changed, 1006 insertions(+), 40 deletions(-)

diff --git a/antora/components/relnotes/modules/ROOT/pages/2026/4.0.0-M3/otel-howto.adoc b/antora/components/relnotes/modules/ROOT/pages/2026/4.0.0-M3/otel-howto.adoc
index dbaa35fd7e1..8b3768541e3 100644
--- a/antora/components/relnotes/modules/ROOT/pages/2026/4.0.0-M3/otel-howto.adoc
+++ b/antora/components/relnotes/modules/ROOT/pages/2026/4.0.0-M3/otel-howto.adoc
@@ -116,10 +116,13 @@ Full sampling and frequent metrics export are useful for this local verification
 
 In Jaeger, select service *Causeway Pet Clinic*, click *Find Traces*, and open a recent trace from a domain operation.
 HTMX domain operations use `/graphql`; Wicket requests use `/wicket/` paths.
+Select the enclosing HTTP server span to see `causeway.execution.mode=foreground`; this includes static asset requests as well as domain operations.
 Expand the span tree and select *Causeway Root Interaction* to inspect its *Attributes*, such as `causeway.interaction.language`, `causeway.interaction.timezone` and `causeway.user.impersonating`.
 In this default run, `causeway.user.name` and `causeway.user.multiTenancyToken` should be absent.
 When explicitly enabled in <<verification-scenarios>>, those attributes appear on interaction spans when the corresponding user values are nonempty; they are not added to the enclosing HTTP server span.
-Static asset requests, such as `pdf.worker.min.mjs`, may contain only HTTP/security spans.
+The root interaction also has `causeway.interaction.id`, the UUID used by Causeway's interaction/command, even in the default scenario.
+It is distinct from Jaeger's trace ID and span ID, and is not copied onto the HTTP span or nested interactions.
+Static asset requests, such as `pdf.worker.min.mjs`, may contain only HTTP/security spans and therefore have no interaction UUID.
 
 Span display names are contextual, for example `Action <member>` or `Persist <entity type>`.
 Underlying Micrometer operation names are categories such as `causeway.member.action` and `causeway.jpa.persist`.
@@ -147,6 +150,42 @@ Prometheus metric names can differ from contextual span names, including dots tr
 The optional username and tenancy attributes are high-cardinality span metadata; enabling them does not add them as Micrometer metric labels.
 Grafana queries the internal Docker address `http://prometheus:9090`; Petclinic sends to `localhost:9090` from the host.
 
+=== Find requests and correlate interactions
+
+In Jaeger, select *All Span Names* and use the *Attributes* field for these searches separately:
+
+[source,text]
+----
+causeway.execution.mode=foreground
+----
+
+Copy the `causeway.interaction.id` from a root interaction, then replace the example UUID below with that value:
+
+[source,text]
+----
+causeway.interaction.id=12345678-1234-1234-1234-123456789abc
+----
+
+The execution mode is on the entry span and the UUID is on the root interaction span.
+Do not combine these into a same-span attribute query: no single span is required to carry both.
+The UUID can correlate command replay with the originating command; replay retains that command's effective identifier even if it replaces an ID allocated when the interaction opened.
+Sampling, export failures, time ranges and Jaeger's in-memory retention can limit matches; restarting Jaeger loses earlier traces.
+Neither attribute adds an interaction UUID or execution-mode label to Causeway's Micrometer meters.
+
+The attribute key can be changed without changing the values `foreground` and `background`.
+Stop Petclinic and restart, for example:
+
+[source,bash]
+----
+CAUSEWAY_EXECUTION_MODE_KEY=app.execution.mode \
+  ./viewers/webcomponents/sample-htmx-petclinic/run-with-telemetry.sh
+----
+
+This binds `causeway.execution.mode.key`; it also works with `--agent`.
+Inspect new HTTP spans for `app.execution.mode=foreground` and use that key in searches.
+The default key is replaced, not emitted alongside it; empty or whitespace-only keys fail startup.
+Unset any exported override before returning to the default examples.
+
 [[verification-scenarios]]
 === Select a verification scenario
 
@@ -256,11 +295,15 @@ import io.opentelemetry.api.GlobalOpenTelemetry;
 @Profile("agent")
 public class AgentObservationConfiguration {
     @Bean
-    ObservationRegistry agentObservationRegistry() {
+    io.micrometer.tracing.Tracer agentTracer() {
         // The attached agent owns the global SDK and exporter. Do not construct another SDK here.
         var currentContext = new OtelCurrentTraceContext();
-        var tracer = new OtelTracer(GlobalOpenTelemetry.getTracer("org.apache.causeway"), currentContext,
+        return new OtelTracer(GlobalOpenTelemetry.getTracer("org.apache.causeway"), currentContext,
                 event -> {}, new OtelBaggageManager(currentContext, List.of(), List.of()));
+    }
+
+    @Bean
+    ObservationRegistry agentObservationRegistry(io.micrometer.tracing.Tracer tracer) {
         var registry = ObservationRegistry.create();
         registry.observationConfig().observationHandler(new DefaultTracingObservationHandler(tracer));
         return registry;
@@ -268,7 +311,9 @@ public class AgentObservationConfiguration {
 }
 ----
 
-This registry uses the agent's global OpenTelemetry instance; it does not construct another SDK.
+The registry and entry-span classifier share this tracer bean, which uses the agent's global OpenTelemetry instance without constructing another SDK.
+A custom registry without a tracer bean can still create observations, but cannot classify agent-owned entry spans.
+When observations are active, supply a single tracer or designate a primary if multiple tracers exist.
 Add an agent-specific configuration that replaces tracing ownership while retaining Micrometer metrics:
 
 [source,yaml]
@@ -281,6 +326,7 @@ spring:
       - org.springframework.boot.micrometer.tracing.opentelemetry.autoconfigure.OpenTelemetryTracingAutoConfiguration
       - org.springframework.boot.micrometer.tracing.opentelemetry.autoconfigure.otlp.OtlpTracingAutoConfiguration
       - org.springframework.boot.micrometer.tracing.autoconfigure.MicrometerTracingAutoConfiguration
+      - org.springframework.boot.webmvc.autoconfigure.WebMvcObservationAutoConfiguration
 management:
   opentelemetry:
     map-environment-variables: false
@@ -293,6 +339,8 @@ causeway:
 ----
 
 The exclusions above match the Spring Boot 4.2.0-M1 baseline used by this checkout.
+The WebMVC exclusion lets the agent own the HTTP entry span without a second Boot HTTP observation around it; foreground classification then tags the agent's span.
+It also removes Boot's HTTP request observation meters in this configuration; JVM and Causeway Micrometer metrics remain available.
 Build the application with the bridge configuration included.
 Activate `observation,agent` alongside any other required application profiles and attach the agent to the application JVM:
 
@@ -315,6 +363,30 @@ java -javaagent:/absolute/path/to/opentelemetry-javaagent.jar \
 The shared `management.otlp.metrics.export.enabled=true` configuration therefore retains Micrometer's separate metrics pipeline.
 When returning to Boot-managed tracing, remove `-javaagent`, deactivate the `agent` profile, and unset the exported `OTEL_*` variables above so that Boot does not inherit the agent-only settings.
 
+== Verify background-command classification
+
+Petclinic's walkthrough exercises foreground requests; it does not configure a command-log scheduler.
+In an application using `RunBackgroundCommandsJob`, Causeway tags the current job entry span `causeway.execution.mode=background` before checking whether the job is paused.
+A paused run can therefore have that tag without any interaction beneath it.
+Boot mode requires application or library instrumentation to open and scope that entry span; the classifier itself creates no span, and safely does nothing without a current span.
+The Java agent can supply the entry span through its Quartz instrumentation, with the shared tracer bridge shown above.
+Other schedulers and arbitrary Quartz jobs are not automatically classified by this change.
+
+For repeatable verification without setting up a command store, first install the checkout's core, webapp and command-log artifacts, then run from the repository root:
+
+[source,bash]
+----
+mvn -f regressiontests/tracing-compatibility/pom.xml \
+  '-Dtest=MicrometerTracingCompatibilityTest#servletAndQuartzEntryClassificationAndCorrelation' test
+----
+
+This fixture starts a servlet container, a Quartz scheduler and its own temporary OTLP receiver.
+It checks Boot-provided and agent-instrumented entry spans with both default and custom keys, and root interaction correlation after command DTO identity replacement.
+Business collaborators are controlled test doubles; this is not a full persisted-command replay test.
+It asserts exported trace data directly and does not send traces to your local Jaeger.
+In an instrumented application, search Jaeger separately for `causeway.execution.mode=background` and for the replayed command's `causeway.interaction.id`, using *All Span Names*.
+The same sampling and retention limits apply as for foreground searches.
+
 == Inspect and stop the local services
 
 [source,bash]
diff --git a/antora/components/userguide/modules/ROOT/pages/observability.adoc b/antora/components/userguide/modules/ROOT/pages/observability.adoc
index c19ac1dd5a4..0061f3f0f77 100644
--- a/antora/components/userguide/modules/ROOT/pages/observability.adoc
+++ b/antora/components/userguide/modules/ROOT/pages/observability.adoc
@@ -38,13 +38,16 @@ Add the BOM-managed `io.micrometer:micrometer-tracing-bridge-otel` dependency fo
 @Profile("agent")
 class AgentObservationConfiguration {
     @Bean
-    ObservationRegistry agentObservationRegistry() {
+    io.micrometer.tracing.Tracer agentTracer() {
         var currentContext = new OtelCurrentTraceContext();
-        var tracer = new OtelTracer(
+        return new OtelTracer(
                 GlobalOpenTelemetry.getTracer("org.apache.causeway"),
                 currentContext,
                 event -> {},
                 new OtelBaggageManager(currentContext, List.of(), List.of()));
+    }
+    @Bean
+    ObservationRegistry agentObservationRegistry(io.micrometer.tracing.Tracer tracer) {
         var registry = ObservationRegistry.create();
         registry.observationConfig()
                 .observationHandler(new DefaultTracingObservationHandler(tracer));
@@ -68,7 +71,10 @@ spring:
       - org.springframework.boot.micrometer.tracing.opentelemetry.autoconfigure.OpenTelemetryTracingAutoConfiguration
       - org.springframework.boot.micrometer.tracing.opentelemetry.autoconfigure.otlp.OtlpTracingAutoConfiguration
       - org.springframework.boot.micrometer.tracing.autoconfigure.MicrometerTracingAutoConfiguration
+      - org.springframework.boot.webmvc.autoconfigure.WebMvcObservationAutoConfiguration
 management:
+  opentelemetry:
+    map-environment-variables: false
   tracing:
     export:
       enabled: false
@@ -91,10 +97,45 @@ export OTEL_LOGS_EXPORTER=none
 java -javaagent:/path/to/opentelemetry-javaagent.jar -jar application.jar
 ----
 
+The registry and entry-span classifier share the exposed Micrometer tracer.
+The WebMVC exclusion avoids a second Boot HTTP observation inside the agent's entry span; Boot HTTP request observation meters are consequently absent, while other Micrometer metrics remain available.
+Disabling environment-variable mapping prevents the agent's `OTEL_METRICS_EXPORTER=none` from disabling a separately configured Micrometer metrics exporter.
 Do not also construct an independent SDK/export pipeline in this configuration.
 The agent configuration is an explicit application choice; it does not replace the Boot-managed default.
 With the agent profile but without the `observation` profile, automatic agent spans remain active and Causeway observations remain inactive.
 
+== Execution classification and interaction correlation
+
+With `observation` active, Causeway tags existing servlet entry spans `causeway.execution.mode=foreground` across all paths, including static resources.
+The filter runs after Boot HTTP instrumentation and before nested security/viewer observations; in agent mode it tags the agent's current servlet span.
+Async/error redispatch does not repeat classification.
+`RunBackgroundCommandsJob` tags its current entry span `background` before its pause check.
+Boot needs application/library job instrumentation to provide that span; the Java agent's Quartz instrumentation can provide it.
+Without a tracer or current span the classifier does nothing, and it creates no scopes, spans or SDK.
+An inactive observation profile disables classification too.
+An active profile requires a single/primary Micrometer tracer; a registry-only custom setup cannot classify external entry spans.
+
+Override the attribute key, retaining the two fixed values:
+
+[source,yaml]
+----
+causeway:
+  execution:
+    mode:
+      key: app.execution.mode
+----
+
+The default is `causeway.execution.mode`; blank keys are rejected even with observations inactive.
+An override replaces the default key.
+Classification is local to the entry span and is not copied to every child or added as a metric label.
+
+`Causeway Root Interaction` carries high-cardinality `causeway.interaction.id` from the interaction's UUID.
+The value is refreshed before observation stop so that command DTO replay's replacement identifier is exported.
+It is independent of username/tenancy opt-ins, and is neither a metric label nor baggage nor an HTTP/descendant attribute.
+Nested/reused interaction semantics and observation names are unchanged.
+Search Jaeger with *All Span Names*, separately for execution mode and interaction ID: these attributes belong to different spans and a same-span conjunction need not match.
+An interaction UUID is distinct from the tracing system's trace ID; sampling, export failures and backend retention can prevent correlation searches finding every execution.
+
 == Duration filtering and failures
 
 Duration filtering is disabled by default, so short successful JPA parents remain available when their children are exported.
@@ -177,7 +218,8 @@ These are the tested versions, not a requirement to override the application's C
 The child-process fixture uses production interaction and action services and the JPA facet with mocked non-telemetry collaborators, a local JDK HTTP server, H2, and a local OTLP receiver.
 Its JPA backend is controlled; agent mode instruments real H2 JDBC automatically, while Boot mode explicitly wraps the SQL call in a fixture observation.
 It proves agent HTTP/framework/JPA/JDBC ancestry, one framework span per invocation, a failure followed by a successful request on the same worker, Boot-managed export, duration filtering, inactive-profile behavior and operation without an agent/exporter.
-It does not prove every servlet container or viewer integration.
+The additional entry-point fixture uses real Tomcat servlet and Quartz instrumentation with controlled business collaborators, verifying default/custom classification keys and effective replay UUIDs in both trace ownership modes.
+It does not prove every servlet container or viewer integration, nor a full persisted-command replay.
 
 From the repository root, after building the required main artifacts:
 
diff --git a/core/config/src/main/java/org/apache/causeway/core/config/observation/CausewayExecutionPolicy.java b/core/config/src/main/java/org/apache/causeway/core/config/observation/CausewayExecutionPolicy.java
new file mode 100644
index 00000000000..1959a5a6c91
--- /dev/null
+++ b/core/config/src/main/java/org/apache/causeway/core/config/observation/CausewayExecutionPolicy.java
@@ -0,0 +1,36 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.causeway.core.config.observation;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.boot.context.properties.bind.DefaultValue;
+
+/** Shared execution-mode attribute configuration, independent of trace ownership. */
+@ConfigurationProperties("causeway.execution")
+public record CausewayExecutionPolicy(@DefaultValue Mode mode) {
+    public static final String DEFAULT_KEY = "causeway.execution.mode";
+
+    public record Mode(@DefaultValue(DEFAULT_KEY) String key) {
+        public Mode {
+            if (key == null || key.isBlank()) {
+                throw new IllegalArgumentException("causeway.execution.mode.key must not be blank");
+            }
+        }
+    }
+}
diff --git a/core/config/src/main/java/org/apache/causeway/core/config/observation/CausewayObservationAutoConfiguration.java b/core/config/src/main/java/org/apache/causeway/core/config/observation/CausewayObservationAutoConfiguration.java
index 38d0be407fa..e177ee16159 100644
--- a/core/config/src/main/java/org/apache/causeway/core/config/observation/CausewayObservationAutoConfiguration.java
+++ b/core/config/src/main/java/org/apache/causeway/core/config/observation/CausewayObservationAutoConfiguration.java
@@ -33,6 +33,7 @@
 import org.apache.causeway.core.config.observation.CausewayObservationAutoConfiguration.DiscardedSpanExportingPredicate;
 
 import io.micrometer.observation.ObservationRegistry;
+import io.micrometer.tracing.Tracer;
 import io.micrometer.tracing.exporter.FinishedSpan;
 import io.micrometer.tracing.exporter.SpanExportingPredicate;
 
@@ -52,7 +53,7 @@
  * own observations or make registry injection ambiguous.
  */
 @AutoConfiguration
-@EnableConfigurationProperties(CausewayObservationPolicy.class)
+@EnableConfigurationProperties({CausewayObservationPolicy.class, CausewayExecutionPolicy.class})
 @ConditionalOnClass(ObservationRegistry.class)
 @Import({
 	DiscardedSpanExportingPredicate.class
@@ -85,6 +86,24 @@ public ObservationRegistry observationRegistry() {
 		return ObservationRegistry.create();
 	}
 
+    /**
+     * Tags the current entry span through the same tracer used by observation
+     * handlers. Inactive profiles and missing tracers are inert; an active
+     * configuration must supply a single or primary tracer to avoid tagging
+     * an unrelated tracing pipeline. This bean creates no spans or SDK.
+     */
+    @Bean
+    public CausewayTraceClassifier causewayTraceClassifier(
+            final ObjectProvider<Tracer> tracers,
+            final Environment environment,
+            final CausewayExecutionPolicy policy) {
+        // Do not resolve application tracers when inactive, including ambiguous ones.
+        var tracer = environment.acceptsProfiles(Profiles.of("observation"))
+                ? tracers.getIfAvailable(() -> Tracer.NOOP)
+                : Tracer.NOOP;
+        return new CausewayTraceClassifier(tracer, policy.mode().key());
+    }
+
     /**
      * Always supplies the integration used by framework consumers, including
      * when Causeway observations are disabled.
@@ -95,7 +114,7 @@ public ObservationRegistry observationRegistry() {
      * an arbitrary telemetry pipeline. When inactive, application registries are
      * not resolved or modified.
      */
-	@Bean
+    @Bean
     public CausewayObservationIntegration causewayObservationIntegration(
             final ObjectProvider<ObservationRegistry> registries,
             final Environment environment,
diff --git a/core/config/src/main/java/org/apache/causeway/core/config/observation/CausewayTraceClassifier.java b/core/config/src/main/java/org/apache/causeway/core/config/observation/CausewayTraceClassifier.java
new file mode 100644
index 00000000000..081cad62bd4
--- /dev/null
+++ b/core/config/src/main/java/org/apache/causeway/core/config/observation/CausewayTraceClassifier.java
@@ -0,0 +1,45 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.causeway.core.config.observation;
+
+import java.util.Objects;
+
+import io.micrometer.tracing.Tracer;
+
+/** Tags an existing entry span using the application's tracing bridge; never creates a span or scope. */
+public final class CausewayTraceClassifier {
+    public enum ExecutionMode {
+        FOREGROUND("foreground"), BACKGROUND("background");
+        private final String value;
+        ExecutionMode(String value) { this.value = value; }
+    }
+
+    private final Tracer tracer;
+    private final String key;
+
+    public CausewayTraceClassifier(Tracer tracer, String key) {
+        this.tracer = Objects.requireNonNull(tracer);
+        this.key = Objects.requireNonNull(key);
+    }
+
+    public void classifyCurrentSpan(ExecutionMode mode) {
+        var span = tracer.currentSpan();
+        if (span != null) span.tag(key, mode.value);
+    }
+}
diff --git a/core/config/src/test/java/org/apache/causeway/core/config/observation/CausewayTraceClassifierTest.java b/core/config/src/test/java/org/apache/causeway/core/config/observation/CausewayTraceClassifierTest.java
new file mode 100644
index 00000000000..999d11a1935
--- /dev/null
+++ b/core/config/src/test/java/org/apache/causeway/core/config/observation/CausewayTraceClassifierTest.java
@@ -0,0 +1,107 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.causeway.core.config.observation;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.context.annotation.AnnotationConfigApplicationContext;
+import org.springframework.core.env.MapPropertySource;
+
+import io.micrometer.tracing.Span;
+import io.micrometer.tracing.Tracer;
+
+class CausewayTraceClassifierTest {
+    @Test void bindsDefaultAndCustomKeyForBothModes() {
+        for (String key : new String[]{CausewayExecutionPolicy.DEFAULT_KEY, "app.execution"}) {
+            try (var context = context(true, key)) {
+                var tracer = mock(Tracer.class);
+                var span = mock(Span.class);
+                when(tracer.currentSpan()).thenReturn(span);
+                context.registerBean(Tracer.class, () -> tracer);
+                context.refresh();
+                assertEquals(key, context.getBean(CausewayExecutionPolicy.class).mode().key());
+                var classifier = context.getBean(CausewayTraceClassifier.class);
+                classifier.classifyCurrentSpan(CausewayTraceClassifier.ExecutionMode.FOREGROUND);
+                classifier.classifyCurrentSpan(CausewayTraceClassifier.ExecutionMode.BACKGROUND);
+                verify(span).tag(key, "foreground");
+                verify(span).tag(key, "background");
+                verifyNoMoreInteractions(span);
+            }
+        }
+    }
+    @Test void missingTracerOrSpanIsSafe() {
+        try (var context = context(true, null)) {
+            context.refresh();
+            assertEquals(CausewayExecutionPolicy.DEFAULT_KEY, context.getBean(CausewayExecutionPolicy.class).mode().key());
+            context.getBean(CausewayTraceClassifier.class).classifyCurrentSpan(CausewayTraceClassifier.ExecutionMode.FOREGROUND);
+        }
+        var tracer = mock(Tracer.class);
+        new CausewayTraceClassifier(tracer, "key").classifyCurrentSpan(CausewayTraceClassifier.ExecutionMode.BACKGROUND);
+        verify(tracer).currentSpan();
+        verifyNoMoreInteractions(tracer);
+    }
+    @Test void inactiveDoesNotResolveOrMutateApplicationTracers() {
+        try (var context = context(false, null)) {
+            var first = mock(Tracer.class);
+            var second = mock(Tracer.class);
+            context.registerBean("first", Tracer.class, () -> first);
+            context.registerBean("second", Tracer.class, () -> second);
+            context.refresh();
+            context.getBean(CausewayTraceClassifier.class).classifyCurrentSpan(CausewayTraceClassifier.ExecutionMode.FOREGROUND);
+            verifyNoInteractions(first, second);
+        }
+    }
+    @Test void primaryTracerIsUsedAndAmbiguityFails() {
+        for (boolean primary : new boolean[]{false, true}) {
+            try (var context = context(true, null)) {
+                var first = mock(Tracer.class);
+                var second = mock(Tracer.class);
+                context.registerBean("first", Tracer.class, () -> first, bd -> bd.setPrimary(primary));
+                context.registerBean("second", Tracer.class, () -> second);
+                if (!primary) {
+                    assertThrows(org.springframework.beans.factory.BeanCreationException.class, context::refresh);
+                } else {
+                    context.refresh();
+                    context.getBean(CausewayTraceClassifier.class).classifyCurrentSpan(CausewayTraceClassifier.ExecutionMode.FOREGROUND);
+                    verify(first).currentSpan();
+                    verifyNoInteractions(second);
+                }
+            }
+        }
+    }
+    @Test void blankKeysFailEvenWhenInactive() {
+        for (String key : new String[]{"", "   "}) {
+            try (var context = context(false, key)) {
+                assertThrows(org.springframework.beans.factory.BeanCreationException.class, context::refresh);
+            }
+        }
+    }
+    private AnnotationConfigApplicationContext context(boolean active, String key) {
+        var context = new AnnotationConfigApplicationContext();
+        if (active) context.getEnvironment().setActiveProfiles("observation");
+        if (key != null) context.getEnvironment().getPropertySources().addFirst(
+                new MapPropertySource("test", Map.of("causeway.execution.mode.key", key)));
+        context.register(CausewayObservationAutoConfiguration.class);
+        return context;
+    }
+}
diff --git a/core/metamodel/src/main/java/org/apache/causeway/core/metamodel/execution/InteractionLayerStack.java b/core/metamodel/src/main/java/org/apache/causeway/core/metamodel/execution/InteractionLayerStack.java
index f76e9ca9ffe..b1c88205631 100644
--- a/core/metamodel/src/main/java/org/apache/causeway/core/metamodel/execution/InteractionLayerStack.java
+++ b/core/metamodel/src/main/java/org/apache/causeway/core/metamodel/execution/InteractionLayerStack.java
@@ -53,9 +53,24 @@ public InteractionLayer push(
             final Observation observation) {
         var parent = currentLayer().orElse(null);
         var interactionCarrier = new InteractionCarrierDefault(executionContext);
+        var newLayer = new InteractionLayer(parent, interactionContext, interactionCarrier);
+        if (parent == null) {
+            observation.highCardinalityKeyValue("causeway.interaction.id",
+                    newLayer.interaction().getInteractionId().toString());
+        }
         var closure = new ObservationClosure().startAndOpenScope(observation);
-        var newLayer = new InteractionLayer(parent, interactionContext, interactionCarrier)
-                .addOnCloseListener(closure::close);
+        newLayer.addOnCloseListener(() -> {
+            try {
+                // Replay can replace the command's opening-time identifier. Bind to
+                // this root, not whichever nested layer happens to be current.
+                if (parent == null) {
+                    observation.highCardinalityKeyValue("causeway.interaction.id",
+                            newLayer.interaction().getInteractionId().toString());
+                }
+            } finally {
+                closure.close();
+            }
+        });
         newLayer.interaction().putAttribute(ObservationClosure.class, closure);
         set(newLayer);
         return newLayer;
diff --git a/core/runtimeservices/src/test/java/org/apache/causeway/core/runtimeservices/ia/InteractionServiceObservationTest.java b/core/runtimeservices/src/test/java/org/apache/causeway/core/runtimeservices/ia/InteractionServiceObservationTest.java
index 671c10aee45..b0662572ca2 100644
--- a/core/runtimeservices/src/test/java/org/apache/causeway/core/runtimeservices/ia/InteractionServiceObservationTest.java
+++ b/core/runtimeservices/src/test/java/org/apache/causeway/core/runtimeservices/ia/InteractionServiceObservationTest.java
@@ -65,6 +65,7 @@ class InteractionServiceObservationTest {
         assertSame(workFailure, assertThrows(AssertionError.class, () ->
                 fixture.service.run(InteractionContextFactory.testing(), () -> { throw workFailure; })));
         assertSame(workFailure, fixture.stopped.get(0).getError());
+        assertNotNull(fixture.stopped.get(0).getHighCardinalityKeyValue("causeway.interaction.id"));
         assertArrayEquals(new Throwable[]{cleanupFailure}, workFailure.getSuppressed());
         assertNull(fixture.registry.getCurrentObservation());
         assertFalse(fixture.service.isInInteraction());
@@ -107,6 +108,66 @@ class InteractionServiceObservationTest {
         assertNull(obs.getContext().getHighCardinalityKeyValue("causeway.user.name"));
         assertNull(obs.getContext().getHighCardinalityKeyValue("causeway.user.multiTenancyToken"));
     }
+    @Test void correlationTracksReplayAndOnlyTagsRoot() {
+        var fixture = new Fixture();
+        var replayId = UUID.fromString("12345678-1234-1234-1234-123456789abc");
+        fixture.service.run(InteractionContextFactory.testing(), () -> {
+            var root = fixture.registry.getCurrentObservation();
+            var interaction = fixture.service.currentInteraction().orElseThrow();
+            assertEquals(interaction.getInteractionId().toString(),
+                    root.getContext().getHighCardinalityKeyValue("causeway.interaction.id").getValue());
+            var dto = new org.apache.causeway.schema.cmd.v2.CommandDto();
+            dto.setInteractionId(replayId.toString());
+            // The exact identifier replacement used by CommandExecutorServiceDefault.
+            interaction.getCommand().updater().setCommandDtoAndIdentifier(dto);
+            fixture.service.run(InteractionContextFactory.testing("nested"), () -> {
+                assertNull(fixture.registry.getCurrentObservation().getContext()
+                        .getHighCardinalityKeyValue("causeway.interaction.id"));
+            });
+            assertSame(root, fixture.registry.getCurrentObservation());
+        });
+        var root = fixture.stopped.get(1);
+        assertEquals(replayId.toString(), root.getHighCardinalityKeyValue("causeway.interaction.id").getValue());
+        assertNull(root.getLowCardinalityKeyValue("causeway.interaction.id"));
+        fixture.service.run(InteractionContextFactory.testing(), () -> {});
+        assertNotEquals(replayId.toString(), fixture.stopped.get(2)
+                .getHighCardinalityKeyValue("causeway.interaction.id").getValue());
+        assertNull(fixture.registry.getCurrentObservation());
+    }
+
+    @Test void replayFailureStillExportsEffectiveIdentifier() {
+        var fixture = new Fixture();
+        var publisher = mock(org.apache.causeway.core.metamodel.services.publishing.CommandPublisher.class);
+        var failure = new IllegalStateException("publisher failure after replay identity replacement");
+        doThrow(failure).when(publisher).ready(any());
+        var executor = new org.apache.causeway.core.runtimeservices.command.CommandExecutorServiceDefault(
+                null, null, null, null, fixture.service, null, null, () -> publisher, null, null);
+        var dto = new org.apache.causeway.schema.cmd.v2.CommandDto();
+        dto.setInteractionId("12345678-1234-1234-1234-123456789abc");
+        assertSame(failure, assertThrows(IllegalStateException.class, () ->
+                fixture.service.run(InteractionContextFactory.testing(), () -> executor.executeCommand(dto))));
+        assertEquals(dto.getInteractionId(), fixture.stopped.get(0)
+                .getHighCardinalityKeyValue("causeway.interaction.id").getValue());
+        assertSame(failure, fixture.stopped.get(0).getError());
+        assertNull(fixture.registry.getCurrentObservation());
+        assertFalse(fixture.service.isInInteraction());
+    }
+
+    @Test void reusedLayerKeepsOneDeterministicRootIdentifier() {
+        var fixture = new Fixture();
+        var context = InteractionContextFactory.testing();
+        fixture.service.run(context, () -> {
+            var root = fixture.registry.getCurrentObservation();
+            fixture.service.run(context, () -> {
+                assertSame(root, fixture.registry.getCurrentObservation());
+                assertEquals(1, fixture.service.getInteractionLayerCount());
+            });
+        });
+        assertEquals(1, fixture.stopped.size());
+        assertEquals(new UUID(0, 1).toString(), fixture.stopped.get(0)
+                .getHighCardinalityKeyValue("causeway.interaction.id").getValue());
+    }
+
     private static class Fixture {
         final ObservationRegistry registry = ObservationRegistry.create();
         final ArrayList<Observation.Context> stopped = new ArrayList<>();
@@ -123,7 +184,8 @@ private static class Fixture {
             when(beanFactory.getRegisteredScope(InteractionScopeBeanFactoryPostProcessor.SCOPE_NAME)).thenReturn(scope);
             when(transactions.currentTransactionState()).thenReturn(TransactionState.MUST_ABORT);
             var executionContext = mock(ExecutionContext.class, RETURNS_DEEP_STUBS);
-            when(executionContext.idGenerator().interactionId()).thenAnswer(__ -> UUID.randomUUID());
+            var sequence = new java.util.concurrent.atomic.AtomicLong();
+            when(executionContext.idGenerator().interactionId()).thenAnswer(__ -> new UUID(0, sequence.incrementAndGet()));
             service = new InteractionServiceDefault(beanFactory, mock(ServiceInjector.class), transactions,
                     mock(ClockService.class), () -> null, executionContext, new CausewayObservationIntegration(registry, policy));
         }
diff --git a/core/webapp/src/main/java/module-info.java b/core/webapp/src/main/java/module-info.java
index 4f619044cb6..fd8f34c5e7d 100644
--- a/core/webapp/src/main/java/module-info.java
+++ b/core/webapp/src/main/java/module-info.java
@@ -22,6 +22,7 @@
     exports org.apache.causeway.core.webapp.health;
     exports org.apache.causeway.core.webapp.keyvaluestore;
     exports org.apache.causeway.core.webapp.modules;
+    exports org.apache.causeway.core.webapp.modules.observation;
     exports org.apache.causeway.core.webapp.modules.logonlog;
     exports org.apache.causeway.core.webapp.modules.templresources;
     exports org.apache.causeway.core.webapp.routing;
diff --git a/core/webapp/src/main/java/org/apache/causeway/core/webapp/CausewayModuleCoreWebapp.java b/core/webapp/src/main/java/org/apache/causeway/core/webapp/CausewayModuleCoreWebapp.java
index 83ec20a6e11..16b06663acb 100644
--- a/core/webapp/src/main/java/org/apache/causeway/core/webapp/CausewayModuleCoreWebapp.java
+++ b/core/webapp/src/main/java/org/apache/causeway/core/webapp/CausewayModuleCoreWebapp.java
@@ -52,6 +52,8 @@
         WebModuleLogOnExceptionLogger.class,
         WebModuleTemplateResources.class,
 
+        org.apache.causeway.core.webapp.modules.observation.WebObservationConfiguration.class,
+
         // @Component's
 
         HealthIndicatorUsingHealthCheckService.class,
diff --git a/core/webapp/src/main/java/org/apache/causeway/core/webapp/modules/observation/CausewayForegroundTraceFilter.java b/core/webapp/src/main/java/org/apache/causeway/core/webapp/modules/observation/CausewayForegroundTraceFilter.java
new file mode 100644
index 00000000000..84efeb7ae47
--- /dev/null
+++ b/core/webapp/src/main/java/org/apache/causeway/core/webapp/modules/observation/CausewayForegroundTraceFilter.java
@@ -0,0 +1,49 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.causeway.core.webapp.modules.observation;
+
+import java.io.IOException;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import org.apache.causeway.core.config.observation.CausewayTraceClassifier;
+import org.apache.causeway.core.config.observation.CausewayTraceClassifier.ExecutionMode;
+
+/** Classifies the HTTP entry, not the security or viewer observations opened downstream. */
+public final class CausewayForegroundTraceFilter extends OncePerRequestFilter {
+    private final CausewayTraceClassifier classifier;
+
+    public CausewayForegroundTraceFilter(CausewayTraceClassifier classifier) {
+        this.classifier = classifier;
+    }
+
+    @Override
+    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
+            FilterChain chain) throws ServletException, IOException {
+        classifier.classifyCurrentSpan(ExecutionMode.FOREGROUND);
+        chain.doFilter(request, response);
+    }
+    // OncePerRequestFilter skips async/error redispatch: the entry was already
+    // classified, and the current span during redispatch might be a child.
+}
diff --git a/core/webapp/src/main/java/org/apache/causeway/core/webapp/modules/observation/WebObservationConfiguration.java b/core/webapp/src/main/java/org/apache/causeway/core/webapp/modules/observation/WebObservationConfiguration.java
new file mode 100644
index 00000000000..e01a6ec8574
--- /dev/null
+++ b/core/webapp/src/main/java/org/apache/causeway/core/webapp/modules/observation/WebObservationConfiguration.java
@@ -0,0 +1,47 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.causeway.core.webapp.modules.observation;
+
+import jakarta.servlet.DispatcherType;
+
+import org.springframework.boot.web.servlet.FilterRegistrationBean;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Profile;
+import org.springframework.core.Ordered;
+
+import org.apache.causeway.core.config.observation.CausewayTraceClassifier;
+
+@Configuration(proxyBeanMethods = false)
+@Profile("observation")
+public class WebObservationConfiguration {
+    @Bean
+    public FilterRegistrationBean<CausewayForegroundTraceFilter> causewayForegroundTraceFilter(
+            CausewayTraceClassifier classifier) {
+        var registration = new FilterRegistrationBean<>(new CausewayForegroundTraceFilter(classifier));
+        registration.setName("CausewayForegroundTraceFilter");
+        registration.addUrlPatterns("/*");
+        // Boot's HTTP observation filter runs at HIGHEST_PRECEDENCE + 1.
+        // Agent servlet instrumentation surrounds the whole filter chain.
+        registration.setOrder(Ordered.HIGHEST_PRECEDENCE + 2);
+        registration.setDispatcherTypes(DispatcherType.REQUEST);
+        registration.setAsyncSupported(true);
+        return registration;
+    }
+}
diff --git a/core/webapp/src/test/java/org/apache/causeway/core/webapp/modules/observation/CausewayForegroundTraceFilterTest.java b/core/webapp/src/test/java/org/apache/causeway/core/webapp/modules/observation/CausewayForegroundTraceFilterTest.java
new file mode 100644
index 00000000000..a0201d2c549
--- /dev/null
+++ b/core/webapp/src/test/java/org/apache/causeway/core/webapp/modules/observation/CausewayForegroundTraceFilterTest.java
@@ -0,0 +1,70 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.causeway.core.webapp.modules.observation;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import jakarta.servlet.DispatcherType;
+import jakarta.servlet.ServletException;
+import org.junit.jupiter.api.Test;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+
+import org.apache.causeway.core.config.observation.CausewayTraceClassifier;
+import org.apache.causeway.core.config.observation.CausewayTraceClassifier.ExecutionMode;
+
+class CausewayForegroundTraceFilterTest {
+    @Test void classifiesEveryEntryPathBeforeDownstreamWork() throws Exception {
+        var classifier = mock(CausewayTraceClassifier.class);
+        var filter = new CausewayForegroundTraceFilter(classifier);
+        for (String path : new String[]{"/wicket/", "/graphql", "/restful/", "/static/test.js"}) {
+            clearInvocations(classifier);
+            var request = new MockHttpServletRequest("GET", path);
+            filter.doFilter(request, new MockHttpServletResponse(), (req, res) -> {
+                verify(classifier).classifyCurrentSpan(ExecutionMode.FOREGROUND);
+                // A nested dispatch must not classify a child span.
+                filter.doFilter(req, res, (nestedReq, nestedRes) -> {});
+            });
+            verifyNoMoreInteractions(classifier);
+        }
+    }
+    @Test void propagatesFailureAndCleansRequestMarker() throws Exception {
+        var classifier = mock(CausewayTraceClassifier.class);
+        var filter = new CausewayForegroundTraceFilter(classifier);
+        var request = new MockHttpServletRequest();
+        var response = new MockHttpServletResponse();
+        var failure = new ServletException("downstream");
+        assertSame(failure, assertThrows(ServletException.class,
+                () -> filter.doFilter(request, response, (req, res) -> { throw failure; })));
+        filter.doFilter(request, response, (req, res) -> {});
+        verify(classifier, times(2)).classifyCurrentSpan(ExecutionMode.FOREGROUND);
+    }
+    @Test void skipsAsyncAndErrorRedispatch() throws Exception {
+        var classifier = mock(CausewayTraceClassifier.class);
+        var filter = new CausewayForegroundTraceFilter(classifier);
+        for (var type : new DispatcherType[]{DispatcherType.ASYNC, DispatcherType.ERROR}) {
+            var request = new MockHttpServletRequest();
+            request.setDispatcherType(type);
+            if (type == DispatcherType.ERROR) request.setAttribute("jakarta.servlet.error.request_uri", "/failed");
+            filter.doFilter(request, new MockHttpServletResponse(), (req, res) -> {});
+        }
+        verifyNoInteractions(classifier);
+    }
+}
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/job/RunBackgroundCommandsJob.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/job/RunBackgroundCommandsJob.java
index d9282bd084d..ec24b4f6a49 100644
--- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/job/RunBackgroundCommandsJob.java
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/job/RunBackgroundCommandsJob.java
@@ -44,6 +44,8 @@
 import org.apache.causeway.applib.util.schema.CommandDtoUtils;
 import org.apache.causeway.commons.functional.Try;
 import org.apache.causeway.core.config.CausewayConfiguration;
+import org.apache.causeway.core.config.observation.CausewayTraceClassifier;
+import org.apache.causeway.core.config.observation.CausewayTraceClassifier.ExecutionMode;
 import org.apache.causeway.core.metamodel.services.deadlock.DeadlockRecognizer;
 import org.apache.causeway.core.runtimeservices.transaction.TransactionServiceSpring;
 import org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntry;
@@ -89,8 +91,13 @@ public class RunBackgroundCommandsJob implements Job {
     @Inject List<RunBackgroundCommandsJobListener> listeners;
     @Autowired private CausewayConfiguration causewayConfiguration;
 
+    @Inject CausewayTraceClassifier traceClassifier;
+
     @Override
     public void execute(final JobExecutionContext quartzContext) {
+        // Instrumentation (for example the Java agent) owns the job entry span.
+        // Classify even paused runs, but never manufacture a span when none exists.
+        traceClassifier.classifyCurrentSpan(ExecutionMode.BACKGROUND);
 
         if (backgroundCommandsJobControl.isPaused()) {
             log.debug("currently paused");
diff --git a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/job/RunBackgroundCommandsJobTraceClassificationTest.java b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/job/RunBackgroundCommandsJobTraceClassificationTest.java
new file mode 100644
index 00000000000..a8a84a4f8ad
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/job/RunBackgroundCommandsJobTraceClassificationTest.java
@@ -0,0 +1,65 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.causeway.extensions.commandlog.applib.job;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import org.junit.jupiter.api.Test;
+import org.apache.causeway.core.config.observation.CausewayTraceClassifier;
+import org.apache.causeway.core.config.observation.CausewayTraceClassifier.ExecutionMode;
+
+class RunBackgroundCommandsJobTraceClassificationTest {
+    @Test void classifiesBeforePauseAndPreservesFailures() {
+        var job = new RunBackgroundCommandsJob();
+        job.traceClassifier = mock(CausewayTraceClassifier.class);
+        job.backgroundCommandsJobControl = mock(BackgroundCommandsJobControl.class);
+        when(job.backgroundCommandsJobControl.isPaused()).thenAnswer(invocation -> {
+            verify(job.traceClassifier).classifyCurrentSpan(ExecutionMode.BACKGROUND);
+            return true;
+        });
+        job.execute(null);
+        reset(job.backgroundCommandsJobControl, job.traceClassifier);
+        var failure = new IllegalStateException("job failure");
+        when(job.backgroundCommandsJobControl.isPaused()).thenThrow(failure);
+        assertSame(failure, assertThrows(IllegalStateException.class, () -> job.execute(null)));
+        verify(job.traceClassifier).classifyCurrentSpan(ExecutionMode.BACKGROUND);
+    }
+    @Test void noCurrentSpanAllowsSuccessfulRunAndPreservesPendingLookupFailure() {
+        var job = new RunBackgroundCommandsJob();
+        job.traceClassifier = new CausewayTraceClassifier(io.micrometer.tracing.Tracer.NOOP, "custom.mode");
+        job.backgroundCommandsJobControl = mock(BackgroundCommandsJobControl.class);
+        job.interactionService = mock(org.apache.causeway.applib.services.iactn.InteractionService.class);
+        job.listeners = java.util.List.of();
+        when(job.interactionService.callAndCatch(any(), any())).thenReturn(
+                org.apache.causeway.commons.functional.Try.success(java.util.List.of()));
+        assertDoesNotThrow(() -> job.execute(null));
+        var failure = new IllegalStateException("pending lookup failed");
+        when(job.interactionService.callAndCatch(any(), any())).thenReturn(
+                org.apache.causeway.commons.functional.Try.failure(failure));
+        assertSame(failure, assertThrows(IllegalStateException.class, () -> job.execute(null)));
+    }
+    @Test void noCurrentSpanDoesNotPreventPausedJobExecution() {
+        var job = new RunBackgroundCommandsJob();
+        job.traceClassifier = new CausewayTraceClassifier(io.micrometer.tracing.Tracer.NOOP, "custom.mode");
+        job.backgroundCommandsJobControl = mock(BackgroundCommandsJobControl.class);
+        when(job.backgroundCommandsJobControl.isPaused()).thenReturn(true);
+        assertDoesNotThrow(() -> job.execute(null));
+    }
+}
diff --git a/openspec/changes/classify-and-correlate-observations/design.md b/openspec/changes/classify-and-correlate-observations/design.md
index 99890d72342..545e42d3992 100644
--- a/openspec/changes/classify-and-correlate-observations/design.md
+++ b/openspec/changes/classify-and-correlate-observations/design.md
@@ -1,6 +1,6 @@
 ## Context
 
-Baseline inspected: `6f10ac34cac` on `CAUSEWAY-4096`, after the archived observation-policy change. No remote refresh or new implementation branch is implied by this proposal. Main uses Boot 4.2.0-M1 and supports an explicit agent-owned bridge; core/config already depends on Micrometer Tracing, but not the OpenTelemetry API.
+Baseline inspected: `6f10ac34cac` on `CAUSEWAY-4096`, after the archived observation-policy change. Implementation proceeds on the user-created `CAUSEWAY-4068-v4` branch under CAUSEWAY-4068. Main uses Boot 4.2.0-M1 and supports an explicit agent-owned bridge; core/config already depends on Micrometer Tracing, but not the OpenTelemetry API.
 
 Maintenance sources:
 - `024bd33128d`: current-span foreground filter and background-command job classification.
@@ -27,7 +27,7 @@ Alternative rejected: directly copying maintenance's `Span.current()` helper int
 
 ### Register classification inside HTTP instrumentation and before application/security work
 
-Use a Jakarta servlet filter across `/*`, ordered after Boot's HTTP observation filter and before nested security/viewer observations. In agent mode the agent's servlet entry span must already be current. Verify this ordering with actual exported HTTP spans, not just mocked classifier calls. Do not copy maintenance's servlet priority blindly or import its semantic trace scope.
+Use a Jakarta servlet filter across `/*`, ordered after Boot's HTTP observation filter and before nested security/viewer observations. In agent mode the agent's servlet entry span must already be current. Boot 4.2.0-M1 registers its HTTP observation filter at `HIGHEST_PRECEDENCE + 1`; register classification at `+ 2`. In the explicit agent configuration exclude `WebMvcObservationAutoConfiguration` so classification targets the actual agent HTTP entry instead of a duplicate Boot observati [...]
 
 Foreground includes Wicket, GraphQL/HTMX, REST and static resources: it denotes an HTTP entry, not necessarily a human action. Use request-dispatch semantics that prevent async/error redispatch from retagging arbitrary child spans. Classification creates no scopes and no spans; request exceptions propagate unchanged. No universal propagation to children is promised.
 
@@ -39,11 +39,11 @@ Boot does not automatically guarantee a Quartz entry span. Without one, the clas
 
 ### Keep the maintenance configuration contract, using main's immutable conventions
 
-Expose `causeway.execution.mode.key`, default `causeway.execution.mode`, as typed configuration following main's record/default conventions. Reject empty and whitespace-only values at binding/startup. Retain the exact values `foreground` and `background`; an override replaces the emitted default key rather than adding aliases. Do not add a second observation-policy switch for the same setting. Audit `Execution`/`Mode` type-name collisions instead of copying maintenance's mutable configur [...]
+Expose `causeway.execution.mode.key`, default `causeway.execution.mode`, as typed configuration following main's record/default conventions. Use a separate `CausewayExecutionPolicy` record registered alongside `CausewayObservationPolicy`, avoiding collisions with the root configuration's execution types; generated metadata advertises the key and default. Reject empty and whitespace-only values at binding/startup. Retain the exact values `foreground` and `background`; an override replaces [...]
 
 ### Bind correlation to the root interaction lifecycle
 
-Add `causeway.interaction.id` only to the existing depth-zero `Causeway Root Interaction` observation, as high-cardinality metadata. Source it from that interaction's `getInteractionId()`; do not allocate an ID for telemetry or rename observations. Leave main's existing nested/reused-layer identity semantics unchanged, and do not assume maintenance's shared-ID stack model applies.
+Add `causeway.interaction.id` only to the existing depth-zero `Causeway Root Interaction` observation, as high-cardinality metadata. Source it from that interaction's `getInteractionId()`; do not allocate an ID for telemetry or rename observations. Leave main's existing nested/reused-layer identity semantics unchanged: child carriers share the command, but only the root observation receives this attribute.
 
 Attach the initial value once the root carrier exists, then refresh the same attribute from that root interaction immediately before its observation stops. Bind the supplier to that root instance, not to whichever layer is current at cleanup. This handles replay's identifier replacement, preserves the attribute on failures, and prevents nested interactions from overwriting the outer root. If an equivalent existing lifecycle hook provides this guarantee, reuse it rather than introducing s [...]
 
@@ -66,4 +66,4 @@ Update the M3 how-to incrementally: default foreground attributes on the HTTP sp
 
 ## Open Questions
 
-No product decision blocks implementation. Assign the main-side Jira ticket/branch before implementation; CAUSEWAY-3975/4068 are provenance. Implementation must prove filter ordering against the pinned Boot baseline and check how agent Quartz instrumentation exposes the job entry span. If either requires a contract change, update this plan before expanding scope.
+No product decision blocks implementation. The user selected branch `CAUSEWAY-4068-v4` under CAUSEWAY-4068. Export tests cover the pinned Boot filter ordering and real agent Quartz instrumentation; the background fixture uses production job/command execution with controlled persistence and domain dispatch, rather than claiming full stored-command integration.
diff --git a/openspec/changes/classify-and-correlate-observations/proposal.md b/openspec/changes/classify-and-correlate-observations/proposal.md
index ec430e701ce..d246142fd2d 100644
--- a/openspec/changes/classify-and-correlate-observations/proposal.md
+++ b/openspec/changes/classify-and-correlate-observations/proposal.md
@@ -25,7 +25,7 @@ None. Existing foundation and observation-policy requirements remain in force, i
 
 Main modules: `core/config`, `core/webapp`, `core/runtimeservices` and potentially `core/metamodel` for interaction lifecycle binding; `extensions/core/commandlog/applib` for the job entry point; tracing compatibility tests; sample agent bridge and observability documentation. No new SDK/exporter, production persistence schema, dependency-version upgrade or span-name migration is intended.
 
-Source provenance: CAUSEWAY-3975 commit `024bd33128d`, CAUSEWAY-4068 commits `0325232a3c7` and `3083a51eabd`. This is a main-side adaptation, not a cherry-pick. A new Jira ticket/implementation branch has not been assigned; these ticket numbers record provenance, not a newly created ticket.
+Source provenance: CAUSEWAY-3975 commit `024bd33128d`, CAUSEWAY-4068 commits `0325232a3c7` and `3083a51eabd`. This is a main-side adaptation, not a cherry-pick. The main-side implementation uses the user-created `CAUSEWAY-4068-v4` branch under CAUSEWAY-4068.
 
 ## Non-goals
 
diff --git a/openspec/changes/classify-and-correlate-observations/tasks.md b/openspec/changes/classify-and-correlate-observations/tasks.md
index 0e871140499..d71262fb776 100644
--- a/openspec/changes/classify-and-correlate-observations/tasks.md
+++ b/openspec/changes/classify-and-correlate-observations/tasks.md
@@ -1,26 +1,26 @@
 ## 1. Configuration and tracing bridge
 
-- [ ] 1.1 Add typed `causeway.execution.mode.key` with the maintenance default using main's configuration conventions; verify metadata and binding tests for default, override and blank rejection.
-- [ ] 1.2 Implement current-span classification using the single/primary Micrometer tracer, gated by Causeway observation activation; test foreground/background values, missing tracer/span, inactive profile, ambiguity and absence of duplicate keys after override.
-- [ ] 1.3 Expose and reuse the existing tracer in the sample/fixture agent registry bridge; verify no second SDK, exporter or tracing handler is created.
+- [x] 1.1 Add typed `causeway.execution.mode.key` with the maintenance default using main's configuration conventions; verify metadata and binding tests for default, override and blank rejection.
+- [x] 1.2 Implement current-span classification using the single/primary Micrometer tracer, gated by Causeway observation activation; test foreground/background values, missing tracer/span, inactive profile, ambiguity and absence of duplicate keys after override.
+- [x] 1.3 Expose and reuse the existing tracer in the sample/fixture agent registry bridge; verify no second SDK, exporter or tracing handler is created.
 
 ## 2. Entry-point instrumentation
 
-- [ ] 2.1 Register a Jakarta foreground filter after HTTP tracing and before security/viewer observations; test Wicket, GraphQL/HTMX, REST and static paths, request failure, async/error dispatch and context restoration.
-- [ ] 2.2 Classify the existing span at command-log job entry before its paused check; test paused, successful and failed jobs, configured key and missing entry span without changing retries or transaction behavior.
+- [x] 2.1 Register a Jakarta foreground filter after HTTP tracing and before security/viewer observations; test Wicket, GraphQL/HTMX, REST and static paths, request failure, async/error dispatch and context restoration.
+- [x] 2.2 Classify the existing span at command-log job entry before its paused check; test paused, successful and failed jobs, configured key and missing entry span without changing retries or transaction behavior.
 
 ## 3. Root interaction correlation
 
-- [ ] 3.1 Bind high-cardinality `causeway.interaction.id` to the root interaction instance and refresh its effective UUID before observation stop; preserve existing root/nested names, identity policies and cleanup guarantees.
-- [ ] 3.2 Test deterministic ordinary UUIDs, nested/reused layers, real command DTO identifier replacement, failed work, cleanup failure and subsequent interactions on the same thread; verify no ID metric label or HTTP/descendant copy is introduced.
+- [x] 3.1 Bind high-cardinality `causeway.interaction.id` to the root interaction instance and refresh its effective UUID before observation stop; preserve existing root/nested names, identity policies and cleanup guarantees.
+- [x] 3.2 Test deterministic ordinary UUIDs, nested/reused layers, real command DTO identifier replacement, failed work, cleanup failure and subsequent interactions on the same thread; verify no ID metric label or HTTP/descendant copy is introduced.
 
 ## 4. Exported compatibility evidence
 
-- [ ] 4.1 Extend the export fixture with actual servlet entry-span classification in Boot and agent modes, default/custom keys, root interaction correlation, failure followed by success and unchanged ancestry/no duplicate framework spans.
-- [ ] 4.2 Exercise command-log job classification with a supplied Boot entry span and agent Quartz instrumentation; assert effective replay UUID correlation and safe execution with no span.
-- [ ] 4.3 Run focused config/webapp/interaction/command-log tests, existing Wicket lifecycle regressions and the tracing fixture on main's supported toolchain; record exact commands, versions, results and remaining limitations in validation.md.
+- [x] 4.1 Extend the export fixture with actual servlet entry-span classification in Boot and agent modes, default/custom keys, root interaction correlation, failure followed by success and unchanged ancestry/no duplicate framework spans.
+- [x] 4.2 Exercise command-log job classification with a supplied Boot entry span and agent Quartz instrumentation; assert effective replay UUID correlation and safe execution with no span.
+- [x] 4.3 Run focused config/webapp/interaction/command-log tests, existing Wicket lifecycle regressions and the tracing fixture on main's supported toolchain; record exact commands, versions, results and remaining limitations in validation.md.
 
 ## 5. Operator guidance
 
-- [ ] 5.1 Extend the existing observability guidance and M3 how-to with entry-span classification, root-span UUID lookup, a custom-key example and separate Jaeger searches; preserve default identity/filtering explanations and the current launcher structure.
-- [ ] 5.2 Provide a reproducible background-job verification example or fixture command with its tracing prerequisites; distinguish it from Petclinic's foreground walkthrough, document retention/sampling limits, and validate documentation rendering and configuration examples.
+- [x] 5.1 Extend the existing observability guidance and M3 how-to with entry-span classification, root-span UUID lookup, a custom-key example and separate Jaeger searches; preserve default identity/filtering explanations and the current launcher structure.
+- [x] 5.2 Provide a reproducible background-job verification example or fixture command with its tracing prerequisites; distinguish it from Petclinic's foreground walkthrough, document retention/sampling limits, and validate documentation rendering and configuration examples.
diff --git a/openspec/changes/classify-and-correlate-observations/validation.md b/openspec/changes/classify-and-correlate-observations/validation.md
new file mode 100644
index 00000000000..ba2e62dc771
--- /dev/null
+++ b/openspec/changes/classify-and-correlate-observations/validation.md
@@ -0,0 +1,67 @@
+# Classification and correlation validation
+
+Validated 2026-10-06 on `CAUSEWAY-4068-v4` (CAUSEWAY-4068). Planning checkpoint: `94a6d4011cb`. Implementation is recorded in the commit containing this validation report; archival remains pending.
+
+## Toolchain
+
+- Eclipse Temurin Java 25.0.4, Maven 3.9.13, macOS aarch64.
+- Checkout BOM: Spring Boot 4.2.0-M1, Micrometer Observation 1.18.0-M1, Micrometer Tracing 1.8.0-M1, OpenTelemetry API/SDK 1.64.0.
+- OpenTelemetry Java agent 2.31.1; servlet fixture uses Tomcat 11.0.24.
+- All Maven commands below used `JAVA_HOME=/Users/danhaywood/.sdkman/candidates/java/25.0.4-tem`. Dependencies were available offline. Socket tests ran with permission to bind local ports.
+
+## Commands and results
+
+```sh
+mvn -o -pl core/webapp,core/runtimeservices,extensions/core/commandlog/applib,viewers/wicket/viewer -am \
+  -Dtest=CausewayTraceClassifierTest,CausewayObservationAutoConfigurationTest,InteractionServiceObservationTest,CausewayForegroundTraceFilterTest,RunBackgroundCommandsJobTraceClassificationTest,TelemetryRequestCycleTest \
+  -Dsurefire.failIfNoSpecifiedTests=false -Dmaven.source.skip=true -Dmaven.javadoc.skip=true install
+```
+
+Passed: 16 configuration tests, 7 interaction tests, 3 servlet-filter tests, 2 Wicket lifecycle tests and the original 2 job tests. After adding the third job test, reran that module:
+
+```sh
+mvn -o -f extensions/core/commandlog/applib/pom.xml \
+  -Dtest=RunBackgroundCommandsJobTraceClassificationTest test
+```
+
+Passed: all 3 job tests. Total distinct focused cases: **31**.
+
+```sh
+mvn -o -f regressiontests/tracing-compatibility/pom.xml test
+```
+
+Passed: **9** tests (7 exported compatibility tests and 2 JPA policy tests). The new `servletAndQuartzEntryClassificationAndCorrelation` case launches four child JVMs: Boot and agent, each with default and custom attribute keys. Each exports four HTTP requests (failure followed by success on a single servlet worker) and a real Quartz invocation of the production background job. Assertions cover:
+
+- Foreground on each HTTP entry, never its security-like or interaction children; exactly one framework root per request and correct ancestry.
+- Background on the supplied Boot job entry or actual agent Quartz span, with no additional classifier-created span.
+- Production command executor replacement of interaction identity from a command DTO; final replay UUID on the root interaction.
+- Six root interactions: four requests, pending-command lookup and replay. Five distinct traces, with the two job interactions sharing the job trace.
+- Root-only correlation, valid UUIDs, unchanged identity omission, custom key replacing the default, and one failed request interaction.
+- Existing agent/Boot/JPA export, filtering, inactive-profile and no-exporter checks remain passing.
+
+```sh
+mvn -o -f viewers/webcomponents/pom.xml -pl sample-htmx-petclinic -am \
+  -Pobservation -DskipTests -Dmaven.source.skip=true -Dmaven.javadoc.skip=true package
+```
+
+Passed: Petclinic and its webcomponents reactor package with the shared agent tracer. Java tests were skipped for this packaging check; the reactor's configured JavaScript checks also ran successfully.
+
+Generated `core/config/target/classes/META-INF/spring-configuration-metadata.json` contains `causeway.execution.mode.key`, type String, default `causeway.execution.mode`. Binding tests cover default, override, empty/whitespace rejection, inactive profile, missing tracer/span and single/primary/ambiguous tracers.
+
+Both changed AsciiDoc guides were converted to HTML with the installed Antora Asciidoctor processor. All 7 YAML examples were parsed with `js-yaml`. The documented agent configuration mirrors the compiled sample. `git diff --check` and `openspec validate classify-and-correlate-observations --strict` passed.
+
+## Implementation findings
+
+Boot's `WebMvcObservationAutoConfiguration` registers its filter at `HIGHEST_PRECEDENCE + 1`; the new filter uses `+ 2`. The agent configuration excludes that Boot auto-configuration, preventing a second HTTP observation from becoming the classifier's current span. JVM and Causeway Micrometer metrics remain available; Boot HTTP request observation meters are consequently absent in this agent configuration. Both guides explain the trade-off.
+
+`CausewayExecutionPolicy` follows the existing standalone observation-policy record pattern. Root correlation is refreshed immediately before observation closure, including when replay or cleanup fails. Tests include deterministic ordinary IDs, nested/reused layers, a real command-executor publishing failure after identity replacement, transaction cleanup failure and subsequent work without leaked context.
+
+The first new servlet fixture run exposed an import-order issue in the fixture: its fallback registry was registered before the external agent registry configuration. Importing the application bridge before Causeway's configuration resolved this; all final runs pass. The existing and sample bridge code expose the same tracer used by their observation handler, without constructing an SDK.
+
+## Scope and limitations
+
+- The servlet fixture uses real Tomcat instrumentation with representative Wicket, GraphQL, REST and static paths and controlled business work; it does not boot all real viewers. Existing Wicket lifecycle tests also pass.
+- The job fixture uses the production job and command executor, but mocks command persistence and domain dispatch. It does not prove full persisted-command execution, deadlock retries or every Quartz deployment. Those algorithms were not changed.
+- Async/error redispatch behavior is covered by filter tests, not a full asynchronous servlet export scenario.
+- Petclinic was packaged; live browser, Jaeger and Prometheus/Grafana checks were not repeated in this run. The M3 guide provides the manual steps and distinguishes the fixture's private OTLP collector from local Jaeger.
+- A full repository regression build was not run. No dependency versions, domain schemas, telemetry-service scripts or launcher selection flags changed.
diff --git a/openspec/planned-changes/otel-forward-port-roadmap.md b/openspec/planned-changes/otel-forward-port-roadmap.md
index 6e5fb218237..b56fe9aff15 100644
--- a/openspec/planned-changes/otel-forward-port-roadmap.md
+++ b/openspec/planned-changes/otel-forward-port-roadmap.md
@@ -12,7 +12,7 @@ Main already contains CAUSEWAY-3975, including instrumentation absent from the s
 |---|---|---|---|
 | 1 | 3975: foundation reconciliation | **Archived:** [completed change](../changes/archive/2026-09-27-reconcile-main-observation-foundation/proposal.md). Lifecycle cleanup, activation/registry selection, Boot/agent compatibility evidence, threshold behavior, preservation of main instrumentation. | `9b8b66d5b7b`, `e6752f882b5`; compare main's original 3975 implementation |
 | 1b | CAUSEWAY-4096: standardize observation names, metadata and duration-filtering policy | **Archived (2026-10-05):** [standardize observation policy](../changes/archive/2026-10-05-standardize-observation-policy/proposal.md) on branch `CAUSEWAY-4096`. Follow-up to 3975: stable operation names vs contextual names; review bookmark/query-description names, identity attributes, hard-coded duration threshold and missing exported parents. Set policy before adding new semantic names. | Main  [...]
-| 2 | 3975 + 4068: classification and correlation | **Proposed:** [classify and correlate observations](../changes/classify-and-correlate-observations/proposal.md). Foreground/background classification, root interaction ID, configurable execution-mode attribute key. Depends on foundation; adapt to main's interaction layers. | `024bd33128d`, `0325232a3c7`, `3083a51eabd` |
+| 2 | CAUSEWAY-4068 (3975/4068 provenance): classification and correlation | **Implemented; awaiting archive:** [classify and correlate observations](../changes/classify-and-correlate-observations/proposal.md). Foreground/background classification, root interaction ID, configurable execution-mode attribute key. Depends on foundation; adapt to main's interaction layers. | `024bd33128d`, `0325232a3c7`, `3083a51eabd` |
 | 3 | 4058: current action context | Planned. Exact-current-action queries and rule-checking state; nested restoration and physical mixin receiver semantics. Can proceed independently of most telemetry work. | `6d037db6609`; account for `f66b8f17a89` |
 | 4 | 4062: semantic member names | Planned. Bounded operation categories with domain-facing contextual names; mixed-in property/collection access distinguished from actions. Depends on foundation and naming policy. | `a2ae5877c99`, `d5ecd49a1f3`, `45e01ff6f42` |
 | 5 | 4059 + relevant 4062: Wicket regions/preparation | Planned. Entity-page preparation, region rendering and action prompts; Ajax ancestry, serialization and failure cleanup. The 4062 source touches Wicket as well as core: extract the relevant portions here rather than blindly applying step 4 commits. | `a8264fd1f9b`, relevant parts of `a2ae5877c99` / `45e01ff6f42` |
@@ -99,8 +99,10 @@ Implementation checkpoint: planning committed as `5390171e070`; all 11 implement
 
 ## Classification and correlation proposal (2026-10-06)
 
-Step 2 is captured in [classify-and-correlate-observations](../changes/classify-and-correlate-observations/proposal.md), based on local `6f10ac34cac` after CAUSEWAY-4096. CAUSEWAY-3975/4068 identify maintenance provenance; a new implementation ticket/branch has not been assigned. No implementation is included in this proposal.
+Step 2 is captured in [classify-and-correlate-observations](../changes/classify-and-correlate-observations/proposal.md), based on local `6f10ac34cac` after CAUSEWAY-4096. CAUSEWAY-3975/4068 identify maintenance provenance. The user selected `CAUSEWAY-4068-v4` for the main-side implementation under CAUSEWAY-4068.
 
 The design adapts current-span classification to main's shared Micrometer tracer for both Boot and agent ownership, retains `causeway.execution.mode.key`, and correlates only the existing root interaction observation. It explicitly handles main's replay-time replacement of command identity, avoids introducing job spans when instrumentation supplies none, and keeps semantic naming and rendering detail for later steps.
 
 Before creating the proposal, completed `port-domain-object-locking` was validated and [archived](../changes/archive/2026-10-06-port-domain-object-locking/proposal.md), with its requirements synced. The focused reactor verification passed, including all eight pessimistic-locking regressions. The archive checkpoint was committed as `e65505dbe0c` after session permissions allowed Git writes; the new planning artifacts follow in a separate proposal commit.
+
+Implementation checkpoint (2026-10-06): all 12 tasks completed on `CAUSEWAY-4068-v4`. [Validation evidence](../changes/classify-and-correlate-observations/validation.md) records 31 focused tests, 9 tracing/JPA compatibility tests, Petclinic packaging, generated configuration metadata and documentation checks. The M3 how-to and observability guide now cover foreground/background entry attributes, root interaction UUIDs, custom keys, separate Jaeger searches and the background fixture. Age [...]
diff --git a/regressiontests/tracing-compatibility/pom.xml b/regressiontests/tracing-compatibility/pom.xml
index d40fc247e7c..91ff6160ec9 100644
--- a/regressiontests/tracing-compatibility/pom.xml
+++ b/regressiontests/tracing-compatibility/pom.xml
@@ -45,6 +45,21 @@
     </properties>
 
     <dependencies>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-webmvc</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.causeway.core</groupId>
+            <artifactId>causeway-core-webapp</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.causeway.extensions</groupId>
+            <artifactId>causeway-extensions-commandlog-applib</artifactId>
+            <scope>test</scope>
+        </dependency>
         <dependency>
             <groupId>io.micrometer</groupId>
             <artifactId>micrometer-tracing-bridge-otel</artifactId>
diff --git a/regressiontests/tracing-compatibility/src/test/java/org/apache/causeway/regressiontests/tracing/EntryPointTracingFixture.java b/regressiontests/tracing-compatibility/src/test/java/org/apache/causeway/regressiontests/tracing/EntryPointTracingFixture.java
new file mode 100644
index 00000000000..1dae5a93b22
--- /dev/null
+++ b/regressiontests/tracing-compatibility/src/test/java/org/apache/causeway/regressiontests/tracing/EntryPointTracingFixture.java
@@ -0,0 +1,185 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.causeway.regressiontests.tracing;
+
+import static org.mockito.Mockito.*;
+
+import java.net.URI;
+import java.util.List;
+import java.util.Properties;
+import java.util.UUID;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import jakarta.servlet.http.HttpServlet;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+import org.quartz.JobBuilder;
+import org.quartz.JobExecutionContext;
+import org.quartz.TriggerBuilder;
+import org.quartz.impl.StdSchedulerFactory;
+import org.springframework.beans.factory.config.ConfigurableBeanFactory;
+import org.springframework.beans.factory.config.Scope;
+import org.springframework.boot.SpringBootConfiguration;
+import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
+import org.springframework.boot.builder.SpringApplicationBuilder;
+import org.springframework.boot.web.servlet.ServletRegistrationBean;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Import;
+import org.springframework.core.env.Profiles;
+
+import org.apache.causeway.applib.services.clock.ClockService;
+import org.apache.causeway.applib.services.inject.ServiceInjector;
+import org.apache.causeway.applib.services.xactn.TransactionState;
+import org.apache.causeway.core.config.observation.CausewayObservationAutoConfiguration;
+import org.apache.causeway.core.config.observation.CausewayObservationIntegration;
+import org.apache.causeway.core.config.observation.CausewayTraceClassifier;
+import org.apache.causeway.core.interaction.scope.InteractionScopeBeanFactoryPostProcessor;
+import org.apache.causeway.core.interaction.scope.InteractionScopeLifecycleHandler;
+import org.apache.causeway.core.metamodel.execution.ExecutionContext;
+import org.apache.causeway.core.runtimeservices.ia.InteractionServiceDefault;
+import org.apache.causeway.core.runtimeservices.transaction.TransactionServiceSpring;
+import org.apache.causeway.core.security.authentication.InteractionContextFactory;
+import org.apache.causeway.core.webapp.modules.observation.WebObservationConfiguration;
+import org.apache.causeway.extensions.commandlog.applib.job.RunBackgroundCommandsJob;
+
+import io.micrometer.tracing.Tracer;
+
+/** Real servlet/Quartz entry points; business collaborators are controlled fixtures. */
+@SpringBootConfiguration(proxyBeanMethods = false)
+@EnableAutoConfiguration
+@Import({MicrometerTracingAgentFixture.AgentRegistry.class,
+        CausewayObservationAutoConfiguration.class, WebObservationConfiguration.class})
+public class EntryPointTracingFixture {
+    static InteractionServiceDefault interactions;
+    static CausewayObservationIntegration integration;
+    static CausewayTraceClassifier classifier;
+    static Tracer tracer;
+    static boolean agent;
+    static final CountDownLatch jobFinished = new CountDownLatch(1);
+    static volatile Throwable jobFailure;
+    static final UUID REPLAY_ID = UUID.fromString("12345678-1234-1234-1234-123456789abc");
+
+    public static void main(String[] args) throws Exception {
+        try (var context = new SpringApplicationBuilder(EntryPointTracingFixture.class)
+                .properties("server.port=0", "server.tomcat.threads.max=1", "spring.main.banner-mode=off",
+                        "management.otlp.metrics.export.enabled=false", "spring.quartz.auto-startup=false")
+                .run(args)) {
+            integration = context.getBean(CausewayObservationIntegration.class);
+            classifier = context.getBean(CausewayTraceClassifier.class);
+            tracer = context.getBean(Tracer.class);
+            agent = context.getEnvironment().acceptsProfiles(Profiles.of("agent"));
+            var beanFactory = mock(ConfigurableBeanFactory.class);
+            var scope = mock(Scope.class, withSettings().extraInterfaces(InteractionScopeLifecycleHandler.class));
+            when(beanFactory.getRegisteredScope(InteractionScopeBeanFactoryPostProcessor.SCOPE_NAME)).thenReturn(scope);
+            var transactions = mock(TransactionServiceSpring.class);
+            when(transactions.currentTransactionState()).thenReturn(TransactionState.MUST_ABORT);
+            var executionContext = mock(ExecutionContext.class, RETURNS_DEEP_STUBS);
+            when(executionContext.idGenerator().interactionId()).thenAnswer(__ -> UUID.randomUUID());
+            interactions = new InteractionServiceDefault(beanFactory, mock(ServiceInjector.class), transactions,
+                    mock(ClockService.class), () -> null, executionContext, integration);
+            int port = Integer.parseInt(context.getEnvironment().getProperty("local.server.port"));
+            for (String path : List.of("/wicket/fail", "/graphql", "/restful/test", "/static/test.js")) {
+                var connection = (java.net.HttpURLConnection) URI.create("http://127.0.0.1:" + port + path).toURL().openConnection();
+                connection.setConnectTimeout(10000);
+                connection.setReadTimeout(30000);
+                int expected = path.endsWith("fail") ? 500 : 204;
+                if (connection.getResponseCode() != expected) throw new AssertionError("unexpected status");
+                connection.disconnect();
+            }
+            var properties = new Properties();
+            properties.setProperty("org.quartz.scheduler.instanceName", "classification-fixture");
+            properties.setProperty("org.quartz.threadPool.threadCount", "1");
+            properties.setProperty("org.quartz.jobStore.class", "org.quartz.simpl.RAMJobStore");
+            var scheduler = new StdSchedulerFactory(properties).getScheduler();
+            try {
+                scheduler.start();
+                scheduler.scheduleJob(JobBuilder.newJob(EntryJob.class).withIdentity("background-fixture").build(),
+                        TriggerBuilder.newTrigger().startNow().build());
+                if (!jobFinished.await(30, TimeUnit.SECONDS)) throw new AssertionError("job timeout");
+            } finally { scheduler.shutdown(true); }
+            if (jobFailure != null) throw new AssertionError("job failed", jobFailure);
+            System.out.println("CAUSEWAY_TRACING_FIXTURE_OK");
+        }
+    }
+
+    @Bean
+    ServletRegistrationBean<HttpServlet> fixtureServlet() {
+        return new ServletRegistrationBean<>(new HttpServlet() {
+            @Override protected void service(HttpServletRequest request, HttpServletResponse response) {
+                if (interactions.isInInteraction()) throw new AssertionError("leaked interaction");
+                try {
+                    // A security-like child makes incorrect filter ordering visible in export assertions.
+                    integration.createNotStarted(EntryPointTracingFixture.class, "fixture.security").observe(() ->
+                        interactions.run(InteractionContextFactory.testing(), () -> {
+                            if (request.getRequestURI().endsWith("fail")) throw new IllegalStateException("fixture failure");
+                        }));
+                    response.setStatus(204);
+                } catch (IllegalStateException expected) { response.setStatus(500); }
+                if (interactions.isInInteraction()) throw new AssertionError("leaked interaction after request");
+            }
+        }, "/*");
+    }
+
+    public static class EntryJob extends RunBackgroundCommandsJob {
+        @Override public void execute(JobExecutionContext context) {
+            // Boot has no automatic Quartz entry instrumentation in this fixture.
+            // Agent mode must use the actual span opened around Quartz execution.
+            var entry = agent ? null : tracer.nextSpan().name("fixture.quartz").start();
+            try (var scope = entry == null ? null : tracer.withSpan(entry)) {
+                // Execute the production job and command executor. Persistence and
+                // domain dispatch are controlled; the real replay path replaces identity.
+                set(this, "traceClassifier", classifier);
+                set(this, "interactionService", interactions);
+                set(this, "backgroundCommandsJobControl",
+                        mock(org.apache.causeway.extensions.commandlog.applib.job.BackgroundCommandsJobControl.class));
+                set(this, "listeners", List.of());
+                var dto = new org.apache.causeway.schema.cmd.v2.CommandDto();
+                dto.setInteractionId(REPLAY_ID.toString());
+                var transactions = mock(org.apache.causeway.applib.services.xactn.TransactionService.class);
+                when(transactions.callTransactional(any(org.springframework.transaction.annotation.Propagation.class), any())).thenReturn(
+                        org.apache.causeway.commons.functional.Try.success(List.of(dto)));
+                when(transactions.callWithinCurrentTransactionElseCreateNew(any())).thenReturn(
+                        org.apache.causeway.commons.functional.Try.empty());
+                set(this, "transactionService", transactions);
+                var repository = mock(org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntryRepository.class);
+                when(repository.findByInteractionId(REPLAY_ID)).thenReturn(java.util.Optional.of(
+                        mock(org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntry.class)));
+                set(this, "commandLogEntryRepository", repository);
+                var clock = mock(ClockService.class, RETURNS_DEEP_STUBS);
+                set(this, "clockService", clock);
+                var publisher = mock(org.apache.causeway.core.metamodel.services.publishing.CommandPublisher.class);
+                var executor = new org.apache.causeway.core.runtimeservices.command.CommandExecutorServiceDefault(
+                        null, null, clock, transactions, interactions, null, null, () -> publisher, null, null);
+                set(this, "commandExecutorService", executor);
+                super.execute(context);
+            } catch (Throwable failure) { jobFailure = failure; }
+            finally {
+                if (entry != null) entry.end();
+                jobFinished.countDown();
+            }
+        }
+        private static void set(Object target, String field, Object value) throws Exception {
+            var f = RunBackgroundCommandsJob.class.getDeclaredField(field);
+            f.setAccessible(true);
+            f.set(target, value);
+        }
+    }
+}
diff --git a/regressiontests/tracing-compatibility/src/test/java/org/apache/causeway/regressiontests/tracing/MicrometerTracingAgentFixture.java b/regressiontests/tracing-compatibility/src/test/java/org/apache/causeway/regressiontests/tracing/MicrometerTracingAgentFixture.java
index 7765424105d..9d6c64c97b1 100644
--- a/regressiontests/tracing-compatibility/src/test/java/org/apache/causeway/regressiontests/tracing/MicrometerTracingAgentFixture.java
+++ b/regressiontests/tracing-compatibility/src/test/java/org/apache/causeway/regressiontests/tracing/MicrometerTracingAgentFixture.java
@@ -179,10 +179,13 @@ private static void executeSql() {
     @Configuration(proxyBeanMethods = false)
     @Profile("agent")
     static class AgentRegistry {
-        @Bean ObservationRegistry agentObservationRegistry() {
+        @Bean io.micrometer.tracing.Tracer agentTracer() {
             var currentContext = new OtelCurrentTraceContext();
-            var tracer = new OtelTracer(GlobalOpenTelemetry.getTracer("org.apache.causeway"), currentContext,
+            return new OtelTracer(GlobalOpenTelemetry.getTracer("org.apache.causeway"), currentContext,
                     event -> {}, new OtelBaggageManager(currentContext, List.of(), List.of()));
+        }
+        @Bean
+        ObservationRegistry agentObservationRegistry(io.micrometer.tracing.Tracer tracer) {
             var registry = ObservationRegistry.create();
             registry.observationConfig().observationHandler(new DefaultTracingObservationHandler(tracer));
             return registry;
diff --git a/regressiontests/tracing-compatibility/src/test/java/org/apache/causeway/regressiontests/tracing/MicrometerTracingCompatibilityTest.java b/regressiontests/tracing-compatibility/src/test/java/org/apache/causeway/regressiontests/tracing/MicrometerTracingCompatibilityTest.java
index ba12614febf..527e1dd6e92 100644
--- a/regressiontests/tracing-compatibility/src/test/java/org/apache/causeway/regressiontests/tracing/MicrometerTracingCompatibilityTest.java
+++ b/regressiontests/tracing-compatibility/src/test/java/org/apache/causeway/regressiontests/tracing/MicrometerTracingCompatibilityTest.java
@@ -131,7 +131,56 @@ private void assertJpaAncestry(List<Span> spans, Span action, boolean agent) {
             assertTrue(collector.spans.isEmpty(), names(collector.spans));
         }
     }
+    @Test void servletAndQuartzEntryClassificationAndCorrelation() throws Exception {
+        for (String mode : List.of("boot", "agent")) {
+            for (String key : List.of("causeway.execution.mode", "custom.execution.mode")) {
+                try (var collector = new Collector()) {
+                    launchFixture(EntryPointTracingFixture.class, mode, collector, "--causeway.execution.mode.key=" + key);
+                    var http = collector.spans.stream().filter(s -> s.getKind() == Span.SpanKind.SPAN_KIND_SERVER).toList();
+                    assertEquals(4, http.size(), names(collector.spans));
+                    http.forEach(s -> assertEquals("foreground", attribute(s, key), names(collector.spans)));
+                    var background = collector.spans.stream().filter(s -> "background".equals(attribute(s, key))).toList();
+                    assertEquals(1, background.size(), names(collector.spans));
+                    var roots = collector.spans.stream().filter(s -> s.getName().equals("Causeway Root Interaction")).toList();
+                    assertEquals(6, roots.size(), names(collector.spans));
+                    for (var root : roots) {
+                        var id = attribute(root, "causeway.interaction.id");
+                        assertNotNull(id);
+                        java.util.UUID.fromString(id);
+                        assertNull(attribute(root, key));
+                        assertIdentity(root, false);
+                    }
+                    for (var entry : http) {
+                        var security = collector.spans.stream().filter(s -> s.getName().equals("fixture.security")
+                                && s.getParentSpanId().equals(entry.getSpanId())).toList();
+                        assertEquals(1, security.size());
+                        var children = roots.stream().filter(s -> s.getParentSpanId().equals(security.get(0).getSpanId())).toList();
+                        assertEquals(1, children.size());
+                        assertEquals(entry.getTraceId(), children.get(0).getTraceId());
+                    }
+                    var replay = roots.stream().filter(s -> EntryPointTracingFixture.REPLAY_ID.toString()
+                            .equals(attribute(s, "causeway.interaction.id"))).findFirst().orElseThrow();
+                    assertEquals(background.get(0).getSpanId(), replay.getParentSpanId());
+                    assertEquals(background.get(0).getTraceId(), replay.getTraceId());
+                    for (var span : collector.spans) {
+                        if (!roots.contains(span)) assertNull(attribute(span, "causeway.interaction.id"));
+                        if (!http.contains(span) && !background.contains(span)) assertNull(attribute(span, key));
+                        if (!key.equals("causeway.execution.mode")) assertNull(attribute(span, "causeway.execution.mode"));
+                    }
+                    assertEquals(5, roots.stream().map(Span::getTraceId).distinct().count());
+                    assertEquals(1, roots.stream().filter(s -> s.getStatus().getCode() == Status.StatusCode.STATUS_CODE_ERROR).count());
+                }
+            }
+        }
+    }
+    private static String attribute(Span span, String key) {
+        return span.getAttributesList().stream().filter(a -> a.getKey().equals(key))
+                .map(a -> a.getValue().getStringValue()).findFirst().orElse(null);
+    }
     private void launch(String mode, Collector collector, String... properties) throws Exception {
+        launchFixture(MicrometerTracingAgentFixture.class, mode, collector, properties);
+    }
+    private void launchFixture(Class<?> fixture, String mode, Collector collector, String... properties) throws Exception {
         boolean agent = mode.equals("agent") || mode.equals("inactive");
         var command = new ArrayList<String>();
         command.add(Path.of(System.getProperty("java.home"), "bin", "java").toString());
@@ -139,7 +188,7 @@ private void launch(String mode, Collector collector, String... properties) thro
         command.add("-javaagent:" + Path.of(org.mockito.Mockito.class.getProtectionDomain().getCodeSource().getLocation().toURI()));
         command.add("-cp");
         command.add(System.getProperty("surefire.test.class.path", System.getProperty("java.class.path")));
-        command.add(MicrometerTracingAgentFixture.class.getName());
+        command.add(fixture.getName());
         command.add("--spring.profiles.active=" + (mode.equals("inactive") ? "agent" : agent ? "observation,agent" : "observation"));
         command.add("--management.tracing.sampling.probability=1.0");
         command.add("--management.opentelemetry.tracing.export.otlp.endpoint=" + collector.endpoint());
@@ -149,8 +198,10 @@ private void launch(String mode, Collector collector, String... properties) thro
                     "org.springframework.boot.opentelemetry.autoconfigure.OpenTelemetrySdkAutoConfiguration",
                     "org.springframework.boot.micrometer.tracing.opentelemetry.autoconfigure.OpenTelemetryTracingAutoConfiguration",
                     "org.springframework.boot.micrometer.tracing.opentelemetry.autoconfigure.otlp.OtlpTracingAutoConfiguration",
-                    "org.springframework.boot.micrometer.tracing.autoconfigure.MicrometerTracingAutoConfiguration"));
+                    "org.springframework.boot.micrometer.tracing.autoconfigure.MicrometerTracingAutoConfiguration",
+                    "org.springframework.boot.webmvc.autoconfigure.WebMvcObservationAutoConfiguration"));
         }
+        command.add("--management.opentelemetry.map-environment-variables=false");
         command.addAll(List.of(properties));
         var builder = new ProcessBuilder(command).redirectErrorStream(true);
         builder.environment().keySet().removeIf(key -> key.startsWith("OTEL_"));
@@ -162,7 +213,7 @@ private void launch(String mode, Collector collector, String... properties) thro
         builder.environment().put("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf");
         builder.environment().put("OTEL_TRACES_SAMPLER", "always_on");
         builder.environment().put("OTEL_BSP_SCHEDULE_DELAY", "100");
-        var output = Path.of("target", "fixture-" + mode + ".log");
+        var output = Path.of("target", "fixture-" + fixture.getSimpleName() + "-" + mode + ".log");
         var process = builder.start();
         var drainer = new Thread(() -> {
             try (var stream = process.getInputStream()) { Files.copy(stream, output, java.nio.file.StandardCopyOption.REPLACE_EXISTING); }
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/application-agent.properties b/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/application-agent.properties
index b6b5d2b0c79..42a54668ecc 100644
--- a/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/application-agent.properties
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/application-agent.properties
@@ -17,7 +17,8 @@
 # under the License.
 #
 # The Java agent owns tracing. This profile requires its registry bridge and -javaagent.
-spring.autoconfigure.exclude=org.springframework.boot.opentelemetry.autoconfigure.OpenTelemetrySdkAutoConfiguration,org.springframework.boot.micrometer.tracing.opentelemetry.autoconfigure.OpenTelemetryTracingAutoConfiguration,org.springframework.boot.micrometer.tracing.opentelemetry.autoconfigure.otlp.OtlpTracingAutoConfiguration,org.springframework.boot.micrometer.tracing.autoconfigure.MicrometerTracingAutoConfiguration
+# Agent servlet instrumentation owns the HTTP entry; avoid a second Boot HTTP observation span.
+spring.autoconfigure.exclude=org.springframework.boot.opentelemetry.autoconfigure.OpenTelemetrySdkAutoConfiguration,org.springframework.boot.micrometer.tracing.opentelemetry.autoconfigure.OpenTelemetryTracingAutoConfiguration,org.springframework.boot.micrometer.tracing.opentelemetry.autoconfigure.otlp.OtlpTracingAutoConfiguration,org.springframework.boot.micrometer.tracing.autoconfigure.MicrometerTracingAutoConfiguration,org.springframework.boot.webmvc.autoconfigure.WebMvcObservationAuto [...]
 # Keep OTEL_* settings owned by the agent; do not map its metrics disable flag onto Micrometer.
 management.opentelemetry.map-environment-variables=false
 management.tracing.export.enabled=false
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/observation/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/AgentObservationConfiguration.java b/viewers/webcomponents/sample-htmx-petclinic/src/observation/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/AgentObservationConfiguration.java
index bc357c1c802..05530056067 100644
--- a/viewers/webcomponents/sample-htmx-petclinic/src/observation/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/AgentObservationConfiguration.java
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/observation/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/AgentObservationConfiguration.java
@@ -36,11 +36,14 @@
 @Profile("agent")
 public class AgentObservationConfiguration {
     @Bean
-    ObservationRegistry agentObservationRegistry() {
+    io.micrometer.tracing.Tracer agentTracer() {
         // The attached agent owns the global SDK and exporter. Do not construct another SDK here.
         var currentContext = new OtelCurrentTraceContext();
-        var tracer = new OtelTracer(GlobalOpenTelemetry.getTracer("org.apache.causeway"), currentContext,
+        return new OtelTracer(GlobalOpenTelemetry.getTracer("org.apache.causeway"), currentContext,
                 event -> {}, new OtelBaggageManager(currentContext, List.of(), List.of()));
+    }
+    @Bean
+    ObservationRegistry agentObservationRegistry(io.micrometer.tracing.Tracer tracer) {
         var registry = ObservationRegistry.create();
         registry.observationConfig().observationHandler(new DefaultTracingObservationHandler(tracer));
         return registry;


(causeway) 01/02: CAUSEWAY-4068: propose observation classification and correlation

Posted by da...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

danhaywood pushed a commit to branch CAUSEWAY-4068-v4
in repository https://gitbox.apache.org/repos/asf/causeway.git

commit 7f9edf85b7a50a73eb8707cc4c19ef6c09ed527a
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Tue Oct 6 06:26:27 2026 +0100

    CAUSEWAY-4068: propose observation classification and correlation
---
 .../.openspec.yaml                                 |  2 +
 .../classify-and-correlate-observations/design.md  | 69 ++++++++++++++++++++++
 .../proposal.md                                    | 32 ++++++++++
 .../specs/interaction-trace-correlation/spec.md    | 41 +++++++++++++
 .../observation-execution-classification/spec.md   | 53 +++++++++++++++++
 .../classify-and-correlate-observations/tasks.md   | 26 ++++++++
 .../planned-changes/otel-forward-port-roadmap.md   | 12 +++-
 7 files changed, 233 insertions(+), 2 deletions(-)

diff --git a/openspec/changes/classify-and-correlate-observations/.openspec.yaml b/openspec/changes/classify-and-correlate-observations/.openspec.yaml
new file mode 100644
index 00000000000..aa32566bf64
--- /dev/null
+++ b/openspec/changes/classify-and-correlate-observations/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-10-06
diff --git a/openspec/changes/classify-and-correlate-observations/design.md b/openspec/changes/classify-and-correlate-observations/design.md
new file mode 100644
index 00000000000..99890d72342
--- /dev/null
+++ b/openspec/changes/classify-and-correlate-observations/design.md
@@ -0,0 +1,69 @@
+## Context
+
+Baseline inspected: `6f10ac34cac` on `CAUSEWAY-4096`, after the archived observation-policy change. No remote refresh or new implementation branch is implied by this proposal. Main uses Boot 4.2.0-M1 and supports an explicit agent-owned bridge; core/config already depends on Micrometer Tracing, but not the OpenTelemetry API.
+
+Maintenance sources:
+- `024bd33128d`: current-span foreground filter and background-command job classification.
+- `0325232a3c7`: interaction UUID on the root observation.
+- `3083a51eabd`: `causeway.execution.mode.key`. Its surrounding filter also contains later semantic-naming support, which is excluded here.
+
+Main differs materially: `InteractionServiceDefault.openInteractionLayer` creates root/nested observations through `InteractionLayerStack`; interaction carriers allocate command identity. `CommandExecutorServiceDefault.doExecute` later calls `setCommandDtoAndIdentifier`, replacing that identity for replay. Copying maintenance's opening-time tag would leave a stale correlation value. The recently corrected Wicket listener order must remain intact.
+
+## Goals / Non-Goals
+
+**Goals:** bounded entry-span classification, operator-configurable classification key, trustworthy root interaction UUID correlation, and exported evidence for Boot and agent modes. Preserve inactive-profile behavior, scope cleanup, existing names and transaction semantics.
+
+**Non-goals:** semantic naming, rendering detail, correlation baggage/MDC, cross-process identity propagation, new SDK/exporter ownership, generic scheduler instrumentation, adding metric identity labels, or forcing every span to carry classification.
+
+## Decisions
+
+### Classify the existing entry span through the configured tracing bridge
+
+Introduce a small classifier backed by the single/primary Micrometer `Tracer`, using `currentSpan().tag(key, value)` only when a span exists. Configure an inert implementation when Causeway's `observation` profile is inactive or no tracer is available. Resolve any ambiguous active tracer configuration explicitly using Spring's single/primary rules rather than guessing. Keep key/value constants and typed configuration available to consuming applications.
+
+Boot supplies the tracer. Refactor the documented sample agent bridge to expose its existing `OtelTracer` as a bean and inject that same instance into the observation handler; do not create a second tracer pipeline or SDK. Update the compatibility fixture equivalently. A custom registry alone without an exposed tracer still supports observations, but cannot classify non-observation entry spans; document this prerequisite.
+
+Alternative rejected: directly copying maintenance's `Span.current()` helper into core/config would add an OpenTelemetry API dependency and assume agent context ownership. Tagging only the current Observation is insufficient because an agent-owned HTTP/Quartz entry span need not have a Micrometer observation.
+
+### Register classification inside HTTP instrumentation and before application/security work
+
+Use a Jakarta servlet filter across `/*`, ordered after Boot's HTTP observation filter and before nested security/viewer observations. In agent mode the agent's servlet entry span must already be current. Verify this ordering with actual exported HTTP spans, not just mocked classifier calls. Do not copy maintenance's servlet priority blindly or import its semantic trace scope.
+
+Foreground includes Wicket, GraphQL/HTMX, REST and static resources: it denotes an HTTP entry, not necessarily a human action. Use request-dispatch semantics that prevent async/error redispatch from retagging arbitrary child spans. Classification creates no scopes and no spans; request exceptions propagate unchanged. No universal propagation to children is promised.
+
+### Classify the command-log job at entry
+
+Call the classifier at the start of `RunBackgroundCommandsJob.execute`, before its paused check, matching maintenance behavior. Label the current instrumented job entry span `background`, preserving retries, callbacks and transaction behavior. Do not classify every anonymous interaction or arbitrary Quartz job.
+
+Boot does not automatically guarantee a Quartz entry span. Without one, the classifier is a no-op. Tests explicitly provide a recording entry span for the Boot job fixture; an agent fixture should exercise real Quartz instrumentation. Documentation must distinguish this fixture/setup requirement from out-of-the-box Petclinic behavior. Petclinic is suitable for foreground verification, not a claim of a configured command-log scheduler.
+
+### Keep the maintenance configuration contract, using main's immutable conventions
+
+Expose `causeway.execution.mode.key`, default `causeway.execution.mode`, as typed configuration following main's record/default conventions. Reject empty and whitespace-only values at binding/startup. Retain the exact values `foreground` and `background`; an override replaces the emitted default key rather than adding aliases. Do not add a second observation-policy switch for the same setting. Audit `Execution`/`Mode` type-name collisions instead of copying maintenance's mutable configur [...]
+
+### Bind correlation to the root interaction lifecycle
+
+Add `causeway.interaction.id` only to the existing depth-zero `Causeway Root Interaction` observation, as high-cardinality metadata. Source it from that interaction's `getInteractionId()`; do not allocate an ID for telemetry or rename observations. Leave main's existing nested/reused-layer identity semantics unchanged, and do not assume maintenance's shared-ID stack model applies.
+
+Attach the initial value once the root carrier exists, then refresh the same attribute from that root interaction immediately before its observation stops. Bind the supplier to that root instance, not to whichever layer is current at cleanup. This handles replay's identifier replacement, preserves the attribute on failures, and prevents nested interactions from overwriting the outer root. If an equivalent existing lifecycle hook provides this guarantee, reuse it rather than introducing s [...]
+
+Rejected alternatives: tagging every HTTP/JDBC span creates needless correlation duplication; recording only at observation creation loses replay identity; changing command identity semantics to simplify tagging is outside scope.
+
+## Risks / Trade-offs
+
+- Wrong servlet filter order labels a child or misses the entry → actual servlet/export tests for both trace owners, success and failure, followed by thread reuse.
+- ID replacement or nested carriers produce misleading searches → deterministic UUID tests, real replay path and final exported-value assertions.
+- No current job span in Boot → explicit no-op contract and example instrumentation prerequisite; no invented background trace.
+- Custom registries may omit a tracer bean → document shared bridge wiring and cover registry-only operation without failure.
+- Classification is span-local, while correlation lives on a different span → document separate Jaeger searches using All Span Names; do not promise same-span conjunctions will match.
+- In-memory Jaeger restarts and sampling can remove matches → document retention and sampling limits; correlation does not recover unexported traces.
+
+## Migration Plan
+
+Additive span attributes; names, exporters, sampling, identity opt-ins and filtering defaults remain unchanged. Applications using the documented agent bridge expose the shared tracer bean before checking classification. Override the key only when required by existing telemetry conventions and update searches accordingly. Removing the change removes the new attributes without changing domain outcomes or requiring data migration.
+
+Update the M3 how-to incrementally: default foreground attributes on the HTTP span, root UUID on the interaction span, copy-and-search example, custom key, and a separately identified background fixture/example. Retain the separate Jaeger and metrics scripts and unified Petclinic launcher.
+
+## Open Questions
+
+No product decision blocks implementation. Assign the main-side Jira ticket/branch before implementation; CAUSEWAY-3975/4068 are provenance. Implementation must prove filter ordering against the pinned Boot baseline and check how agent Quartz instrumentation exposes the job entry span. If either requires a contract change, update this plan before expanding scope.
diff --git a/openspec/changes/classify-and-correlate-observations/proposal.md b/openspec/changes/classify-and-correlate-observations/proposal.md
new file mode 100644
index 00000000000..ec430e701ce
--- /dev/null
+++ b/openspec/changes/classify-and-correlate-observations/proposal.md
@@ -0,0 +1,32 @@
+## Why
+
+The observation foundation and CAUSEWAY-4096 now provide reliable scopes and explicit metadata policy, but operators cannot consistently distinguish foreground requests from background command jobs or locate a trace using a Causeway interaction ID. Forward-port roadmap step 2 so these searches work in both supported tracing configurations before adding semantic names and viewer detail.
+
+## What Changes
+
+- Classify existing HTTP entry spans as `foreground` and existing command-log Quartz job entry spans as `background`, without creating extra spans or inferring execution mode from usernames or thread names.
+- Expose typed configuration `causeway.execution.mode.key`, default `causeway.execution.mode`, shared by both entry points; reject blank keys.
+- Add high-cardinality `causeway.interaction.id` to the existing root interaction observation, using its actual UUID, including the effective command ID after background replay replaces the initial identifier.
+- Support Boot-managed and agent-managed tracing using the existing trace owner; remain inert with the `observation` profile inactive or no current span.
+- Extend export/lifecycle tests and the M3 how-to with concrete attribute locations, Jaeger searches, custom-key checks and an honest background-job example.
+
+## Capabilities
+
+### New Capabilities
+
+- `observation-execution-classification`: Foreground/background entry-span classification, configurable attribute key, activation and trace-owner compatibility.
+- `interaction-trace-correlation`: Root interaction UUID metadata, replay identity, and retained-trace lookup guidance.
+
+### Modified Capabilities
+
+None. Existing foundation and observation-policy requirements remain in force, including default identity omission, duration filtering policy and deterministic scope cleanup.
+
+## Impact
+
+Main modules: `core/config`, `core/webapp`, `core/runtimeservices` and potentially `core/metamodel` for interaction lifecycle binding; `extensions/core/commandlog/applib` for the job entry point; tracing compatibility tests; sample agent bridge and observability documentation. No new SDK/exporter, production persistence schema, dependency-version upgrade or span-name migration is intended.
+
+Source provenance: CAUSEWAY-3975 commit `024bd33128d`, CAUSEWAY-4068 commits `0325232a3c7` and `3083a51eabd`. This is a main-side adaptation, not a cherry-pick. A new Jira ticket/implementation branch has not been assigned; these ticket numbers record provenance, not a newly created ticket.
+
+## Non-goals
+
+Semantic trace-root names, action/mixin identity changes, Wicket rendering regions, collection budgets, new application span APIs, automatic classification of arbitrary jobs/executors, MDC/baggage propagation, metric correlation labels, and creating job spans when no tracing instrumentation supplies one.
diff --git a/openspec/changes/classify-and-correlate-observations/specs/interaction-trace-correlation/spec.md b/openspec/changes/classify-and-correlate-observations/specs/interaction-trace-correlation/spec.md
new file mode 100644
index 00000000000..dbe62369501
--- /dev/null
+++ b/openspec/changes/classify-and-correlate-observations/specs/interaction-trace-correlation/spec.md
@@ -0,0 +1,41 @@
+## ADDED Requirements
+
+### Requirement: Root interaction UUID correlation
+
+When observation is active, the existing root interaction span SHALL contain high-cardinality string attribute `causeway.interaction.id`, equal to the canonical UUID returned by its own interaction's `getInteractionId()` at completion. The attribute SHALL be independent of username and tenancy opt-ins and SHALL NOT become a metric label.
+
+#### Scenario: Ordinary interaction
+- **WHEN** an observed root interaction completes
+- **THEN** its exported ID equals the interaction UUID even with username and tenancy export disabled
+
+#### Scenario: Background replay replaces identity
+- **WHEN** command replay replaces the initial command identifier using a command DTO
+- **THEN** the root span exports the effective replay interaction UUID rather than the temporary opening-time UUID
+
+#### Scenario: Nested or reused interaction
+- **WHEN** a nested layer opens or the current layer is reused
+- **THEN** the outer root keeps its own interaction identity, without adding another root solely for correlation or changing existing layer identity semantics
+
+### Requirement: Correlation preserves lifecycle and trace structure
+
+Correlation SHALL NOT allocate new interaction IDs, derive trace/span IDs or display names from the UUID, copy it onto HTTP entry spans or all descendants, or introduce duplicate observations. It SHALL preserve success/failure behavior and deterministic scope cleanup with Boot-managed and agent-managed tracing.
+
+#### Scenario: Failure followed by another interaction
+- **WHEN** observed work fails and another interaction subsequently runs on the same thread
+- **THEN** the failed root retains its effective ID, cleanup restores the prior context, and the later root exports its own ID without stale correlation
+
+#### Scenario: Inactive observation
+- **WHEN** an interaction executes with the observation profile inactive
+- **THEN** its existing identity and behavior remain unchanged and no correlation span or exporter is required
+
+### Requirement: Operator verification explains attribute placement
+
+The observability guide SHALL show execution-mode attributes on the applicable entry span and interaction UUID on the root interaction span. It SHALL provide separate Jaeger searches using the configured key or exact UUID with All Span Names, explain retention/sampling limitations, and distinguish runnable foreground examples from background instrumentation prerequisites.
+
+#### Scenario: Find a retained trace by interaction ID
+- **WHEN** an operator copies a root interaction UUID and searches retained traces using `causeway.interaction.id=<UUID>` without a conflicting operation filter
+- **THEN** the documented procedure locates the trace containing that root span when it was exported and is still retained
+
+#### Scenario: Verify default and custom classification
+- **WHEN** an operator follows the foreground and background examples with the default or an overridden key
+- **THEN** the guide identifies the span to inspect, the exact expected attribute and the requirement for an existing instrumented job span, without implying that every child carries the attribute
diff --git a/openspec/changes/classify-and-correlate-observations/specs/observation-execution-classification/spec.md b/openspec/changes/classify-and-correlate-observations/specs/observation-execution-classification/spec.md
new file mode 100644
index 00000000000..c206a445731
--- /dev/null
+++ b/openspec/changes/classify-and-correlate-observations/specs/observation-execution-classification/spec.md
@@ -0,0 +1,53 @@
+## ADDED Requirements
+
+### Requirement: Foreground HTTP entry classification
+
+With the observation profile active and a supported tracing bridge, Causeway SHALL annotate the existing HTTP entry span with execution mode `foreground`. It SHALL NOT create or rename spans, alter parentage, or tag a nested security/viewer span in place of the entry span. Classification SHALL cover framework servlet routes including static resources and SHALL NOT mean that an authenticated human initiated the request.
+
+#### Scenario: Framework HTTP request
+- **WHEN** a Wicket, GraphQL/HTMX, REST or static-resource request enters the filter under Boot-managed or agent-managed tracing
+- **THEN** its exported HTTP entry span carries `foreground` under the configured key and existing names and parentage remain intact
+
+#### Scenario: Failure and redispatch
+- **WHEN** request processing fails or undergoes async/error redispatch
+- **THEN** classification preserves the failure and existing context lifecycle without creating spans or classifying an unrelated child
+
+### Requirement: Background command job classification
+
+Causeway SHALL annotate an existing current entry span at the beginning of `RunBackgroundCommandsJob.execute` with execution mode `background`, including paused jobs. It SHALL preserve scheduling, retries, callback and transaction semantics and SHALL NOT manufacture an entry span when none exists.
+
+#### Scenario: Instrumented job invocation
+- **WHEN** the command-log job starts with a recording entry span, whether paused or executing commands
+- **THEN** that span carries `background` and job results and failures retain their existing behavior
+
+#### Scenario: Job without tracing instrumentation
+- **WHEN** the command-log job starts without a current span
+- **THEN** classification is a no-op and execution proceeds without requiring an SDK or exporter
+
+### Requirement: Configurable shared execution-mode key
+
+Typed Causeway configuration SHALL expose `causeway.execution.mode.key`, defaulting to `causeway.execution.mode`, and SHALL reject empty or whitespace-only values. Both classifiers SHALL use the configured key and the bounded values `foreground` and `background`. Application instrumentation SHALL be able to read the same typed setting.
+
+#### Scenario: Default and override
+- **WHEN** either entry point classifies a span
+- **THEN** it writes the default key unless overridden, in which case it writes only the configured key
+
+#### Scenario: Invalid key
+- **WHEN** the configured key is empty or whitespace-only
+- **THEN** startup reports an actionable configuration error
+
+### Requirement: Classification respects tracing ownership and activation
+
+Classification SHALL use the configured single/primary tracing bridge and SHALL NOT create another SDK, exporter or scope. Inactive Causeway observation SHALL NOT mutate application spans. An absent tracer or current span SHALL be safe; ambiguous active tracer candidates SHALL require explicit resolution.
+
+#### Scenario: Inactive profile
+- **WHEN** application tracing is active but Causeway's observation profile is inactive
+- **THEN** Causeway adds no execution-mode attribute and application tracing continues independently
+
+#### Scenario: Agent bridge
+- **WHEN** the documented agent setup exposes the same tracer used by its observation handler
+- **THEN** classification reaches the agent entry span without duplicate framework export or a competing trace owner
+
+#### Scenario: Missing or ambiguous tracer
+- **WHEN** no tracer is available, or multiple active tracers have no primary
+- **THEN** absence results in safe no-op classification, while ambiguity produces an actionable configuration error
diff --git a/openspec/changes/classify-and-correlate-observations/tasks.md b/openspec/changes/classify-and-correlate-observations/tasks.md
new file mode 100644
index 00000000000..0e871140499
--- /dev/null
+++ b/openspec/changes/classify-and-correlate-observations/tasks.md
@@ -0,0 +1,26 @@
+## 1. Configuration and tracing bridge
+
+- [ ] 1.1 Add typed `causeway.execution.mode.key` with the maintenance default using main's configuration conventions; verify metadata and binding tests for default, override and blank rejection.
+- [ ] 1.2 Implement current-span classification using the single/primary Micrometer tracer, gated by Causeway observation activation; test foreground/background values, missing tracer/span, inactive profile, ambiguity and absence of duplicate keys after override.
+- [ ] 1.3 Expose and reuse the existing tracer in the sample/fixture agent registry bridge; verify no second SDK, exporter or tracing handler is created.
+
+## 2. Entry-point instrumentation
+
+- [ ] 2.1 Register a Jakarta foreground filter after HTTP tracing and before security/viewer observations; test Wicket, GraphQL/HTMX, REST and static paths, request failure, async/error dispatch and context restoration.
+- [ ] 2.2 Classify the existing span at command-log job entry before its paused check; test paused, successful and failed jobs, configured key and missing entry span without changing retries or transaction behavior.
+
+## 3. Root interaction correlation
+
+- [ ] 3.1 Bind high-cardinality `causeway.interaction.id` to the root interaction instance and refresh its effective UUID before observation stop; preserve existing root/nested names, identity policies and cleanup guarantees.
+- [ ] 3.2 Test deterministic ordinary UUIDs, nested/reused layers, real command DTO identifier replacement, failed work, cleanup failure and subsequent interactions on the same thread; verify no ID metric label or HTTP/descendant copy is introduced.
+
+## 4. Exported compatibility evidence
+
+- [ ] 4.1 Extend the export fixture with actual servlet entry-span classification in Boot and agent modes, default/custom keys, root interaction correlation, failure followed by success and unchanged ancestry/no duplicate framework spans.
+- [ ] 4.2 Exercise command-log job classification with a supplied Boot entry span and agent Quartz instrumentation; assert effective replay UUID correlation and safe execution with no span.
+- [ ] 4.3 Run focused config/webapp/interaction/command-log tests, existing Wicket lifecycle regressions and the tracing fixture on main's supported toolchain; record exact commands, versions, results and remaining limitations in validation.md.
+
+## 5. Operator guidance
+
+- [ ] 5.1 Extend the existing observability guidance and M3 how-to with entry-span classification, root-span UUID lookup, a custom-key example and separate Jaeger searches; preserve default identity/filtering explanations and the current launcher structure.
+- [ ] 5.2 Provide a reproducible background-job verification example or fixture command with its tracing prerequisites; distinguish it from Petclinic's foreground walkthrough, document retention/sampling limits, and validate documentation rendering and configuration examples.
diff --git a/openspec/planned-changes/otel-forward-port-roadmap.md b/openspec/planned-changes/otel-forward-port-roadmap.md
index 55cafa271f6..6e5fb218237 100644
--- a/openspec/planned-changes/otel-forward-port-roadmap.md
+++ b/openspec/planned-changes/otel-forward-port-roadmap.md
@@ -12,7 +12,7 @@ Main already contains CAUSEWAY-3975, including instrumentation absent from the s
 |---|---|---|---|
 | 1 | 3975: foundation reconciliation | **Archived:** [completed change](../changes/archive/2026-09-27-reconcile-main-observation-foundation/proposal.md). Lifecycle cleanup, activation/registry selection, Boot/agent compatibility evidence, threshold behavior, preservation of main instrumentation. | `9b8b66d5b7b`, `e6752f882b5`; compare main's original 3975 implementation |
 | 1b | CAUSEWAY-4096: standardize observation names, metadata and duration-filtering policy | **Archived (2026-10-05):** [standardize observation policy](../changes/archive/2026-10-05-standardize-observation-policy/proposal.md) on branch `CAUSEWAY-4096`. Follow-up to 3975: stable operation names vs contextual names; review bookmark/query-description names, identity attributes, hard-coded duration threshold and missing exported parents. Set policy before adding new semantic names. | Main  [...]
-| 2 | 3975 + 4068: classification and correlation | Planned. Foreground/background classification, root interaction ID, configurable execution-mode attribute key. Depends on foundation; adapt to main's interaction layers. | `024bd33128d`, `0325232a3c7`, `3083a51eabd` |
+| 2 | 3975 + 4068: classification and correlation | **Proposed:** [classify and correlate observations](../changes/classify-and-correlate-observations/proposal.md). Foreground/background classification, root interaction ID, configurable execution-mode attribute key. Depends on foundation; adapt to main's interaction layers. | `024bd33128d`, `0325232a3c7`, `3083a51eabd` |
 | 3 | 4058: current action context | Planned. Exact-current-action queries and rule-checking state; nested restoration and physical mixin receiver semantics. Can proceed independently of most telemetry work. | `6d037db6609`; account for `f66b8f17a89` |
 | 4 | 4062: semantic member names | Planned. Bounded operation categories with domain-facing contextual names; mixed-in property/collection access distinguished from actions. Depends on foundation and naming policy. | `a2ae5877c99`, `d5ecd49a1f3`, `45e01ff6f42` |
 | 5 | 4059 + relevant 4062: Wicket regions/preparation | Planned. Entity-page preparation, region rendering and action prompts; Ajax ancestry, serialization and failure cleanup. The 4062 source touches Wicket as well as core: extract the relevant portions here rather than blindly applying step 4 commits. | `a8264fd1f9b`, relevant parts of `a2ae5877c99` / `45e01ff6f42` |
@@ -95,4 +95,12 @@ Step 1b is now [archived](../changes/archive/2026-10-05-standardize-observation-
 
 The proposal preserves existing instrumentation and registry ownership. It does not include later mixin semantics, trace-root naming, classification/correlation or collection budgets. Before creating it, the completed viewer-documentation proposal was archived on main in `da8c9a98bb7`, and branch `CAUSEWAY-4096` was rebased onto that commit.
 
-Implementation checkpoint: planning committed as `5390171e070`; all 11 implementation tasks completed. [Validation evidence](../changes/archive/2026-10-05-standardize-observation-policy/validation.md) records 43 focused tests, 8 JPA/export tests, 22 existing regression tests and the documentation build. Explicit Boot filtering was also verified to leave exported children of discarded JPA parents; the default retains those parents. The core implementation was committed as `5b92e345321`. L [...]
+Implementation checkpoint: planning committed as `5390171e070`; all 11 implementation tasks completed. [Validation evidence](../changes/archive/2026-10-05-standardize-observation-policy/validation.md) records 43 focused tests, 8 JPA/export tests, 22 existing regression tests and the documentation build. Explicit Boot filtering was also verified to leave exported children of discarded JPA parents; the default retains those parents. The core implementation was committed as `5b92e345321`. L [...]
+
+## Classification and correlation proposal (2026-10-06)
+
+Step 2 is captured in [classify-and-correlate-observations](../changes/classify-and-correlate-observations/proposal.md), based on local `6f10ac34cac` after CAUSEWAY-4096. CAUSEWAY-3975/4068 identify maintenance provenance; a new implementation ticket/branch has not been assigned. No implementation is included in this proposal.
+
+The design adapts current-span classification to main's shared Micrometer tracer for both Boot and agent ownership, retains `causeway.execution.mode.key`, and correlates only the existing root interaction observation. It explicitly handles main's replay-time replacement of command identity, avoids introducing job spans when instrumentation supplies none, and keeps semantic naming and rendering detail for later steps.
+
+Before creating the proposal, completed `port-domain-object-locking` was validated and [archived](../changes/archive/2026-10-06-port-domain-object-locking/proposal.md), with its requirements synced. The focused reactor verification passed, including all eight pessimistic-locking regressions. The archive checkpoint was committed as `e65505dbe0c` after session permissions allowed Git writes; the new planning artifacts follow in a separate proposal commit.