From fa1cd01aa2b93b5e943a88d215b797e338595784 Mon Sep 17 00:00:00 2001 From: Paul O'Fallon Date: Sun, 26 Jul 2026 00:29:32 +0000 Subject: [PATCH 1/2] fix(up): stamp the authored config in devcontainer.metadata, not this machine's paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deacon substituted variables before stamping the `devcontainer.metadata` container label, baking the recording machine's absolute workspace path into metadata whose whole purpose is to describe the configuration to whoever reads the container later. The deciding evidence is the spec, not the reference. `image-metadata.md`'s Merge Logic closes with "Variables in string values will be substituted at the time the value is applied" — recording is not applying, so the recorded form is the template. T115 was planned as "match the reference" (`reference: divergent`); it is actually `spec: nonconformant`, and a waiver was never the right instrument. Measuring widened the defect and caught a second one: - Not just `mounts`. A probe fixture against pinned oracle 0.87.0 showed the reference stamps templates for `mounts`, `containerEnv`, `remoteEnv` AND `postCreateCommand`, while applying the substituted `containerEnv` value to `Config.Env`. Every picked string field was affected. - Fixing only the stamp would have traded one divergence for another. With templates in the label, the `--container-id` read-back surfaces them. The reference re-substitutes what it can there — `devcontainer exec --container-id` prints `LWF=[${localWorkspaceFolder}]` but `LENV=[/home/vscode]` — so `config_from_metadata_label` now applies a host-env-only pass. deacon previously matched on `${localEnv:…}` only by accident, because the value was baked in at stamp time. `load_with_overrides_and_substitution_raw` returns `(raw, substituted, report)` — the reference's `SubstitutedConfig { raw, config }` — surfaced as `ConfigLoadResult::raw_config`. `up` picks the metadata entry from it at the top of the flow and threads the resulting value into both stamp sites; `build_container_metadata_label` takes that value rather than a `&DevContainerConfig`, so a substituted config cannot be passed by accident. One normalization change, named and single-key. `case-state-dockerfile-nonroot` still diverged on nothing but key insertion order and two spaces, so `label_json_document` (scope `channel:chan-container-state`) parses the one label whose value IS a JSON document and compares it structurally. It removes nothing: every key and value is still compared, array order is preserved (fragment order carries precedence), a malformed value is left verbatim so it still diverges, and labels outside the enumerated set are untouched. Aligning deacon's pick order with upstream's `pickConfigProperties` would instead pin deacon to an implementation detail of the reference's serializer. `NORMALIZER_VERSION` 5 → 6, snapshot refreshed through the reviewed path. Also corrects a stale claim in lockstep: `POST_BRANCH_BEHAVIORS` and `src-obs-container-identity-labels` both asserted `devcontainer.metadata` "compares byte-equal". True on `fx-up-basic`, whose bare config contributes no picked property so both sides stamp `[]` — false in general. A measurement on one fixture is not a claim about the field, which is the lesson the keep-alive `cmd` field taught in T114. All three `case-state-*` cases now report `agree` on the live differential; the docker-gated regression test was demonstrated to fail on the pre-fix behavior. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017UCGzJEFZd85HJqz1Wx2kE --- .../registry/behaviors/observable-state.json | 12 +- conformance/registry/cases.json | 139 ++++++------- conformance/registry/sources/observed.json | 12 +- conformance/registry/sources/spec.json | 19 +- .../case-readconfig-snapshot/provenance.json | 4 +- crates/conformance/src/conservation.rs | 51 ++++- crates/conformance/src/snapshot.rs | 6 +- crates/core/src/config.rs | 96 ++++++++- crates/core/src/container_env_probe.rs | 7 + crates/core/src/variable.rs | 50 ++++- .../src/commands/shared/config_loader.rs | 27 ++- .../src/commands/shared/container_metadata.rs | 102 +++++++++- crates/deacon/src/commands/up/compose.rs | 6 +- crates/deacon/src/commands/up/container.rs | 14 +- .../deacon/src/commands/up/merged_config.rs | 68 ++++++- crates/deacon/src/commands/up/mod.rs | 19 ++ .../tests/integration_up_exec_identity.rs | 97 +++++++++ crates/parity-harness/src/normalize.rs | 186 +++++++++++++++++- .../plan-phase2.md | 24 ++- .../tasks.md | 8 +- 20 files changed, 828 insertions(+), 119 deletions(-) diff --git a/conformance/registry/behaviors/observable-state.json b/conformance/registry/behaviors/observable-state.json index f41f1425..f79b0f14 100644 --- a/conformance/registry/behaviors/observable-state.json +++ b/conformance/registry/behaviors/observable-state.json @@ -29,7 +29,17 @@ "spec": "unspecified", "reference": "divergent", "decision": "intentional-divergence", - "notes": "Classified as intentional ONLY because the observable behavior was measured equal, not because the difference looks cosmetic. `docker stop`: deacon 245 ms (single-container) / 138 ms (compose), reference 215 ms; exit code 0 on both sides. deacon's form additionally keeps the BusyBox fallback (`sleep infinity` is rejected on Alpine) and a PATH prefix that survives a feature rewriting PATH. An EARLIER version of this same field was recorded as intentional on the assumption that a keep-alive cannot matter behaviorally \u2014 that assumption was false and hid a 10,258 ms `docker stop` stall with a SIGKILL exit (fixed in e243921, T114). The difference between then and now is that the equivalence is measured." + "notes": "Classified as intentional ONLY because the observable behavior was measured equal, not because the difference looks cosmetic. `docker stop`: deacon 245 ms (single-container) / 138 ms (compose), reference 215 ms; exit code 0 on both sides. deacon's form additionally keeps the BusyBox fallback (`sleep infinity` is rejected on Alpine) and a PATH prefix that survives a feature rewriting PATH. An EARLIER version of this same field was recorded as intentional on the assumption that a keep-alive cannot matter behaviorally — that assumption was false and hid a 10,258 ms `docker stop` stall with a SIGKILL exit (fixed in e243921, T114). The difference between then and now is that the equivalence is measured." + }, + { + "id": "bhv-container-metadata-label-authored", + "area": "observable-state", + "statement": "The `devcontainer.metadata` label stamped on a created container records the AUTHORED configuration fragments with their variable templates intact (`${localWorkspaceFolder}`, `${containerWorkspaceFolder}`, `${localEnv:VAR}`), while the substituted values are applied to the container itself; reading the label back on the `--container-id` path re-substitutes only the tokens resolvable without a workspace (`${localEnv:VAR}` / `${env:VAR}`), leaving workspace- and container-relative tokens literal.", + "applicability": [], + "spec": "conformant", + "reference": "aligned", + "decision": "follow-spec", + "notes": "Spec-mandated, not merely reference parity: image-metadata.md's Merge Logic closes with \"Variables in string values will be substituted at the time the value is applied\" (clu-image-metadata-variables-in-string-values-will-be-substituted-a-desc-31cd8289), so the recorded form is the template and substitution belongs to application. deacon violated this until T115: `up::merged_config::config_metadata_entry` ran on the already-substituted config, baking the recording machine's absolute path into container metadata and making the label wrong for any later reader. Measured against pinned oracle 0.87.0 on fx-state-single-container: deacon wrote \"source=/tmp/.../ws/sib,target=/workspaces/sib,type=bind\" where the reference wrote \"source=${localWorkspaceFolder}/sib,...\"; a probe fixture confirmed the reference stamps templates for `mounts`, `containerEnv`, `remoteEnv` and `postCreateCommand` alike while applying the substituted `containerEnv` value to `Config.Env`. Normalization could not paper over it — tokenizing deacon's side yields `/sib`, a different FORM — so it was fixed by threading the pre-substitution config (ConfigLoadResult::raw_config, the reference's SubstitutedConfig.raw) to the stamp site. The read-back half was measured too: `devcontainer exec --container-id` over a labelled container prints `LWF=[${localWorkspaceFolder}] LENV=[/home/vscode]`, so deacon applies the same host-env-only pass rather than trading one divergence for another. Backed by case-state-single-container / case-state-mount-variety / case-state-dockerfile-nonroot, which reported `diverge` on this path until the fix." }, { "id": "bhv-state-container-parity", diff --git a/conformance/registry/cases.json b/conformance/registry/cases.json index 7aed0d9b..973b72f5 100644 --- a/conformance/registry/cases.json +++ b/conformance/registry/cases.json @@ -127,7 +127,7 @@ "cleanup": { "tempdir": true }, - "notes": "Decision half of the migrated `parity_corpus_errors::bad-config-path` unit (023 T036/T072). The unit's `both-reject` waiver asserts TWO things \u2014 that deacon rejects an explicit --config that does not exist, AND that the reference agrees \u2014 and one oracleType cannot express both. The sibling live-differential case `case-errors-decl-bad-config-path` keeps the cross-CLI agreement; this case PINS deacon's own rejection and its diagnostic, so the rejection cannot quietly evaporate if both CLIs ever start accepting the input. The two are variants of one behavior, distinguished by oracle type." + "notes": "Decision half of the migrated `parity_corpus_errors::bad-config-path` unit (023 T036/T072). The unit's `both-reject` waiver asserts TWO things — that deacon rejects an explicit --config that does not exist, AND that the reference agrees — and one oracleType cannot express both. The sibling live-differential case `case-errors-decl-bad-config-path` keeps the cross-CLI agreement; this case PINS deacon's own rejection and its diagnostic, so the rejection cannot quietly evaporate if both CLIs ever start accepting the input. The two are variants of one behavior, distinguished by oracle type." }, { "id": "case-errors-decl-duplicate-keys", @@ -162,7 +162,7 @@ "cleanup": { "tempdir": true }, - "notes": "Both CLIs accept duplicate top-level JSON keys with last-wins semantics and must resolve to the same document (waiver wvr-duplicate-keys, both-accept). The structured-output channel is compared under the declarative normalizer, which \u2014 unlike the legacy `prune` path \u2014 does NOT drop null/empty values (research D3); differences it surfaces are characterized in US4/T064, never re-hidden." + "notes": "Both CLIs accept duplicate top-level JSON keys with last-wins semantics and must resolve to the same document (waiver wvr-duplicate-keys, both-accept). The structured-output channel is compared under the declarative normalizer, which — unlike the legacy `prune` path — does NOT drop null/empty values (research D3); differences it surfaces are characterized in US4/T064, never re-hidden." }, { "id": "case-errors-decl-extends-cycle", @@ -203,7 +203,7 @@ "cleanup": { "tempdir": true }, - "notes": "deacon rejects a cyclic extends chain where the reference leniently accepts it \u2014 an intentional divergence characterized by waiver wvr-extends-cycle (deacon-stricter). Expressed as a spec-expectation on deacon's own decision: the exit-code channel is scalar, so a live-differential divergence there would need a bare-channel tolerance, which is rejected as a global ignore (FR-032)." + "notes": "deacon rejects a cyclic extends chain where the reference leniently accepts it — an intentional divergence characterized by waiver wvr-extends-cycle (deacon-stricter). Expressed as a spec-expectation on deacon's own decision: the exit-code channel is scalar, so a live-differential divergence there would need a bare-channel tolerance, which is rejected as a global ignore (FR-032)." }, { "id": "case-errors-decl-extends-missing", @@ -244,7 +244,7 @@ "cleanup": { "tempdir": true }, - "notes": "deacon rejects an extends target that does not exist where the reference leniently accepts it \u2014 waiver wvr-extends-missing (deacon-stricter). See case-errors-decl-extends-cycle for why this is a spec-expectation." + "notes": "deacon rejects an extends target that does not exist where the reference leniently accepts it — waiver wvr-extends-missing (deacon-stricter). See case-errors-decl-extends-cycle for why this is a spec-expectation." }, { "id": "case-errors-decl-malformed-json", @@ -285,7 +285,7 @@ "cleanup": { "tempdir": true }, - "notes": "deacon rejects a hard JSONC syntax error where the reference leniently recovers \u2014 waiver wvr-malformed-json (deacon-stricter). See case-errors-decl-extends-cycle for why this is a spec-expectation." + "notes": "deacon rejects a hard JSONC syntax error where the reference leniently recovers — waiver wvr-malformed-json (deacon-stricter). See case-errors-decl-extends-cycle for why this is a spec-expectation." }, { "id": "case-errors-decl-missing-config", @@ -357,7 +357,7 @@ "cleanup": { "tempdir": true }, - "notes": "Decision half of the migrated `parity_corpus_errors::missing-config` unit (023 T036/T072). The unit's `both-reject` waiver asserts TWO things \u2014 that deacon rejects a workspace with no devcontainer configuration, AND that the reference agrees \u2014 and one oracleType cannot express both. The sibling live-differential case `case-errors-decl-missing-config` keeps the cross-CLI agreement; this case PINS deacon's own rejection and its diagnostic, so the rejection cannot quietly evaporate if both CLIs ever start accepting the input. The two are variants of one behavior, distinguished by oracle type." + "notes": "Decision half of the migrated `parity_corpus_errors::missing-config` unit (023 T036/T072). The unit's `both-reject` waiver asserts TWO things — that deacon rejects a workspace with no devcontainer configuration, AND that the reference agrees — and one oracleType cannot express both. The sibling live-differential case `case-errors-decl-missing-config` keeps the cross-CLI agreement; this case PINS deacon's own rejection and its diagnostic, so the rejection cannot quietly evaporate if both CLIs ever start accepting the input. The two are variants of one behavior, distinguished by oracle type." }, { "id": "case-errors-decl-unknown-field-preserved", @@ -433,7 +433,7 @@ "cleanup": { "tempdir": true }, - "notes": "deacon rejects a `features` value that is a bare string where the reference accepts it \u2014 waiver wvr-wrong-type-features (deacon-stricter)." + "notes": "deacon rejects a `features` value that is a bare string where the reference accepts it — waiver wvr-wrong-type-features (deacon-stricter)." }, { "id": "case-errors-decl-wrong-type-forwardports", @@ -474,7 +474,7 @@ "cleanup": { "tempdir": true }, - "notes": "deacon rejects a `forwardPorts` value that is a bare string where the reference accepts it \u2014 waiver wvr-wrong-type-forwardports (deacon-stricter)." + "notes": "deacon rejects a `forwardPorts` value that is a bare string where the reference accepts it — waiver wvr-wrong-type-forwardports (deacon-stricter)." }, { "id": "case-exec-decl-tty", @@ -702,7 +702,7 @@ "cleanup": { "tempdir": true }, - "notes": "Merged-mode VARIANT of the tier-1 corpus case `bare-base-node-feature`: the same workspace and the same behavior, distinguished only by the `--include-merged-configuration` input shape. A variant adds a case, never a behavior (T039/SC-005). The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which \u2014 unlike the legacy `prune` path \u2014 does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." + "notes": "Merged-mode VARIANT of the tier-1 corpus case `bare-base-node-feature`: the same workspace and the same behavior, distinguished only by the `--include-merged-configuration` input shape. A variant adds a case, never a behavior (T039/SC-005). The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which — unlike the legacy `prune` path — does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." }, { "id": "case-merged-decl-build-args-subst", @@ -738,7 +738,7 @@ "cleanup": { "tempdir": true }, - "notes": "Merged-mode VARIANT of the tier-1 corpus case `build-args-subst`: the same workspace and the same behavior, distinguished only by the `--include-merged-configuration` input shape. A variant adds a case, never a behavior (T039/SC-005). The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which \u2014 unlike the legacy `prune` path \u2014 does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." + "notes": "Merged-mode VARIANT of the tier-1 corpus case `build-args-subst`: the same workspace and the same behavior, distinguished only by the `--include-merged-configuration` input shape. A variant adds a case, never a behavior (T039/SC-005). The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which — unlike the legacy `prune` path — does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." }, { "id": "case-merged-decl-compose-array", @@ -774,7 +774,7 @@ "cleanup": { "tempdir": true }, - "notes": "Merged-mode VARIANT of the tier-1 corpus case `compose-array`: the same workspace and the same behavior, distinguished only by the `--include-merged-configuration` input shape. A variant adds a case, never a behavior (T039/SC-005). The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which \u2014 unlike the legacy `prune` path \u2014 does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." + "notes": "Merged-mode VARIANT of the tier-1 corpus case `compose-array`: the same workspace and the same behavior, distinguished only by the `--include-merged-configuration` input shape. A variant adds a case, never a behavior (T039/SC-005). The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which — unlike the legacy `prune` path — does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." }, { "id": "case-merged-decl-compose-postgres", @@ -810,7 +810,7 @@ "cleanup": { "tempdir": true }, - "notes": "Merged-mode VARIANT of the tier-1 corpus case `compose-postgres`: the same workspace and the same behavior, distinguished only by the `--include-merged-configuration` input shape. A variant adds a case, never a behavior (T039/SC-005). The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which \u2014 unlike the legacy `prune` path \u2014 does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." + "notes": "Merged-mode VARIANT of the tier-1 corpus case `compose-postgres`: the same workspace and the same behavior, distinguished only by the `--include-merged-configuration` input shape. A variant adds a case, never a behavior (T039/SC-005). The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which — unlike the legacy `prune` path — does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." }, { "id": "case-merged-decl-containerenv-subst", @@ -846,7 +846,7 @@ "cleanup": { "tempdir": true }, - "notes": "Merged-mode VARIANT of the tier-1 corpus case `containerenv-subst`: the same workspace and the same behavior, distinguished only by the `--include-merged-configuration` input shape. A variant adds a case, never a behavior (T039/SC-005). The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which \u2014 unlike the legacy `prune` path \u2014 does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." + "notes": "Merged-mode VARIANT of the tier-1 corpus case `containerenv-subst`: the same workspace and the same behavior, distinguished only by the `--include-merged-configuration` input shape. A variant adds a case, never a behavior (T039/SC-005). The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which — unlike the legacy `prune` path — does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." }, { "id": "case-merged-decl-dependson-autoinstall", @@ -882,7 +882,7 @@ "cleanup": { "tempdir": true }, - "notes": "Merged-mode VARIANT of the tier-1 corpus case `dependson-autoinstall`: the same workspace and the same behavior, distinguished only by the `--include-merged-configuration` input shape. A variant adds a case, never a behavior (T039/SC-005). The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which \u2014 unlike the legacy `prune` path \u2014 does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." + "notes": "Merged-mode VARIANT of the tier-1 corpus case `dependson-autoinstall`: the same workspace and the same behavior, distinguished only by the `--include-merged-configuration` input shape. A variant adds a case, never a behavior (T039/SC-005). The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which — unlike the legacy `prune` path — does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." }, { "id": "case-merged-decl-dockerfile-build", @@ -918,7 +918,7 @@ "cleanup": { "tempdir": true }, - "notes": "Merged-mode VARIANT of the tier-1 corpus case `dockerfile-build`: the same workspace and the same behavior, distinguished only by the `--include-merged-configuration` input shape. A variant adds a case, never a behavior (T039/SC-005). The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which \u2014 unlike the legacy `prune` path \u2014 does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." + "notes": "Merged-mode VARIANT of the tier-1 corpus case `dockerfile-build`: the same workspace and the same behavior, distinguished only by the `--include-merged-configuration` input shape. A variant adds a case, never a behavior (T039/SC-005). The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which — unlike the legacy `prune` path — does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." }, { "id": "case-merged-decl-dotnet-mounts", @@ -954,7 +954,7 @@ "cleanup": { "tempdir": true }, - "notes": "Merged-mode VARIANT of the tier-1 corpus case `dotnet-mounts`: the same workspace and the same behavior, distinguished only by the `--include-merged-configuration` input shape. A variant adds a case, never a behavior (T039/SC-005). The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which \u2014 unlike the legacy `prune` path \u2014 does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." + "notes": "Merged-mode VARIANT of the tier-1 corpus case `dotnet-mounts`: the same workspace and the same behavior, distinguished only by the `--include-merged-configuration` input shape. A variant adds a case, never a behavior (T039/SC-005). The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which — unlike the legacy `prune` path — does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." }, { "id": "case-merged-decl-extends-child", @@ -1000,7 +1000,7 @@ "cleanup": { "tempdir": true }, - "notes": "Merged-mode VARIANT of the tier-1 corpus case `extends-child`, and the one unit whose recorded expectation is a REFERENCE rejection: deacon resolves the full `extends` chain eagerly under --include-merged-configuration where the reference errors \u2014 the ahead-of-spec capability recorded as ext-extends-resolution and characterized by waiver wvr-extends-child-merged, preserved unchanged (023 T044). Expressed as a spec-expectation, NOT a live-differential: a differential asserts the two sides are EQUAL, which both inverts this expectation into a guaranteed failure and pins nothing about deacon's own side. It pins deacon succeeding and resolving the base image from the extends chain \u2014 the waiver's recorded `image` signal \u2014 so the ahead-of-spec resolution cannot silently regress (023 T072). The reference-side rejection stays characterized by the waiver." + "notes": "Merged-mode VARIANT of the tier-1 corpus case `extends-child`, and the one unit whose recorded expectation is a REFERENCE rejection: deacon resolves the full `extends` chain eagerly under --include-merged-configuration where the reference errors — the ahead-of-spec capability recorded as ext-extends-resolution and characterized by waiver wvr-extends-child-merged, preserved unchanged (023 T044). Expressed as a spec-expectation, NOT a live-differential: a differential asserts the two sides are EQUAL, which both inverts this expectation into a guaranteed failure and pins nothing about deacon's own side. It pins deacon succeeding and resolving the base image from the extends chain — the waiver's recorded `image` signal — so the ahead-of-spec resolution cannot silently regress (023 T072). The reference-side rejection stays characterized by the waiver." }, { "id": "case-merged-decl-feature-order", @@ -1036,7 +1036,7 @@ "cleanup": { "tempdir": true }, - "notes": "Merged-mode VARIANT of the tier-1 corpus case `feature-order`: the same workspace and the same behavior, distinguished only by the `--include-merged-configuration` input shape. A variant adds a case, never a behavior (T039/SC-005). The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which \u2014 unlike the legacy `prune` path \u2014 does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." + "notes": "Merged-mode VARIANT of the tier-1 corpus case `feature-order`: the same workspace and the same behavior, distinguished only by the `--include-merged-configuration` input shape. A variant adds a case, never a behavior (T039/SC-005). The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which — unlike the legacy `prune` path — does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." }, { "id": "case-merged-decl-go-minimal", @@ -1072,7 +1072,7 @@ "cleanup": { "tempdir": true }, - "notes": "Merged-mode VARIANT of the tier-1 corpus case `go-minimal`: the same workspace and the same behavior, distinguished only by the `--include-merged-configuration` input shape. A variant adds a case, never a behavior (T039/SC-005). The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which \u2014 unlike the legacy `prune` path \u2014 does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." + "notes": "Merged-mode VARIANT of the tier-1 corpus case `go-minimal`: the same workspace and the same behavior, distinguished only by the `--include-merged-configuration` input shape. A variant adds a case, never a behavior (T039/SC-005). The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which — unlike the legacy `prune` path — does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." }, { "id": "case-merged-decl-init-privileged", @@ -1108,7 +1108,7 @@ "cleanup": { "tempdir": true }, - "notes": "Merged-mode VARIANT of the tier-1 corpus case `init-privileged`: the same workspace and the same behavior, distinguished only by the `--include-merged-configuration` input shape. A variant adds a case, never a behavior (T039/SC-005). The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which \u2014 unlike the legacy `prune` path \u2014 does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." + "notes": "Merged-mode VARIANT of the tier-1 corpus case `init-privileged`: the same workspace and the same behavior, distinguished only by the `--include-merged-configuration` input shape. A variant adds a case, never a behavior (T039/SC-005). The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which — unlike the legacy `prune` path — does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." }, { "id": "case-merged-decl-lifecycle-arrays", @@ -1144,7 +1144,7 @@ "cleanup": { "tempdir": true }, - "notes": "Merged-mode VARIANT of the tier-1 corpus case `lifecycle-arrays`: the same workspace and the same behavior, distinguished only by the `--include-merged-configuration` input shape. A variant adds a case, never a behavior (T039/SC-005). The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which \u2014 unlike the legacy `prune` path \u2014 does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." + "notes": "Merged-mode VARIANT of the tier-1 corpus case `lifecycle-arrays`: the same workspace and the same behavior, distinguished only by the `--include-merged-configuration` input shape. A variant adds a case, never a behavior (T039/SC-005). The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which — unlike the legacy `prune` path — does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." }, { "id": "case-merged-decl-lifecycle-mixed", @@ -1180,7 +1180,7 @@ "cleanup": { "tempdir": true }, - "notes": "Merged-mode VARIANT of the tier-1 corpus case `lifecycle-mixed`: the same workspace and the same behavior, distinguished only by the `--include-merged-configuration` input shape. A variant adds a case, never a behavior (T039/SC-005). The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which \u2014 unlike the legacy `prune` path \u2014 does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." + "notes": "Merged-mode VARIANT of the tier-1 corpus case `lifecycle-mixed`: the same workspace and the same behavior, distinguished only by the `--include-merged-configuration` input shape. A variant adds a case, never a behavior (T039/SC-005). The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which — unlike the legacy `prune` path — does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." }, { "id": "case-merged-decl-mounts-bind-localenv", @@ -1216,7 +1216,7 @@ "cleanup": { "tempdir": true }, - "notes": "Merged-mode VARIANT of the tier-1 corpus case `mounts-bind-localenv`: the same workspace and the same behavior, distinguished only by the `--include-merged-configuration` input shape. A variant adds a case, never a behavior (T039/SC-005). The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which \u2014 unlike the legacy `prune` path \u2014 does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." + "notes": "Merged-mode VARIANT of the tier-1 corpus case `mounts-bind-localenv`: the same workspace and the same behavior, distinguished only by the `--include-merged-configuration` input shape. A variant adds a case, never a behavior (T039/SC-005). The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which — unlike the legacy `prune` path — does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." }, { "id": "case-merged-decl-name-subst", @@ -1252,7 +1252,7 @@ "cleanup": { "tempdir": true }, - "notes": "Merged-mode VARIANT of the tier-1 corpus case `name-subst`: the same workspace and the same behavior, distinguished only by the `--include-merged-configuration` input shape. A variant adds a case, never a behavior (T039/SC-005). The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which \u2014 unlike the legacy `prune` path \u2014 does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." + "notes": "Merged-mode VARIANT of the tier-1 corpus case `name-subst`: the same workspace and the same behavior, distinguished only by the `--include-merged-configuration` input shape. A variant adds a case, never a behavior (T039/SC-005). The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which — unlike the legacy `prune` path — does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." }, { "id": "case-merged-decl-node-ts", @@ -1288,7 +1288,7 @@ "cleanup": { "tempdir": true }, - "notes": "Merged-mode VARIANT of the tier-1 corpus case `node-ts`: the same workspace and the same behavior, distinguished only by the `--include-merged-configuration` input shape. A variant adds a case, never a behavior (T039/SC-005). The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which \u2014 unlike the legacy `prune` path \u2014 does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." + "notes": "Merged-mode VARIANT of the tier-1 corpus case `node-ts`: the same workspace and the same behavior, distinguished only by the `--include-merged-configuration` input shape. A variant adds a case, never a behavior (T039/SC-005). The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which — unlike the legacy `prune` path — does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." }, { "id": "case-merged-decl-object-form-metadata", @@ -1324,7 +1324,7 @@ "cleanup": { "tempdir": true }, - "notes": "Merged-mode VARIANT of the tier-1 corpus case `object-form-metadata`: the same workspace and the same behavior, distinguished only by the `--include-merged-configuration` input shape. A variant adds a case, never a behavior (T039/SC-005). The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which \u2014 unlike the legacy `prune` path \u2014 does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." + "notes": "Merged-mode VARIANT of the tier-1 corpus case `object-form-metadata`: the same workspace and the same behavior, distinguished only by the `--include-merged-configuration` input shape. A variant adds a case, never a behavior (T039/SC-005). The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which — unlike the legacy `prune` path — does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." }, { "id": "case-merged-decl-ports-mixed", @@ -1360,7 +1360,7 @@ "cleanup": { "tempdir": true }, - "notes": "Merged-mode VARIANT of the tier-1 corpus case `ports-mixed`: the same workspace and the same behavior, distinguished only by the `--include-merged-configuration` input shape. A variant adds a case, never a behavior (T039/SC-005). The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which \u2014 unlike the legacy `prune` path \u2014 does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." + "notes": "Merged-mode VARIANT of the tier-1 corpus case `ports-mixed`: the same workspace and the same behavior, distinguished only by the `--include-merged-configuration` input shape. A variant adds a case, never a behavior (T039/SC-005). The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which — unlike the legacy `prune` path — does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." }, { "id": "case-merged-decl-python-features", @@ -1396,7 +1396,7 @@ "cleanup": { "tempdir": true }, - "notes": "Merged-mode VARIANT of the tier-1 corpus case `python-features`: the same workspace and the same behavior, distinguished only by the `--include-merged-configuration` input shape. A variant adds a case, never a behavior (T039/SC-005). The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which \u2014 unlike the legacy `prune` path \u2014 does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." + "notes": "Merged-mode VARIANT of the tier-1 corpus case `python-features`: the same workspace and the same behavior, distinguished only by the `--include-merged-configuration` input shape. A variant adds a case, never a behavior (T039/SC-005). The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which — unlike the legacy `prune` path — does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." }, { "id": "case-merged-decl-ruby-node-feature", @@ -1432,7 +1432,7 @@ "cleanup": { "tempdir": true }, - "notes": "Merged-mode VARIANT of the tier-1 corpus case `ruby-node-feature`: the same workspace and the same behavior, distinguished only by the `--include-merged-configuration` input shape. A variant adds a case, never a behavior (T039/SC-005). The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which \u2014 unlike the legacy `prune` path \u2014 does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." + "notes": "Merged-mode VARIANT of the tier-1 corpus case `ruby-node-feature`: the same workspace and the same behavior, distinguished only by the `--include-merged-configuration` input shape. A variant adds a case, never a behavior (T039/SC-005). The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which — unlike the legacy `prune` path — does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." }, { "id": "case-merged-decl-universal-jsonc", @@ -1468,7 +1468,7 @@ "cleanup": { "tempdir": true }, - "notes": "Merged-mode VARIANT of the tier-1 corpus case `universal-jsonc`: the same workspace and the same behavior, distinguished only by the `--include-merged-configuration` input shape. A variant adds a case, never a behavior (T039/SC-005). The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which \u2014 unlike the legacy `prune` path \u2014 does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." + "notes": "Merged-mode VARIANT of the tier-1 corpus case `universal-jsonc`: the same workspace and the same behavior, distinguished only by the `--include-merged-configuration` input shape. A variant adds a case, never a behavior (T039/SC-005). The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which — unlike the legacy `prune` path — does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." }, { "id": "case-merged-decl-user-mapping", @@ -1504,7 +1504,7 @@ "cleanup": { "tempdir": true }, - "notes": "Merged-mode VARIANT of the tier-1 corpus case `user-mapping`: the same workspace and the same behavior, distinguished only by the `--include-merged-configuration` input shape. A variant adds a case, never a behavior (T039/SC-005). The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which \u2014 unlike the legacy `prune` path \u2014 does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." + "notes": "Merged-mode VARIANT of the tier-1 corpus case `user-mapping`: the same workspace and the same behavior, distinguished only by the `--include-merged-configuration` input shape. A variant adds a case, never a behavior (T039/SC-005). The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which — unlike the legacy `prune` path — does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." }, { "id": "case-merged-decl-workspacefolder-custom", @@ -1540,7 +1540,7 @@ "cleanup": { "tempdir": true }, - "notes": "Merged-mode VARIANT of the tier-1 corpus case `workspacefolder-custom`: the same workspace and the same behavior, distinguished only by the `--include-merged-configuration` input shape. A variant adds a case, never a behavior (T039/SC-005). The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which \u2014 unlike the legacy `prune` path \u2014 does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." + "notes": "Merged-mode VARIANT of the tier-1 corpus case `workspacefolder-custom`: the same workspace and the same behavior, distinguished only by the `--include-merged-configuration` input shape. A variant adds a case, never a behavior (T039/SC-005). The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which — unlike the legacy `prune` path — does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." }, { "id": "case-observable-state", @@ -1737,7 +1737,7 @@ "cleanup": { "tempdir": true }, - "notes": "Declarative spec-expectation case (022-conformance-runner MVP): unknown top-level keys round-trip through read-configuration (Constitution IV faithful-on-unmodeled). No new Rust function \u2014 the shared runner verdicts it from this data." + "notes": "Declarative spec-expectation case (022-conformance-runner MVP): unknown top-level keys round-trip through read-configuration (Constitution IV faithful-on-unmodeled). No new Rust function — the shared runner verdicts it from this data." }, { "id": "case-readconfig-workspace-file-present", @@ -1781,7 +1781,7 @@ "cleanup": { "tempdir": true }, - "notes": "Declarative spec-expectation case (022-conformance-runner US3): exercises the allowlist-scoped filesystem observer \u2014 the workspace's .devcontainer/devcontainer.json is present after read-configuration. path_token normalization applies; no full-tree diff (clarify Q1)." + "notes": "Declarative spec-expectation case (022-conformance-runner US3): exercises the allowlist-scoped filesystem observer — the workspace's .devcontainer/devcontainer.json is present after read-configuration. path_token normalization applies; no full-tree diff (clarify Q1)." }, { "id": "case-secrets-dotenv", @@ -1902,6 +1902,7 @@ "behaviors": [ "bhv-container-identity-labels", "bhv-container-keepalive-command", + "bhv-container-metadata-label-authored", "bhv-state-diff-parity" ], "context": [], @@ -1977,13 +1978,14 @@ "volumes": true, "tempdir": true }, - "notes": "Migrated from parity_state_diff::dockerfile-build-and-nonroot-user (024 Phase 5). A Dockerfile-built workspace with a non-root `containerUser`/`remoteUser` yields equivalent container state in both CLIs \u2014 the built image's ENV, the resolved user, and the workspace bind all compare. Image pinned to debian:bookworm-slim (V18)." + "notes": "Migrated from parity_state_diff::dockerfile-build-and-nonroot-user (024 Phase 5). A Dockerfile-built workspace with a non-root `containerUser`/`remoteUser` yields equivalent container state in both CLIs — the built image's ENV, the resolved user, and the workspace bind all compare. Image pinned to debian:bookworm-slim (V18). T115: also evidences bhv-container-metadata-label-authored — this case reported `diverge` on chan-container-state.labels.devcontainer.metadata until deacon was fixed to stamp the pre-substitution config." }, { "id": "case-state-mount-variety", "behaviors": [ "bhv-container-identity-labels", "bhv-container-keepalive-command", + "bhv-container-metadata-label-authored", "bhv-state-diff-parity" ], "context": [], @@ -2059,13 +2061,14 @@ "volumes": true, "tempdir": true }, - "notes": "Migrated from parity_state_diff::mount-variety-readonly-and-tmpfs (024 Phase 5). A read-only bind mount and a tmpfs mount are applied identically by both CLIs \u2014 the read-only flag and the tmpfs target both appear in the compared mount set. Image pinned to debian:bookworm-slim (V18)." + "notes": "Migrated from parity_state_diff::mount-variety-readonly-and-tmpfs (024 Phase 5). A read-only bind mount and a tmpfs mount are applied identically by both CLIs — the read-only flag and the tmpfs target both appear in the compared mount set. Image pinned to debian:bookworm-slim (V18). T115: also evidences bhv-container-metadata-label-authored — this case reported `diverge` on chan-container-state.labels.devcontainer.metadata until deacon was fixed to stamp the pre-substitution config." }, { "id": "case-state-single-container", "behaviors": [ "bhv-container-identity-labels", "bhv-container-keepalive-command", + "bhv-container-metadata-label-authored", "bhv-state-diff-parity" ], "context": [], @@ -2141,7 +2144,7 @@ "volumes": true, "tempdir": true }, - "notes": "Migrated from parity_state_diff::single-container-parity (024 Phase 5). deacon and the reference produce equivalent inspected container state for a single-container workspace: the `workspaceMount` bind, a second `mounts` bind at /workspaces/sib, and `containerEnv` SC_ENV. Image pinned to debian:bookworm-slim (V18)." + "notes": "Migrated from parity_state_diff::single-container-parity (024 Phase 5). deacon and the reference produce equivalent inspected container state for a single-container workspace: the `workspaceMount` bind, a second `mounts` bind at /workspaces/sib, and `containerEnv` SC_ENV. Image pinned to debian:bookworm-slim (V18). T115: also evidences bhv-container-metadata-label-authored — this case reported `diverge` on chan-container-state.labels.devcontainer.metadata until deacon was fixed to stamp the pre-substitution config." }, { "id": "case-tier1-decl-bare-base-node-feature", @@ -2176,7 +2179,7 @@ "cleanup": { "tempdir": true }, - "notes": "Tier-1 corpus case `bare-base-node-feature`: deacon and the pinned reference resolve the same configuration for this workspace. The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which \u2014 unlike the legacy `prune` path \u2014 does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." + "notes": "Tier-1 corpus case `bare-base-node-feature`: deacon and the pinned reference resolve the same configuration for this workspace. The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which — unlike the legacy `prune` path — does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." }, { "id": "case-tier1-decl-build-args-subst", @@ -2211,7 +2214,7 @@ "cleanup": { "tempdir": true }, - "notes": "Tier-1 corpus case `build-args-subst`: deacon and the pinned reference resolve the same configuration for this workspace. The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which \u2014 unlike the legacy `prune` path \u2014 does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." + "notes": "Tier-1 corpus case `build-args-subst`: deacon and the pinned reference resolve the same configuration for this workspace. The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which — unlike the legacy `prune` path — does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." }, { "id": "case-tier1-decl-compose-array", @@ -2246,7 +2249,7 @@ "cleanup": { "tempdir": true }, - "notes": "Tier-1 corpus case `compose-array`: deacon and the pinned reference resolve the same configuration for this workspace. The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which \u2014 unlike the legacy `prune` path \u2014 does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." + "notes": "Tier-1 corpus case `compose-array`: deacon and the pinned reference resolve the same configuration for this workspace. The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which — unlike the legacy `prune` path — does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." }, { "id": "case-tier1-decl-compose-postgres", @@ -2281,7 +2284,7 @@ "cleanup": { "tempdir": true }, - "notes": "Tier-1 corpus case `compose-postgres`: deacon and the pinned reference resolve the same configuration for this workspace. The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which \u2014 unlike the legacy `prune` path \u2014 does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." + "notes": "Tier-1 corpus case `compose-postgres`: deacon and the pinned reference resolve the same configuration for this workspace. The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which — unlike the legacy `prune` path — does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." }, { "id": "case-tier1-decl-containerenv-subst", @@ -2316,7 +2319,7 @@ "cleanup": { "tempdir": true }, - "notes": "Tier-1 corpus case `containerenv-subst`: deacon and the pinned reference resolve the same configuration for this workspace. The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which \u2014 unlike the legacy `prune` path \u2014 does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." + "notes": "Tier-1 corpus case `containerenv-subst`: deacon and the pinned reference resolve the same configuration for this workspace. The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which — unlike the legacy `prune` path — does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." }, { "id": "case-tier1-decl-dependson-autoinstall", @@ -2351,7 +2354,7 @@ "cleanup": { "tempdir": true }, - "notes": "Tier-1 corpus case `dependson-autoinstall`: deacon and the pinned reference resolve the same configuration for this workspace. The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which \u2014 unlike the legacy `prune` path \u2014 does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." + "notes": "Tier-1 corpus case `dependson-autoinstall`: deacon and the pinned reference resolve the same configuration for this workspace. The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which — unlike the legacy `prune` path — does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." }, { "id": "case-tier1-decl-dockerfile-build", @@ -2386,7 +2389,7 @@ "cleanup": { "tempdir": true }, - "notes": "Tier-1 corpus case `dockerfile-build`: deacon and the pinned reference resolve the same configuration for this workspace. The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which \u2014 unlike the legacy `prune` path \u2014 does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." + "notes": "Tier-1 corpus case `dockerfile-build`: deacon and the pinned reference resolve the same configuration for this workspace. The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which — unlike the legacy `prune` path — does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." }, { "id": "case-tier1-decl-dotnet-mounts", @@ -2421,7 +2424,7 @@ "cleanup": { "tempdir": true }, - "notes": "Tier-1 corpus case `dotnet-mounts`: deacon and the pinned reference resolve the same configuration for this workspace. The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which \u2014 unlike the legacy `prune` path \u2014 does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." + "notes": "Tier-1 corpus case `dotnet-mounts`: deacon and the pinned reference resolve the same configuration for this workspace. The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which — unlike the legacy `prune` path — does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." }, { "id": "case-tier1-decl-extends-child", @@ -2456,7 +2459,7 @@ "cleanup": { "tempdir": true }, - "notes": "Tier-1 corpus case `extends-child`: deacon and the pinned reference resolve the same configuration for this workspace. The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which \u2014 unlike the legacy `prune` path \u2014 does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." + "notes": "Tier-1 corpus case `extends-child`: deacon and the pinned reference resolve the same configuration for this workspace. The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which — unlike the legacy `prune` path — does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." }, { "id": "case-tier1-decl-feature-order", @@ -2491,7 +2494,7 @@ "cleanup": { "tempdir": true }, - "notes": "Tier-1 corpus case `feature-order`: deacon and the pinned reference resolve the same configuration for this workspace. The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which \u2014 unlike the legacy `prune` path \u2014 does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." + "notes": "Tier-1 corpus case `feature-order`: deacon and the pinned reference resolve the same configuration for this workspace. The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which — unlike the legacy `prune` path — does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." }, { "id": "case-tier1-decl-go-minimal", @@ -2526,7 +2529,7 @@ "cleanup": { "tempdir": true }, - "notes": "Tier-1 corpus case `go-minimal`: deacon and the pinned reference resolve the same configuration for this workspace. The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which \u2014 unlike the legacy `prune` path \u2014 does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." + "notes": "Tier-1 corpus case `go-minimal`: deacon and the pinned reference resolve the same configuration for this workspace. The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which — unlike the legacy `prune` path — does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." }, { "id": "case-tier1-decl-init-privileged", @@ -2561,7 +2564,7 @@ "cleanup": { "tempdir": true }, - "notes": "Tier-1 corpus case `init-privileged`: deacon and the pinned reference resolve the same configuration for this workspace. The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which \u2014 unlike the legacy `prune` path \u2014 does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." + "notes": "Tier-1 corpus case `init-privileged`: deacon and the pinned reference resolve the same configuration for this workspace. The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which — unlike the legacy `prune` path — does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." }, { "id": "case-tier1-decl-lifecycle-arrays", @@ -2596,7 +2599,7 @@ "cleanup": { "tempdir": true }, - "notes": "Tier-1 corpus case `lifecycle-arrays`: deacon and the pinned reference resolve the same configuration for this workspace. The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which \u2014 unlike the legacy `prune` path \u2014 does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." + "notes": "Tier-1 corpus case `lifecycle-arrays`: deacon and the pinned reference resolve the same configuration for this workspace. The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which — unlike the legacy `prune` path — does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." }, { "id": "case-tier1-decl-lifecycle-mixed", @@ -2631,7 +2634,7 @@ "cleanup": { "tempdir": true }, - "notes": "Tier-1 corpus case `lifecycle-mixed`: deacon and the pinned reference resolve the same configuration for this workspace. The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which \u2014 unlike the legacy `prune` path \u2014 does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." + "notes": "Tier-1 corpus case `lifecycle-mixed`: deacon and the pinned reference resolve the same configuration for this workspace. The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which — unlike the legacy `prune` path — does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." }, { "id": "case-tier1-decl-mounts-bind-localenv", @@ -2666,7 +2669,7 @@ "cleanup": { "tempdir": true }, - "notes": "Tier-1 corpus case `mounts-bind-localenv`: deacon and the pinned reference resolve the same configuration for this workspace. The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which \u2014 unlike the legacy `prune` path \u2014 does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." + "notes": "Tier-1 corpus case `mounts-bind-localenv`: deacon and the pinned reference resolve the same configuration for this workspace. The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which — unlike the legacy `prune` path — does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." }, { "id": "case-tier1-decl-name-subst", @@ -2701,7 +2704,7 @@ "cleanup": { "tempdir": true }, - "notes": "Tier-1 corpus case `name-subst`: deacon and the pinned reference resolve the same configuration for this workspace. The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which \u2014 unlike the legacy `prune` path \u2014 does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." + "notes": "Tier-1 corpus case `name-subst`: deacon and the pinned reference resolve the same configuration for this workspace. The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which — unlike the legacy `prune` path — does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." }, { "id": "case-tier1-decl-node-ts", @@ -2736,7 +2739,7 @@ "cleanup": { "tempdir": true }, - "notes": "Tier-1 corpus case `node-ts`: deacon and the pinned reference resolve the same configuration for this workspace. The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which \u2014 unlike the legacy `prune` path \u2014 does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." + "notes": "Tier-1 corpus case `node-ts`: deacon and the pinned reference resolve the same configuration for this workspace. The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which — unlike the legacy `prune` path — does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." }, { "id": "case-tier1-decl-object-form-metadata", @@ -2771,7 +2774,7 @@ "cleanup": { "tempdir": true }, - "notes": "Tier-1 corpus case `object-form-metadata`: deacon and the pinned reference resolve the same configuration for this workspace. The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which \u2014 unlike the legacy `prune` path \u2014 does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." + "notes": "Tier-1 corpus case `object-form-metadata`: deacon and the pinned reference resolve the same configuration for this workspace. The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which — unlike the legacy `prune` path — does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." }, { "id": "case-tier1-decl-ports-mixed", @@ -2806,7 +2809,7 @@ "cleanup": { "tempdir": true }, - "notes": "Tier-1 corpus case `ports-mixed`: deacon and the pinned reference resolve the same configuration for this workspace. The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which \u2014 unlike the legacy `prune` path \u2014 does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." + "notes": "Tier-1 corpus case `ports-mixed`: deacon and the pinned reference resolve the same configuration for this workspace. The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which — unlike the legacy `prune` path — does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." }, { "id": "case-tier1-decl-python-features", @@ -2841,7 +2844,7 @@ "cleanup": { "tempdir": true }, - "notes": "Tier-1 corpus case `python-features`: deacon and the pinned reference resolve the same configuration for this workspace. The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which \u2014 unlike the legacy `prune` path \u2014 does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." + "notes": "Tier-1 corpus case `python-features`: deacon and the pinned reference resolve the same configuration for this workspace. The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which — unlike the legacy `prune` path — does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." }, { "id": "case-tier1-decl-ruby-node-feature", @@ -2876,7 +2879,7 @@ "cleanup": { "tempdir": true }, - "notes": "Tier-1 corpus case `ruby-node-feature`: deacon and the pinned reference resolve the same configuration for this workspace. The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which \u2014 unlike the legacy `prune` path \u2014 does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." + "notes": "Tier-1 corpus case `ruby-node-feature`: deacon and the pinned reference resolve the same configuration for this workspace. The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which — unlike the legacy `prune` path — does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." }, { "id": "case-tier1-decl-universal-jsonc", @@ -2911,7 +2914,7 @@ "cleanup": { "tempdir": true }, - "notes": "Tier-1 corpus case `universal-jsonc`: deacon and the pinned reference resolve the same configuration for this workspace. The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which \u2014 unlike the legacy `prune` path \u2014 does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." + "notes": "Tier-1 corpus case `universal-jsonc`: deacon and the pinned reference resolve the same configuration for this workspace. The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which — unlike the legacy `prune` path — does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." }, { "id": "case-tier1-decl-user-mapping", @@ -2946,7 +2949,7 @@ "cleanup": { "tempdir": true }, - "notes": "Tier-1 corpus case `user-mapping`: deacon and the pinned reference resolve the same configuration for this workspace. The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which \u2014 unlike the legacy `prune` path \u2014 does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." + "notes": "Tier-1 corpus case `user-mapping`: deacon and the pinned reference resolve the same configuration for this workspace. The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which — unlike the legacy `prune` path — does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." }, { "id": "case-tier1-decl-workspacefolder-custom", @@ -2981,7 +2984,7 @@ "cleanup": { "tempdir": true }, - "notes": "Tier-1 corpus case `workspacefolder-custom`: deacon and the pinned reference resolve the same configuration for this workspace. The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which \u2014 unlike the legacy `prune` path \u2014 does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." + "notes": "Tier-1 corpus case `workspacefolder-custom`: deacon and the pinned reference resolve the same configuration for this workspace. The structured-output channel is compared under the declarative normalizer (path_token + null_preserving), which — unlike the legacy `prune` path — does NOT drop null/empty values or `configFilePath` (research D3). Differences it surfaces are strictness improvements to characterize in US4/T064, never to re-hide." }, { "id": "case-trust-gate", @@ -3071,7 +3074,7 @@ "behavior": "bhv-container-keepalive-command", "context": [], "observablePath": "chan-container-state.cmd", - "rationale": "The two keep-alive command strings differ; the observable behavior does not. Measured, not assumed: `docker stop` completes in 245 ms with exit 0 for deacon and 215 ms with exit 0 for the reference. Scoped to `cmd` alone \u2014 `entrypoint` and every other container-state field stay compared.", + "rationale": "The two keep-alive command strings differ; the observable behavior does not. Measured, not assumed: `docker stop` completes in 245 ms with exit 0 for deacon and 215 ms with exit 0 for the reference. Scoped to `cmd` alone — `entrypoint` and every other container-state field stay compared.", "divergenceId": "bhv-container-keepalive-command" } ], @@ -3082,7 +3085,7 @@ "volumes": true, "tempdir": true }, - "notes": "Records the container-label surface both CLIs produce (024 Phase 5). Only the five deacon-only keys are excused; `local_folder`/`config_file` differ only by each side's own isolated temp workspace and are normalized by the workspace path token, so they must compare equal. This case's first live run reported `diverge` on two further paths \u2014 `labels.devcontainer.metadata` and `cmd` \u2014 and BOTH were fixed in deacon rather than tolerated (the cmd one was a 10s `docker stop` stall). They are now compared and must agree; no tolerance was added for either." + "notes": "Records the container-label surface both CLIs produce (024 Phase 5). Only the five deacon-only keys are excused; `local_folder`/`config_file` differ only by each side's own isolated temp workspace and are normalized by the workspace path token, so they must compare equal. This case's first live run reported `diverge` on two further paths — `labels.devcontainer.metadata` and `cmd` — and BOTH were fixed in deacon rather than tolerated (the cmd one was a 10s `docker stop` stall). They are now compared and must agree; no tolerance was added for either." }, { "id": "case-up-container-labels-stamped", @@ -3129,7 +3132,7 @@ "volumes": true, "tempdir": true }, - "notes": "Faithful destination for parity_observable_state::container-and-image-labels (024 Phase 5). The legacy test runs DEACON ONLY \u2014 it never invokes the oracle \u2014 and asserts four facts about deacon's own container labels, so a spec-expectation is its honest shape; a live-differential would silently claim cross-CLI agreement the unit never checked. Two corrections to the baseline record, which overstated it: the unit declares chan-image but inspects no image at all (only Config.Labels of the container), and its assertion text says \"and image\" for the same reason. The hash-valued labels (configHash, workspaceHash) are deliberately NOT pinned to literals here \u2014 they are derived from the workspace path, which differs per run; the sibling live-differential case covers them by comparison. Distinguished from that sibling by oracle type. Image pinned to debian:bookworm-slim (V18)." + "notes": "Faithful destination for parity_observable_state::container-and-image-labels (024 Phase 5). The legacy test runs DEACON ONLY — it never invokes the oracle — and asserts four facts about deacon's own container labels, so a spec-expectation is its honest shape; a live-differential would silently claim cross-CLI agreement the unit never checked. Two corrections to the baseline record, which overstated it: the unit declares chan-image but inspects no image at all (only Config.Labels of the container), and its assertion text says \"and image\" for the same reason. The hash-valued labels (configHash, workspaceHash) are deliberately NOT pinned to literals here — they are derived from the workspace path, which differs per run; the sibling live-differential case covers them by comparison. Distinguished from that sibling by oracle type. Image pinned to debian:bookworm-slim (V18)." }, { "id": "case-up-docker-channels", @@ -3206,7 +3209,7 @@ "volumes": true, "tempdir": true }, - "notes": "Declarative Docker-backed spec-expectation case (022-conformance-runner US5): `deacon up` in an ISOLATED external temp workspace (collision-resistant, guaranteed cleanup) exercises all four Docker channels \u2014 chan-image (deacon stamps devcontainer.source), chan-injected-process (containerEnv CONF_TOKEN injected), chan-process-graph (mount/network/volume graph captured), chan-temporal (container running). Image pinned to alpine:3.19 (V18)." + "notes": "Declarative Docker-backed spec-expectation case (022-conformance-runner US5): `deacon up` in an ISOLATED external temp workspace (collision-resistant, guaranteed cleanup) exercises all four Docker channels — chan-image (deacon stamps devcontainer.source), chan-injected-process (containerEnv CONF_TOKEN injected), chan-process-graph (mount/network/volume graph captured), chan-temporal (container running). Image pinned to alpine:3.19 (V18)." }, { "id": "case-up-exec-decl-traditional", @@ -3258,7 +3261,7 @@ "volumes": true, "tempdir": true }, - "notes": "Migrated from parity_up_exec::traditional: after `up` on a traditional (non-compose) workspace, both CLIs' `exec` reach that workspace's own container and observe the same postCreate markers \u2014 including the #332 parity that neither CLI substitutes `${containerEnv:VAR}` inside a lifecycle command string (both must print the same EMPTY marker). Image pinned to alpine:3.19 (V18)." + "notes": "Migrated from parity_up_exec::traditional: after `up` on a traditional (non-compose) workspace, both CLIs' `exec` reach that workspace's own container and observe the same postCreate markers — including the #332 parity that neither CLI substitutes `${containerEnv:VAR}` inside a lifecycle command string (both must print the same EMPTY marker). Image pinned to alpine:3.19 (V18)." }, { "id": "case-up-exec-parity", @@ -3280,7 +3283,7 @@ "expectation": "exec --container-id recovers the config-only remoteEnv from the container's devcontainer.metadata label, byte-identical to the reference" } ], - "notes": "Research D2's INVERSE defect, resolved (023 T054). `parity_up_exec` asserts two things \u2014 up/exec container parity AND `exec --container-id` recovering remoteUser/remoteEnv from the devcontainer.metadata label \u2014 but emits a SINGLE CaseResult (`traditional`), so the registry previously claimed two independently-evidenced cases from one reported outcome and one of them had no evidence of its own. Both assertions are genuinely exercised (a regression fails the binary); only the REPORTING granularity is coarse. The two pointer cases are therefore merged into this one: one reported outcome, two behaviors, stated rather than disguised. `bhv-up-exec-parity` additionally has independent declarative evidence in case-up-exec-decl-traditional; `bhv-exec-container-id-metadata` awaits its own independently-reported case, which needs a runtime-resolved container-id token in an operation's argv (deferred, tasks.md#T110). THIS CASE IS WHY `parity_up_exec.rs` SURVIVES: its equivalence verdict is clean, but it carries the ONLY evidence for bhv-exec-container-id-metadata, so deleting it would leave that behavior uncovered (V5). See tasks.md#T110." + "notes": "Research D2's INVERSE defect, resolved (023 T054). `parity_up_exec` asserts two things — up/exec container parity AND `exec --container-id` recovering remoteUser/remoteEnv from the devcontainer.metadata label — but emits a SINGLE CaseResult (`traditional`), so the registry previously claimed two independently-evidenced cases from one reported outcome and one of them had no evidence of its own. Both assertions are genuinely exercised (a regression fails the binary); only the REPORTING granularity is coarse. The two pointer cases are therefore merged into this one: one reported outcome, two behaviors, stated rather than disguised. `bhv-up-exec-parity` additionally has independent declarative evidence in case-up-exec-decl-traditional; `bhv-exec-container-id-metadata` awaits its own independently-reported case, which needs a runtime-resolved container-id token in an operation's argv (deferred, tasks.md#T110). THIS CASE IS WHY `parity_up_exec.rs` SURVIVES: its equivalence verdict is clean, but it carries the ONLY evidence for bhv-exec-container-id-metadata, so deleting it would leave that behavior uncovered (V5). See tasks.md#T110." }, { "id": "case-up-idempotent", @@ -3328,7 +3331,7 @@ "volumes": true, "tempdir": true }, - "notes": "Declarative invariant-metamorphic case (022-conformance-runner US6): a second `deacon up` on the same isolated workspace must be IDEMPOTENT \u2014 it reuses the running container created by the first up (same container id, still running) rather than recreating it. The oracle verdicts on the DECLARED RELATIONSHIP between op-up-2 and op-up-1's chan-temporal state, not a fixed output (FR-008). Image pinned to alpine:3.19 (V18)." + "notes": "Declarative invariant-metamorphic case (022-conformance-runner US6): a second `deacon up` on the same isolated workspace must be IDEMPOTENT — it reuses the running container created by the first up (same container id, still running) rather than recreating it. The oracle verdicts on the DECLARED RELATIONSHIP between op-up-2 and op-up-1's chan-temporal state, not a fixed output (FR-008). Image pinned to alpine:3.19 (V18)." }, { "id": "case-user-profiles", diff --git a/conformance/registry/sources/observed.json b/conformance/registry/sources/observed.json index 1663a221..8dc231f6 100644 --- a/conformance/registry/sources/observed.json +++ b/conformance/registry/sources/observed.json @@ -26,7 +26,7 @@ "inventory": "observed", "revision": "rev-oracle-0-87-0", "locator": "up/container-labels", - "summary": "On the same fixture (fx-up-basic, image alpine:3.19), `docker inspect .Config.Labels` shows the reference CLI setting exactly three labels \u2014 devcontainer.metadata, devcontainer.local_folder, devcontainer.config_file \u2014 while deacon sets those three plus five it alone defines: devcontainer.configHash, devcontainer.config_name, devcontainer.name, devcontainer.source (value \"deacon\"), devcontainer.workspaceHash. devcontainer.metadata is byte-identical between the two; local_folder and config_file differ only by each side's own workspace path.", + "summary": "On the same fixture (fx-up-basic, image alpine:3.19), `docker inspect .Config.Labels` shows the reference CLI setting exactly three labels — devcontainer.metadata, devcontainer.local_folder, devcontainer.config_file — while deacon sets those three plus five it alone defines: devcontainer.configHash, devcontainer.config_name, devcontainer.name, devcontainer.source (value \"deacon\"), devcontainer.workspaceHash. local_folder and config_file differ only by each side's own workspace path. devcontainer.metadata is byte-identical ON THIS FIXTURE only because its bare config contributes no picked property, so both sides stamp \"[]\"; its CONTENT is a separate claim, recorded by bhv-container-metadata-label-authored, and it was NOT in agreement until T115.", "behaviors": [ "bhv-container-identity-labels" ] @@ -41,6 +41,16 @@ "bhv-container-keepalive-command" ] }, + { + "id": "src-obs-container-metadata-label-authored", + "inventory": "observed", + "revision": "rev-oracle-0-87-0", + "locator": "up/container-metadata-label", + "summary": "On fx-state-single-container the reference stamps `devcontainer.metadata` as [{\"mounts\":[\"source=${localWorkspaceFolder}/sib,target=/workspaces/sib,type=bind\"],\"containerEnv\":{\"SC_ENV\":\"yes\"}}] — templates intact — where deacon stamped the substituted absolute path. A dedicated probe fixture extended the measurement to `remoteEnv` and `postCreateCommand` (also templates) and to `Config.Env` (the SUBSTITUTED containerEnv value), and to the read-back: `devcontainer exec --container-id` prints `LWF=[${localWorkspaceFolder}]`, `CWF=[${containerWorkspaceFolder}]`, `LENV=[/home/vscode]`.", + "behaviors": [ + "bhv-container-metadata-label-authored" + ] + }, { "id": "src-obs-duplicate-keys", "inventory": "observed", diff --git a/conformance/registry/sources/spec.json b/conformance/registry/sources/spec.json index ce59cb3a..d5df2240 100644 --- a/conformance/registry/sources/spec.json +++ b/conformance/registry/sources/spec.json @@ -7,7 +7,10 @@ "revision": "rev-spec-113500f4", "locator": "Container creation and metadata", "summary": "A created container's observable state (running status, labels, mounts, environment) reflects the resolved configuration.", - "behaviors": ["bhv-state-container-parity", "bhv-state-diff-parity"] + "behaviors": [ + "bhv-state-container-parity", + "bhv-state-diff-parity" + ] }, { "id": "src-spec-exec-command", @@ -15,7 +18,19 @@ "revision": "rev-spec-113500f4", "locator": "Command execution", "summary": "A command is executed inside the container with its output streamed and its exit code propagated.", - "behaviors": ["bhv-exec-command-parity"] + "behaviors": [ + "bhv-exec-command-parity" + ] + }, + { + "id": "src-spec-metadata-substitution-timing", + "inventory": "spec", + "revision": "rev-spec-113500f4", + "locator": "image-metadata.md#merge-logic", + "summary": "\"Variables in string values will be substituted at the time the value is applied.\" The recorded metadata therefore holds the template; substitution belongs to the point of application, not the point of recording (clu-image-metadata-variables-in-string-values-will-be-substituted-a-desc-31cd8289).", + "behaviors": [ + "bhv-container-metadata-label-authored" + ] }, { "id": "src-spec-readconfig-resolution", diff --git a/conformance/snapshots/linux-x86_64/case-readconfig-snapshot/provenance.json b/conformance/snapshots/linux-x86_64/case-readconfig-snapshot/provenance.json index b9b46c3a..9ddcf101 100644 --- a/conformance/snapshots/linux-x86_64/case-readconfig-snapshot/provenance.json +++ b/conformance/snapshots/linux-x86_64/case-readconfig-snapshot/provenance.json @@ -14,6 +14,6 @@ "dockerVersion": "29.6.2-1", "composeVersion": "2.40.3", "imageDigests": {}, - "normalizerVersion": "5", - "capturedAt": "2026-07-25T17:57:06Z" + "normalizerVersion": "6", + "capturedAt": "2026-07-26T00:15:30Z" } diff --git a/crates/conformance/src/conservation.rs b/crates/conformance/src/conservation.rs index 22bd4cfa..b36290b0 100644 --- a/crates/conformance/src/conservation.rs +++ b/crates/conformance/src/conservation.rs @@ -72,9 +72,29 @@ pub const POST_BRANCH_BEHAVIORS: &[(&str, &str)] = &[ fx-up-basic. This is a deacon EXTENSION, not a variant of any pre-migration claim: \ no existing behavior describes what either CLI labels a container with, because the \ retired `strip_intentional_labels` rule removed the whole `devcontainer.*` namespace \ - before comparison. The three shared keys are NOT part of this behavior — \ - `devcontainer.metadata` compares byte-equal, and `.local_folder` / `.config_file` \ - differ only by each side's own temp workspace path and are normalized, not tolerated.", + before comparison. The three shared keys are NOT part of this behavior: \ + `.local_folder` / `.config_file` differ only by each side's own temp workspace path \ + and are normalized, not tolerated, and `devcontainer.metadata`'s CONTENT is its own \ + claim — see `bhv-container-metadata-label-authored`. (This entry originally read \ + '`devcontainer.metadata` compares byte-equal'. True on fx-up-basic, whose bare config \ + contributes no picked property so both sides stamp `[]` — and false in general, which \ + T115 measured. Corrected here rather than left standing, because a measurement on one \ + fixture is not a claim about the field.)", + ), + ( + "bhv-container-metadata-label-authored", + "The `devcontainer.metadata` label records the AUTHORED configuration — variable \ + templates intact — while the substituted values are applied to the container. Newly \ + RECORDABLE for the same reason as the two entries around it: until \ + `chan-container-state` became an observed channel no behavior described any label's \ + CONTENT, and the one pre-migration source record that mentioned this label measured a \ + fixture where both sides stamp `[]`. Not a variant of any pre-migration claim, and not \ + a variant of `bhv-container-identity-labels` either: that behavior is about WHICH \ + labels each CLI sets, this one about what the shared one CONTAINS — deacon set the \ + label all along and still got its content wrong. Nor is it merely observed: \ + image-metadata.md's Merge Logic states variables are substituted 'at the time the \ + value is applied', so this is `spec: conformant` / `follow-spec` after fixing deacon, \ + not a tolerated difference (T115).", ), ( "bhv-container-keepalive-command", @@ -665,6 +685,31 @@ pub const NORMALIZATION_RULES: &[NormalizationRule] = &[ ), known_non_compliant: None, }, + NormalizationRule { + name: "label_json_document", + scopes: &["channel:chan-container-state"], + action: RuleAction::Canonicalize, + removes: &[], + justification: Some( + "Parses the value of ONE enumerated label — `devcontainer.metadata`, whose \ + value is itself a JSON document — and compares it structurally instead of as \ + a byte string. This is `label_semantic` applied one level deeper: a label SET \ + is a key/value mapping rather than an opaque string, and so is a label VALUE \ + that is a JSON document. Measured against pinned oracle 0.87.0, both CLIs \ + stamp the same fragments with different key insertion order and two extra \ + spaces, e.g. deacon's \ + `[{\"remoteUser\":\"dev\",\"containerUser\":\"dev\",…}]` versus the \ + reference's `[ {\"containerEnv\":…,\"containerUser\":\"dev\",\"remoteUser\":\ + \"dev\"} ]`. Removes nothing: every key and value is preserved and compared, \ + only key order and insignificant whitespace stop mattering, array order is \ + kept, a value that is not valid JSON is left verbatim so a malformed label \ + still diverges, and a label key outside the enumerated set is untouched. The \ + alternative — aligning deacon's insertion order with upstream's \ + `pickConfigProperties` — would pin deacon to an implementation detail of the \ + reference's serializer that carries no meaning and no stability guarantee.", + ), + known_non_compliant: None, + }, NormalizationRule { name: "label_semantic", scopes: &["channel:chan-image"], diff --git a/crates/conformance/src/snapshot.rs b/crates/conformance/src/snapshot.rs index 36848a70..3041283f 100644 --- a/crates/conformance/src/snapshot.rs +++ b/crates/conformance/src/snapshot.rs @@ -33,7 +33,11 @@ use serde_json::Value; /// `portsAttributes`, where its key list was measured — it previously walked the whole /// document, eliding an enumerated key NAME at any depth (including inside /// `customizations`, arbitrary user data), which is the unbounded reach FR-029 forbids. -pub const NORMALIZER_VERSION: &str = "5"; +/// T115 set it to `"6"`: `label_json_document` was added to the `chan-container-state` +/// chain, so the one label whose value is a JSON document (`devcontainer.metadata`) +/// compares as that document rather than as a byte string — key order and insignificant +/// whitespace stop mattering, nothing is removed. +pub const NORMALIZER_VERSION: &str = "6"; /// The `provenance.json` record — the FR-017 identity/environment elements (data-model /// §7, contract snapshot-provenance.md). Thirteen fields: twelve identity/environment diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index e619c7de..9dcb449b 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -2739,7 +2739,6 @@ impl ConfigLoader { /// ## Returns /// /// Returns the merged and substituted configuration with substitution report. - #[instrument(skip_all, fields(path = %path.display(), merges = merge_config_paths.len()))] pub async fn load_with_overrides_and_substitution( path: &Path, merge_config_paths: &[&Path], @@ -2747,6 +2746,39 @@ impl ConfigLoader { workspace_path: &Path, resolve_devcontainer_id: bool, ) -> Result<(DevContainerConfig, crate::variable::SubstitutionReport)> { + let (_raw, substituted, report) = Self::load_with_overrides_and_substitution_raw( + path, + merge_config_paths, + secrets, + workspace_path, + resolve_devcontainer_id, + ) + .await?; + Ok((substituted, report)) + } + + /// As [`ConfigLoader::load_with_overrides_and_substitution`], but also returns + /// the merged configuration **before** variable substitution. + /// + /// Returns `(raw, substituted, report)`, mirroring the reference CLI's + /// `SubstitutedConfig { raw, config }`: some outputs describe the resolved + /// configuration (substituted) and some describe the authored one (raw). The + /// `devcontainer.metadata` container label is the second kind — it exists so a + /// later reader can recover the configuration, so it must carry the templates, + /// not this machine's absolute paths (measured against pinned oracle 0.87.0; + /// see `up::merged_config::build_container_metadata_label`). + #[instrument(skip_all, fields(path = %path.display(), merges = merge_config_paths.len()))] + pub async fn load_with_overrides_and_substitution_raw( + path: &Path, + merge_config_paths: &[&Path], + secrets: Option<&crate::secrets::SecretsCollection>, + workspace_path: &Path, + resolve_devcontainer_id: bool, + ) -> Result<( + DevContainerConfig, + DevContainerConfig, + crate::variable::SubstitutionReport, + )> { debug!( "Loading configuration with merge fragments and substitution from {}", path.display() @@ -2810,7 +2842,7 @@ impl ConfigLoader { merged.apply_variable_substitution(&substitution_context); debug!("Configuration loading with overrides and substitution complete"); - Ok((substituted_config, substitution_report)) + Ok((merged, substituted_config, substitution_report)) } /// Load configuration with variable substitution applied @@ -4204,6 +4236,66 @@ mod tests { ); } + /// T115: `load_with_overrides_and_substitution_raw` must return BOTH shapes of + /// the same configuration — the reference CLI's `SubstitutedConfig { raw, + /// config }`. Outputs describing the resolved container use `config`; the + /// `devcontainer.metadata` label, which a later reader recovers config from, + /// uses `raw`. + #[test] + fn test_load_raw_returns_pre_substitution_config() { + let temp_dir = TempDir::new().unwrap(); + let dc_dir = temp_dir.path().join(".devcontainer"); + std::fs::create_dir_all(&dc_dir).unwrap(); + let config_path = dc_dir.join("devcontainer.json"); + std::fs::write( + &config_path, + r#"{ + "image": "debian:bookworm-slim", + "workspaceFolder": "/srv/app", + "containerEnv": { "APP_DIR": "${containerWorkspaceFolder}" }, + "mounts": ["source=${localWorkspaceFolder}/sib,target=/workspaces/sib,type=bind"] + }"#, + ) + .unwrap(); + + let rt = tokio::runtime::Runtime::new().unwrap(); + let (raw, substituted, _report) = rt + .block_on(ConfigLoader::load_with_overrides_and_substitution_raw( + &config_path, + &[], + None, + temp_dir.path(), + true, + )) + .unwrap(); + + // raw: templates intact. + assert_eq!( + raw.container_env.get("APP_DIR").map(String::as_str), + Some("${containerWorkspaceFolder}") + ); + assert_eq!( + raw.mounts.first().and_then(|m| m.as_str()), + Some("source=${localWorkspaceFolder}/sib,target=/workspaces/sib,type=bind") + ); + + // substituted: resolved, and identical to the non-`_raw` entry point. + assert_eq!( + substituted.container_env.get("APP_DIR").map(String::as_str), + Some("/srv/app") + ); + let mount = substituted + .mounts + .first() + .and_then(|m| m.as_str()) + .unwrap() + .to_string(); + assert!( + !mount.contains("${localWorkspaceFolder}"), + "substituted mount should be resolved, got {mount}" + ); + } + #[test] fn test_substitution_covers_name_remote_env_users() { // Per #107 — apply_variable_substitution must expand variables in diff --git a/crates/core/src/container_env_probe.rs b/crates/core/src/container_env_probe.rs index 611bc7da..2d612058 100644 --- a/crates/core/src/container_env_probe.rs +++ b/crates/core/src/container_env_probe.rs @@ -620,6 +620,13 @@ impl ContainerEnvironmentProber { feature_vars: HashMap::new(), template_options: None, resolve_devcontainer_id: true, + // No workspace in this context, so `${localWorkspaceFolder}` + // must stay literal rather than collapse to the empty string. + // In the ordinary flow config substitution already resolved it; + // the one path where the token can still arrive here is a config + // recovered from a container's `devcontainer.metadata` label, + // where the reference also leaves it literal. + resolve_local_workspace_folder: false, }; let mut report = crate::variable::SubstitutionReport::new(); for (k, v_opt) in remote { diff --git a/crates/core/src/variable.rs b/crates/core/src/variable.rs index ef97b300..97fbf5e0 100644 --- a/crates/core/src/variable.rs +++ b/crates/core/src/variable.rs @@ -90,6 +90,14 @@ pub struct SubstitutionContext { /// (which set `devcontainer_id` to a meaningful value) keep resolving it; /// `read-configuration`'s pre-container output passes set this to `false`. pub resolve_devcontainer_id: bool, + /// Whether `${localWorkspaceFolder}` / `${localWorkspaceFolderBasename}` + /// should be resolved in this pass. + /// + /// Defaults to `true`, because every ordinary pass runs with a workspace in + /// hand. It is `false` only in [`SubstitutionContext::host_env_only`], where + /// there is no workspace to resolve against and inventing one would be worse + /// than leaving the token literal. + pub resolve_local_workspace_folder: bool, } impl SubstitutionContext { @@ -162,9 +170,41 @@ impl SubstitutionContext { feature_vars: HashMap::new(), template_options: None, resolve_devcontainer_id: true, + resolve_local_workspace_folder: true, }) } + /// Create a context that can resolve **only host-environment** tokens — + /// `${localEnv:VAR}` and its `${env:VAR}` alias — leaving every + /// workspace-relative and container-relative token literal. + /// + /// This is the context for re-substituting a config recovered from a + /// container's `devcontainer.metadata` label, where no workspace folder + /// exists to resolve against. It reproduces the reference CLI's measured + /// behavior on that path: with pinned oracle 0.87.0, `devcontainer exec + /// --container-id ` over a container whose label carries + /// `remoteEnv: { LWF: "${localWorkspaceFolder}", LENV: "${localEnv:HOME}" }` + /// prints `LWF=[${localWorkspaceFolder}]` and `LENV=[/home/vscode]` — the + /// host-env token resolves, the workspace token stays literal. + /// + /// Unlike [`SubstitutionContext::new`] this is infallible: there is no path + /// to canonicalize. + pub fn host_env_only() -> Self { + Self { + local_workspace_folder: String::new(), + local_env: env::vars().collect(), + // No workspace, so no meaningful identity. Gated off below as well, + // but keep the field empty rather than a hash of "". + devcontainer_id: String::new(), + container_workspace_folder: None, + container_env: None, + feature_vars: HashMap::new(), + template_options: None, + resolve_devcontainer_id: false, + resolve_local_workspace_folder: false, + } + } + /// Generate a deterministic devcontainer ID from workspace path /// /// Uses SHA256 hash of the canonical workspace path and returns the first 12 characters @@ -464,13 +504,19 @@ impl VariableSubstitution { /// - `feature:VAR` - Returns feature-provided variable (if available) fn resolve_variable(variable_expr: &str, context: &SubstitutionContext) -> Option { match variable_expr { - "localWorkspaceFolder" => Some(context.local_workspace_folder.clone()), - "localWorkspaceFolderBasename" => Some( + // Gated exactly like `devcontainerId` below: when there is no + // workspace to resolve against (`host_env_only`), return None so the + // literal token is preserved rather than substituting an empty path. + "localWorkspaceFolder" if context.resolve_local_workspace_folder => { + Some(context.local_workspace_folder.clone()) + } + "localWorkspaceFolderBasename" if context.resolve_local_workspace_folder => Some( std::path::Path::new(&context.local_workspace_folder) .file_name() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_default(), ), + "localWorkspaceFolder" | "localWorkspaceFolderBasename" => None, // Per the reference CLI, `${devcontainerId}` is only resolved once a // container identity exists. When `resolve_devcontainer_id` is false // (config-load / `read-configuration` output before any container), diff --git a/crates/deacon/src/commands/shared/config_loader.rs b/crates/deacon/src/commands/shared/config_loader.rs index 5fd09570..8f788e39 100644 --- a/crates/deacon/src/commands/shared/config_loader.rs +++ b/crates/deacon/src/commands/shared/config_loader.rs @@ -39,6 +39,15 @@ pub struct ConfigLoadArgs<'a> { #[derive(Debug)] pub struct ConfigLoadResult { pub config: DevContainerConfig, + /// The merged configuration **before** variable substitution — the reference + /// CLI's `SubstitutedConfig.raw`. + /// + /// Needed by the one output that describes the *authored* configuration rather + /// than the resolved one: the `devcontainer.metadata` container label, which a + /// later reader recovers config from and so must carry `${localWorkspaceFolder}` + /// rather than this machine's absolute path. + #[allow(dead_code)] + pub raw_config: DevContainerConfig, #[allow(dead_code)] pub substitution_report: SubstitutionReport, pub workspace_folder: PathBuf, @@ -113,17 +122,19 @@ pub async fn load_config(args: ConfigLoadArgs<'_>) -> Result { }; let merge_refs: Vec<&Path> = merge_paths.iter().map(|p| p.as_path()).collect(); - let (config, substitution_report) = ConfigLoader::load_with_overrides_and_substitution( - &config_path, - &merge_refs, - secrets.as_ref(), - &workspace_folder, - args.resolve_devcontainer_id, - ) - .await?; + let (raw_config, config, substitution_report) = + ConfigLoader::load_with_overrides_and_substitution_raw( + &config_path, + &merge_refs, + secrets.as_ref(), + &workspace_folder, + args.resolve_devcontainer_id, + ) + .await?; Ok(ConfigLoadResult { config, + raw_config, substitution_report, workspace_folder, config_path, diff --git a/crates/deacon/src/commands/shared/container_metadata.rs b/crates/deacon/src/commands/shared/container_metadata.rs index 7e36e4b9..948c5a8b 100644 --- a/crates/deacon/src/commands/shared/container_metadata.rs +++ b/crates/deacon/src/commands/shared/container_metadata.rs @@ -10,6 +10,7 @@ use anyhow::{Context, Result}; use deacon_core::config::{ConfigMerger, DevContainerConfig}; use deacon_core::docker::ContainerInfo; +use deacon_core::variable::SubstitutionContext; /// Extract a merged [`DevContainerConfig`] from a container's /// `devcontainer.metadata` label. @@ -18,6 +19,21 @@ use deacon_core::docker::ContainerInfo; /// single-object form) that [`ConfigMerger`] folds together. A missing label is /// NOT an error — many containers aren't built by `deacon up` — so this returns /// `Ok(None)` and the caller falls back to whatever config it already has. +/// +/// ## Substitution +/// +/// The label stores the **authored** configuration, templates intact (T115), so the +/// recovered fragments still contain `${...}` tokens. The callers of this function +/// are the `--container-id` paths, which by definition have no workspace folder, so +/// a [`SubstitutionContext::host_env_only`] pass runs here: `${localEnv:VAR}` / +/// `${env:VAR}` resolve against the host, while `${localWorkspaceFolder}` and +/// `${containerWorkspaceFolder}` stay literal (there is nothing to resolve them +/// against) and `${containerEnv:VAR}` stays literal for the later env-probe pass. +/// +/// This is the reference CLI's measured behavior, not a guess. With pinned oracle +/// 0.87.0, `devcontainer exec --container-id ` over a container labelled +/// `remoteEnv: { LWF: "${localWorkspaceFolder}", LENV: "${localEnv:HOME}" }` prints +/// `LWF=[${localWorkspaceFolder}] LENV=[/home/vscode]`. pub fn config_from_metadata_label(container: &ContainerInfo) -> Result> { let Some(label) = container.labels.get("devcontainer.metadata") else { return Ok(None); @@ -49,5 +65,89 @@ pub fn config_from_metadata_label(container: &ContainerInfo) -> Result) -> ContainerInfo { + ContainerInfo { + id: "cid".to_string(), + names: vec![], + image: "debian:bookworm-slim".to_string(), + status: "running".to_string(), + state: "running".to_string(), + exposed_ports: vec![], + port_mappings: vec![], + env: HashMap::new(), + labels, + mounts: vec![], + } + } + + fn container_with_label(label: &str) -> ContainerInfo { + container(HashMap::from([( + "devcontainer.metadata".to_string(), + label.to_string(), + )])) + } + + /// T115: the label now carries authored templates, so the read-back applies a + /// host-env-only pass. Pins the three outcomes measured on pinned oracle + /// 0.87.0's `exec --container-id`: host-env token resolved, workspace tokens + /// literal (nothing to resolve them against), container-env token deferred to + /// the later env-probe pass. + #[test] + fn read_back_resolves_host_env_and_leaves_workspace_tokens_literal() { + // `PATH` rather than a var this test sets: `unsafe_code` is denied + // workspace-wide, so `set_var` is unavailable, and PATH is always present. + let want = std::env::var("PATH").expect("PATH is set"); + + let cfg = config_from_metadata_label(&container_with_label( + r#"[{"remoteEnv":{ + "HOSTENV":"${localEnv:PATH}", + "LWF":"${localWorkspaceFolder}", + "CWF":"${containerWorkspaceFolder}", + "CENV":"${containerEnv:PATH}" + }}]"#, + )) + .unwrap() + .expect("label present"); + + let get = |k: &str| cfg.remote_env.get(k).cloned().flatten().unwrap_or_default(); + assert_eq!(get("HOSTENV"), want, "${{localEnv:…}} must resolve"); + assert_eq!( + get("LWF"), + "${localWorkspaceFolder}", + "no workspace exists on the --container-id path, so the token stays literal \ + rather than collapsing to the empty string" + ); + assert_eq!( + get("CWF"), + "${containerWorkspaceFolder}", + "container workspace folder is not known here either" + ); + assert_eq!( + get("CENV"), + "${containerEnv:PATH}", + "deferred to the env-probe pass, which is the only place the container \ + environment is known" + ); + } + + #[test] + fn missing_label_is_not_an_error() { + assert!( + config_from_metadata_label(&container(HashMap::new())) + .unwrap() + .is_none() + ); + } } diff --git a/crates/deacon/src/commands/up/compose.rs b/crates/deacon/src/commands/up/compose.rs index 2f0d6ef7..0f052910 100644 --- a/crates/deacon/src/commands/up/compose.rs +++ b/crates/deacon/src/commands/up/compose.rs @@ -81,6 +81,7 @@ pub(crate) async fn execute_compose_up( config_path: &Path, runtime: &ContainerRuntimeImpl, host_ca_set: Option<&CorporateCaSet>, + metadata_config_entry: &serde_json::Value, ) -> Result { debug!("Starting Docker Compose project"); @@ -413,6 +414,9 @@ pub(crate) async fn execute_compose_up( // is recoverable by exec/read-configuration/set-up `--container-id`, exactly // like the single-container path. The effective image is the feature-extended // one when features were built, else the service's own `image:`. + // + // `metadata_config_entry` is picked from the RAW (pre-substitution) config by + // the caller, symmetrically with the single-container path (T115). { use deacon_core::compose::ServiceShape; let effective_image = match &project.service_image_override { @@ -430,7 +434,7 @@ pub(crate) async fn execute_compose_up( if let Some(json) = super::merged_config::build_container_metadata_label( &runtime.cli_docker(), &img, - config, + metadata_config_entry, ) .await { diff --git a/crates/deacon/src/commands/up/container.rs b/crates/deacon/src/commands/up/container.rs index 76b6546d..db12e1d8 100644 --- a/crates/deacon/src/commands/up/container.rs +++ b/crates/deacon/src/commands/up/container.rs @@ -85,18 +85,13 @@ pub(crate) async fn execute_container_up( cache_folder: &Option, build_options: &BuildOptions, host_ca_set: Option<&CorporateCaSet>, + metadata_config_entry: &serde_json::Value, ) -> Result { debug!("Starting traditional development container"); // Merge CLI forward_ports into config let mut config = config.clone(); - // #322: capture the pure USER config BEFORE any image-metadata merge, so the - // `devcontainer.metadata` config entry we stamp carries only devcontainer.json's - // own picked properties (remoteEnv/remoteUser/…) and does NOT duplicate the base - // image's metadata (which is already present as separate label entries). - let user_config_for_metadata = config.clone(); - // Host-CA injection (016, T028): synthesize the six CA env vars into the // container environment at create time, insert-if-absent so user // containerEnv values win (FR-024). The canonical bundle is written by the @@ -579,13 +574,18 @@ pub(crate) async fn execute_container_up( // container and is recoverable by exec/read-configuration/set-up without the // workspace — matching the reference CLI. Informational label; never feeds // `devcontainerId` (see `ContainerIdentity::id_hash_labels`). + // + // `metadata_config_entry` was picked from the RAW (pre-substitution) config by + // the caller (T115). It is not derived from the local `config`: that one has + // been substituted, and stamping substituted values bakes this machine's + // absolute paths into container metadata. let create_identity_owned; let create_identity: &ContainerIdentity = match config.image.as_deref() { Some(image_ref) => { match super::merged_config::build_container_metadata_label( docker, image_ref, - &user_config_for_metadata, + metadata_config_entry, ) .await { diff --git a/crates/deacon/src/commands/up/merged_config.rs b/crates/deacon/src/commands/up/merged_config.rs index 750f7745..fc386bbb 100644 --- a/crates/deacon/src/commands/up/merged_config.rs +++ b/crates/deacon/src/commands/up/merged_config.rs @@ -449,6 +449,17 @@ fn apply_image_metadata_label( /// notably `remoteEnv` — is recoverable from the container by /// exec/read-configuration/set-up. Mirrors upstream `pickConfigProperties`. /// Null/empty values are dropped so the entry stays minimal. +/// +/// **Pass the PRE-substitution config** (`ConfigLoadResult::raw_config`, upstream's +/// `SubstitutedConfig.raw`). The label describes the *authored* configuration to +/// whoever reads the container later, so it must carry `${localWorkspaceFolder}`, +/// not the recording machine's absolute path. Measured against pinned oracle +/// 0.87.0: for `mounts: ["source=${localWorkspaceFolder}/sib,…"]` the reference +/// stamps the template verbatim while applying the substituted value to the +/// container itself, and the same holds for `remoteEnv`, `containerEnv` and +/// `postCreateCommand`. deacon stamped substituted values until this fix (T115), +/// baking a host path into container metadata. Normalization could not paper over +/// it: tokenizing deacon's side yields `/sib`, still a different form. pub(crate) fn config_metadata_entry(config: &DevContainerConfig) -> serde_json::Value { const PICK: &[&str] = &[ "init", @@ -511,10 +522,15 @@ pub(crate) fn config_metadata_entry(config: &DevContainerConfig) -> serde_json:: /// exactly the one where no inherited label exists — the container ended up with no /// `devcontainer.metadata` label at all where the reference has `[]`. Measured against the /// pinned oracle 0.87.0 by the declarative `chan-container-state` differential (024). +/// +/// `config_entry` is the caller's [`config_metadata_entry`] over the **raw** +/// (pre-substitution) config. It is passed in rather than derived here because the +/// raw config only exists at the top of `up`, before the runtime mutations — the +/// signature is what keeps a substituted config from being handed in by accident. pub(crate) async fn build_container_metadata_label( docker: &impl Docker, image_ref: &str, - config: &DevContainerConfig, + config_entry: &serde_json::Value, ) -> Option { let mut entries: Vec = match docker.inspect_image(image_ref).await { Ok(Some(info)) => match info.labels.get("devcontainer.metadata") { @@ -528,13 +544,12 @@ pub(crate) async fn build_container_metadata_label( _ => Vec::new(), }; - let cfg_entry = config_metadata_entry(config); - let cfg_nonempty = cfg_entry + let cfg_nonempty = config_entry .as_object() .map(|o| !o.is_empty()) .unwrap_or(false); if cfg_nonempty { - entries.push(cfg_entry); + entries.push(config_entry.clone()); } serde_json::to_string(&serde_json::Value::Array(entries)).ok() } @@ -580,6 +595,51 @@ mod tests { assert!(!obj.contains_key("securityOpt"), "empty arrays are dropped"); } + /// T115: the picked entry must carry the AUTHORED templates, because the + /// label is what a later reader recovers the configuration from. Measured + /// against pinned oracle 0.87.0, which stamps + /// `"source=${localWorkspaceFolder}/sib,target=/workspaces/sib,type=bind"` + /// while deacon stamped the recording machine's absolute path. + /// + /// This test is over the RAW config on purpose: it pins the shape of what + /// callers must pass, and it fails the moment someone re-derives the entry + /// from a substituted config (every template below would be a concrete path). + #[test] + fn config_metadata_entry_preserves_authored_templates() { + let raw: DevContainerConfig = serde_json::from_str( + r#"{ + "name": "t115", + "image": "debian:bookworm-slim", + "workspaceFolder": "/workspace", + "mounts": ["source=${localWorkspaceFolder}/sib,target=/workspaces/sib,type=bind"], + "containerEnv": { "CE": "${localWorkspaceFolder}" }, + "remoteEnv": { "RE": "${localEnv:HOME}", "CWF": "${containerWorkspaceFolder}" }, + "postCreateCommand": "echo ${containerWorkspaceFolder}" + }"#, + ) + .unwrap(); + let entry = config_metadata_entry(&raw); + let text = serde_json::to_string(&entry).unwrap(); + + // Each of the four field kinds measured on the reference's label. + assert!( + text.contains("source=${localWorkspaceFolder}/sib"), + "mounts must keep the template, got {text}" + ); + assert!( + text.contains(r#""CE":"${localWorkspaceFolder}""#), + "containerEnv must keep the template, got {text}" + ); + assert!( + text.contains(r#""RE":"${localEnv:HOME}""#), + "remoteEnv must keep the template, got {text}" + ); + assert!( + text.contains("echo ${containerWorkspaceFolder}"), + "postCreateCommand must keep the template, got {text}" + ); + } + #[test] fn config_metadata_entry_empty_for_bare_config() { let config: DevContainerConfig = diff --git a/crates/deacon/src/commands/up/mod.rs b/crates/deacon/src/commands/up/mod.rs index 281572bb..8e691e5f 100644 --- a/crates/deacon/src/commands/up/mod.rs +++ b/crates/deacon/src/commands/up/mod.rs @@ -193,6 +193,7 @@ pub(crate) async fn execute_up_with_runtime( // Load configuration with shared resolution (workspace/config/override/secrets) let ConfigLoadResult { mut config, + raw_config, workspace_folder, config_path, .. @@ -220,6 +221,22 @@ pub(crate) async fn execute_up_with_runtime( // reproduce, breaking `up` ↔ `exec` reconnection for Dockerfile configs. let identity_config = config.clone(); + // #322 / T115: pick the `devcontainer.metadata` config entry from the RAW + // (pre-substitution) config, here, where the raw config exists. + // + // Two independent reasons this must happen at the top of `up`: + // 1. Substitution. The label describes the AUTHORED configuration to whoever + // reads the container later, so it carries `${localWorkspaceFolder}`, not + // this machine's absolute path. Measured against pinned oracle 0.87.0. + // 2. Layering. The picked entry must be devcontainer.json's own properties, + // not a duplicate of the base image's metadata (which the label already + // carries as separate, lower-precedence entries) — so it is taken before + // the image-metadata merge, the feature merge and the Dockerfile build. + // None of the mutations between here and the create call touch a picked + // property (they touch `forwardPorts`/`appPort`, `features`, `image`), so this + // snapshot loses nothing they would have contributed. + let metadata_config_entry = merged_config::config_metadata_entry(&raw_config); + // T029: Check for disallowed features before any runtime operations check_for_disallowed_features(&config.features)?; debug!("Validated features - no disallowed features found"); @@ -555,6 +572,7 @@ pub(crate) async fn execute_up_with_runtime( config_path.as_path(), &runtime, host_ca_set.as_ref(), + &metadata_config_entry, ) .await? } else { @@ -571,6 +589,7 @@ pub(crate) async fn execute_up_with_runtime( &cache_folder, &build_options, host_ca_set.as_ref(), + &metadata_config_entry, ) .await? }; diff --git a/crates/deacon/tests/integration_up_exec_identity.rs b/crates/deacon/tests/integration_up_exec_identity.rs index 1b640087..99596fb6 100644 --- a/crates/deacon/tests/integration_up_exec_identity.rs +++ b/crates/deacon/tests/integration_up_exec_identity.rs @@ -225,3 +225,100 @@ fn exec_honors_remote_user_from_image_metadata() { "exec must run as the image-metadata remoteUser (#223), got {got:?}" ); } + +/// T115: the `devcontainer.metadata` label must carry the **authored** templates, +/// not this machine's substituted absolute paths. +/// +/// The label exists so a later reader can recover the configuration from the +/// container alone. Baking in the recording machine's workspace path makes it +/// host-specific and leaks a host path into container metadata. Measured against +/// pinned oracle 0.87.0, which stamps +/// `"source=${localWorkspaceFolder}/sib,target=/workspaces/sib,type=bind"` where +/// deacon stamped `"source=/tmp/…/ws/sib,…"`. +/// +/// The second half pins the consequence: because the label now holds templates, +/// the `--container-id` read-back applies a host-env-only pass — `${localEnv:…}` +/// resolves, `${localWorkspaceFolder}` stays literal. That is also the reference's +/// measured behavior on `devcontainer exec --container-id`, so the fix does not +/// trade one divergence for another. +#[test] +fn up_stamps_authored_templates_in_devcontainer_metadata() { + if !is_docker_available() { + eprintln!("skipping: docker unavailable"); + return; + } + let ws = TempDir::new().unwrap(); + write(ws.path(), "sib/marker.txt", "sib\n"); + write( + ws.path(), + ".devcontainer/devcontainer.json", + r#"{ + "name": "t115", + "image": "debian:bookworm-slim", + "workspaceFolder": "/workspace", + "workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind", + "mounts": ["source=${localWorkspaceFolder}/sib,target=/workspaces/sib,type=bind"], + "remoteEnv": { "T115_HOST": "${localEnv:HOME}", "T115_LWF": "${localWorkspaceFolder}" }, + "overrideCommand": true + }"#, + ); + + up(ws.path()); + + let inspected = deacon() + .args(["read-configuration", "--workspace-folder"]) + .arg(ws.path()) + .stderr(Stdio::null()) + .output() + .expect("spawn deacon read-configuration"); + assert!(inspected.status.success(), "read-configuration failed"); + + let label = StdCommand::new(runtime_bin()) + .args([ + "inspect", + "--format", + "{{index .Config.Labels \"devcontainer.metadata\"}}", + ]) + .arg( + String::from_utf8_lossy( + &StdCommand::new(runtime_bin()) + .args([ + "ps", + "-q", + "--filter", + &format!("label=devcontainer.local_folder={}", ws.path().display()), + ]) + .output() + .expect("spawn docker ps") + .stdout, + ) + .trim(), + ) + .output() + .expect("spawn docker inspect"); + let label = String::from_utf8_lossy(&label.stdout).trim().to_string(); + + // Read back through exec BEFORE tearing down. + let host_env = exec_cmd(ws.path(), &["sh", "-lc", "printf %s \"$T115_HOST\""]); + down(ws.path()); + + assert!( + label.contains("source=${localWorkspaceFolder}/sib"), + "the label must keep the authored mount template (T115); got {label}" + ); + assert!( + label.contains("${localEnv:HOME}"), + "the label must keep the authored remoteEnv template (T115); got {label}" + ); + assert!( + !label.contains(&ws.path().display().to_string()), + "the label must not leak this machine's workspace path (T115); got {label}" + ); + // `up` itself still resolves the value it applies to the container. + assert_eq!( + host_env, + std::env::var("HOME").unwrap_or_default(), + "`up` must still APPLY the substituted remoteEnv value even though the label \ + records the template" + ); +} diff --git a/crates/parity-harness/src/normalize.rs b/crates/parity-harness/src/normalize.rs index 15bc1f94..10a847c7 100644 --- a/crates/parity-harness/src/normalize.rs +++ b/crates/parity-harness/src/normalize.rs @@ -261,6 +261,76 @@ pub fn label_semantic(labels: &Value) -> Value { } } +/// The FINITE, ENUMERATED label keys whose VALUE is itself a JSON document rather than an +/// opaque string, and which [`label_json_document`] therefore compares structurally. +/// +/// One entry today. `devcontainer.metadata` is the array of configuration fragments both +/// CLIs stamp so a later reader can recover the configuration from the container alone. +pub const JSON_DOCUMENT_LABELS: &[&str] = &["devcontainer.metadata"]; + +/// **Rule `label_json_document`**: for the enumerated [`JSON_DOCUMENT_LABELS`], parse the +/// label's string value as JSON and canonicalize it, so a label whose value IS a JSON +/// document compares as that document rather than as a byte string. +/// +/// This is [`label_semantic`] applied one level deeper. `label_semantic` exists because a +/// label *set* is a key/value mapping, not an opaque string; the same reasoning applies to +/// a label *value* that is a JSON document: whichever order the emitter happened to insert +/// its keys in, and whatever insignificant whitespace `JSON.stringify` produced, every +/// reader parses it. Measured against pinned oracle 0.87.0 on `fx-state-dockerfile-nonroot`: +/// +/// ```text +/// deacon: [{"remoteUser":"dev","containerUser":"dev","containerEnv":{"DF_ENV":"yes"}}] +/// ref: [ {"containerEnv":{"DF_ENV":"yes"},"containerUser":"dev","remoteUser":"dev"} ] +/// ``` +/// +/// Identical documents; three keys in a different order and two extra spaces. Aligning +/// deacon's insertion order with upstream's `pickConfigProperties` order would make this +/// one fixture pass while pinning deacon to an implementation detail of the reference's +/// serializer that carries no meaning and no stability guarantee. +/// +/// **Removes nothing** (FR-029). Every key and value is preserved and compared; only the +/// key ORDER and insignificant whitespace stop mattering. A value that is not valid JSON, +/// or a key not in the enumerated list, is left exactly as captured — so a malformed label +/// still surfaces as a divergence rather than being quietly accepted. +pub fn label_json_document(labels: &Value) -> Value { + let Value::Object(obj) = labels else { + return labels.clone(); + }; + let mut out = obj.clone(); + for key in JSON_DOCUMENT_LABELS { + let Some(raw) = obj.get(*key).and_then(Value::as_str) else { + continue; + }; + // Not-valid-JSON is left verbatim: this rule canonicalizes a document, it does + // not sanitize a string. + if let Ok(parsed) = serde_json::from_str::(raw) { + out.insert((*key).to_string(), canonical_json(&parsed)); + } + } + Value::Object(out) +} + +/// Recursively sort object keys so two structurally equal JSON documents compare equal. +/// +/// Needed because the workspace enables serde_json's `preserve_order`: parsing keeps +/// insertion order, which is exactly the difference being canonicalized away. Arrays keep +/// their order — element order in a JSON array IS meaningful. +fn canonical_json(value: &Value) -> Value { + match value { + Value::Object(obj) => { + let mut keys: Vec<&String> = obj.keys().collect(); + keys.sort_unstable(); + let mut out = Map::new(); + for k in keys { + out.insert(k.clone(), canonical_json(&obj[k])); + } + Value::Object(out) + } + Value::Array(items) => Value::Array(items.iter().map(canonical_json).collect()), + other => other.clone(), + } +} + /// **Rule `mount_source_canonical`** (FR-027): path-substitute each mount `source` /// before compare, so two mounts that differ ONLY by a temp path compare equal. Given a /// mounts array `[{ source, target, ... }]`, rewrites each `source` via the token map. @@ -374,14 +444,32 @@ fn apply_channel_rules(channel: &str, value: &Value, tokens: &TokenMap) -> Value // `chan-container-state`: `workspace_basename_token` (carried by the token map // from `tokens_for_channel`) + `path_token` over the whole snapshot — object KEYS // included, so mount destinations keyed by the container-side workspace path - // normalize on both sides — then `null_preserving`. NOTHING is removed: labels, - // entrypoint, cmd and networks are emitted verbatim and any characterized - // difference is covered by a scoped, backed `allowedDifference` (024 Phase 4). - CHAN_CONTAINER_STATE => null_preserving(&path_token(value, tokens)), + // normalize on both sides — then `label_json_document` on the labels whose value is + // a JSON document, then `null_preserving`. NOTHING is removed: labels, entrypoint, + // cmd and networks are emitted verbatim and any characterized difference is covered + // by a scoped, backed `allowedDifference` (024 Phase 4). + CHAN_CONTAINER_STATE => normalize_container_state(value, tokens), _ => value.clone(), } } +/// `chan-container-state`: `path_token` (carrying `workspace_basename_token`) over the +/// whole snapshot, then `label_json_document` on `labels`, then `null_preserving`. +/// +/// The label pass runs AFTER `path_token` deliberately: a path inside the metadata document +/// must still be tokenized, and tokenizing first means the parse sees the already-tokenized +/// text. Order matters only in that direction — canonicalizing keys never introduces a path. +fn normalize_container_state(value: &Value, tokens: &TokenMap) -> Value { + let mut v = path_token(value, tokens); + if let Value::Object(obj) = &mut v { + if let Some(labels) = obj.get("labels") { + let canonical = label_json_document(labels); + obj.insert("labels".to_string(), canonical); + } + } + null_preserving(&v) +} + /// `chan-image`: `label_semantic` on the `labels` field, `path_token` elsewhere, /// `null_preserving` overall. fn normalize_image(value: &Value, tokens: &TokenMap) -> Value { @@ -1276,6 +1364,83 @@ mod tests { assert!(i.value.get("env").is_some() && i.value.get("command").is_some()); } + // -- T115: label_json_document ----------------------------------------------------- + + /// The measured case: the two CLIs' `devcontainer.metadata` documents differ only in + /// key insertion order and insignificant whitespace, and must compare equal. + #[test] + fn label_json_document_compares_metadata_as_a_document() { + let deacon = json!({ + "devcontainer.metadata": + r#"[{"remoteUser":"dev","containerUser":"dev","containerEnv":{"DF_ENV":"yes"}}]"#, + }); + let reference = json!({ + "devcontainer.metadata": + "[ {\"containerEnv\":{\"DF_ENV\":\"yes\"},\"containerUser\":\"dev\",\"remoteUser\":\"dev\"} ]", + }); + assert_eq!( + label_json_document(&deacon), + label_json_document(&reference), + "same document, different key order and whitespace → equal" + ); + } + + /// The rule must still SEE a real difference. This is the property that separates it + /// from a blanket rule: it changes the comparison's representation, not its strictness. + #[test] + fn label_json_document_still_diverges_on_different_content() { + let a = json!({ "devcontainer.metadata": r#"[{"remoteUser":"dev"}]"# }); + let b = json!({ "devcontainer.metadata": r#"[{"remoteUser":"root"}]"# }); + assert_ne!(label_json_document(&a), label_json_document(&b)); + + // A missing key is still a difference, not an absence to be smoothed over. + let c = json!({ "devcontainer.metadata": r#"[{"remoteUser":"dev","init":true}]"# }); + assert_ne!(label_json_document(&a), label_json_document(&c)); + + // Array ORDER is meaningful — metadata fragments are precedence-ordered. + let d = json!({ "devcontainer.metadata": r#"[{"a":1},{"b":2}]"# }); + let e = json!({ "devcontainer.metadata": r#"[{"b":2},{"a":1}]"# }); + assert_ne!( + label_json_document(&d), + label_json_document(&e), + "fragment order carries precedence and must not be canonicalized away" + ); + } + + #[test] + fn label_json_document_leaves_other_labels_and_malformed_values_verbatim() { + // Not in the enumerated set → untouched, even though it parses as JSON. + let other = json!({ "devcontainer.local_folder": r#"{"b":1,"a":2}"# }); + assert_eq!(label_json_document(&other), other); + + // In the set but not valid JSON → untouched, so it still surfaces as a divergence + // rather than being quietly accepted. + let malformed = json!({ "devcontainer.metadata": "[{not json" }); + assert_eq!(label_json_document(&malformed), malformed); + } + + /// The rule is reachable through the real channel chain, not just callable directly — + /// a rule that exists but is never wired in is the shape of defect 022 T115 caught + /// (an observer landing without its evaluator). + #[test] + fn container_state_chain_applies_label_json_document() { + let tokens = TokenMap::workspace(Path::new("/tmp/ws-a")); + let ev = raw( + deacon_conformance::model::CHAN_CONTAINER_STATE, + json!({ "labels": { "devcontainer.metadata": r#"[{"b":2,"a":1}]"# } }), + ); + let n = normalize_channel( + deacon_conformance::model::CHAN_CONTAINER_STATE, + &ev, + &tokens, + ); + assert_eq!( + n.value["labels"]["devcontainer.metadata"], + json!([{ "a": 1, "b": 2 }]), + "the label value must arrive as a parsed, key-sorted document" + ); + } + // -- T040: label_semantic / mount_source_canonical / path_env_segmented ------------ #[test] @@ -1350,11 +1515,14 @@ mod tests { #[test] fn normalizer_version_is_bumped_for_named_rules() { assert_eq!( - NORMALIZER_VERSION, "5", - "the 024 review bounded `drop_absent_optional` to the document root plus \ - `hostRequirements`/`portsAttributes`; it previously elided an enumerated key \ - name at ANY depth, including inside `customizations` — a change to what \ - \"equal\" means, so every recorded snapshot must go stale and be re-reviewed" + NORMALIZER_VERSION, "6", + "T115 added `label_json_document` to the `chan-container-state` chain, so the \ + one label whose value is a JSON document (`devcontainer.metadata`) compares as \ + that document rather than as a byte string — a change to what \"equal\" means, \ + so every recorded snapshot must go stale and be re-reviewed. (\"5\" was the 024 \ + review bounding `drop_absent_optional` to the document root plus \ + `hostRequirements`/`portsAttributes`, which previously elided an enumerated key \ + name at ANY depth, including inside `customizations`.)" ); } diff --git a/specs/023-migrate-parity-to-conformance/plan-phase2.md b/specs/023-migrate-parity-to-conformance/plan-phase2.md index de96a317..4643a24b 100644 --- a/specs/023-migrate-parity-to-conformance/plan-phase2.md +++ b/specs/023-migrate-parity-to-conformance/plan-phase2.md @@ -1,6 +1,6 @@ # Phase 2 — draining the deferrals (in-repo record of the "024" work) -**Status**: in progress. Steps 1–4 complete, step 5 partial, steps 6–8 not started. +**Status**: in progress. Steps 1–4 complete, step 5 complete through T115, steps 5b–8 in progress. This document exists because the work below was driven by a plan that lived outside the repository. Four substantial commits landed with their rationale recorded only in @@ -49,6 +49,7 @@ opposite answer twice (see step 5). | 3 | `b7ebd1b` | D-2 + D-3: an unobserved differential fails loud; deacon's side is reclaimed before the oracle's runs | | 4 | `8b81f52` | `chan-container-state` becomes an observed channel; `strip_intentional_labels` retired (`nonCompliantRules` → 0). Plus 15 review findings | | 5 | `1a502e5`, `e243921`, `2547340`, `3489f6d` | Container-state units migrate (69 → 74). Three deacon defects found; two fixed | +| 5a | — | T115: stamp the AUTHORED config in `devcontainer.metadata` (spec-mandated), re-substitute host-env tokens on read-back, `label_json_document` (`NORMALIZER_VERSION` 6). All three `case-state-*` agree | | 6 | — | `${CONTAINER_ID}` argv token; delete `parity_up_exec`, `parity_exec` | | 7 | — | `require_buildkit()`; image-by-name observation; delete `parity_build` | | 8 | — | Close out migration classes; retire V21/V22 or justify keeping them | @@ -90,8 +91,19 @@ Three divergences surfaced. Reasoning would have mis-classified two of them: measured, and the record says so, so the next reader sees what changed: not the field, the evidence. 3. **`devcontainer.metadata`** — two separate defects. deacon omitted the label entirely - where the reference stamps `[]` (fixed); and deacon substitutes `${localWorkspaceFolder}` - before stamping where the reference stores the template (T115, open). + where the reference stamps `[]` (fixed); and deacon substituted `${localWorkspaceFolder}` + before stamping where the reference stores the template (T115, **fixed** — see below). + +4. **T115 turned out not to be a parity question.** It was planned as "match the reference", + and the vendored spec settles it outright: *"Variables in string values will be + substituted at the time the value is applied"* (`image-metadata.md`, Merge Logic). + Recording is not applying. So the axis is `spec: nonconformant` → `conformant`, and the + right instrument was never a waiver. Measuring also widened the defect from `mounts` to + every picked string field, and caught a *second* divergence the fix would otherwise have + introduced: with templates in the label, the `--container-id` read-back must + re-substitute the host-env tokens the reference re-substitutes there (`${localEnv:…}` + resolves; workspace tokens stay literal — measured, not assumed). Fixing only the stamp + would have traded one divergence for another. The general lesson, worth stating plainly: *"captured but not compared, because it cannot matter"* is itself a claim about behavior, and an uncompared field is exactly where such a @@ -109,8 +121,8 @@ claim never gets tested. The legacy `diff_states` captured `cmd` and skipped it ## Blocking work -**T115 blocks deleting `parity_state_diff`**, and correctly so. The declarative replacements +**T115 blocked deleting `parity_state_diff`**, and correctly so. The declarative replacements compare more than the legacy carrier did, so they are *stricter* — and `equivalence-report` refuses a `stricter` verdict carrying no `characterizedAs`, because unproven-stricter is -indistinguishable from a newly introduced bug. Fixing or characterizing T115 is the -precondition for finishing step 5. +indistinguishable from a newly introduced bug. **Cleared in step 5a** by fixing deacon: the +three cases now `agree`, so the strictness is demonstrated rather than asserted. diff --git a/specs/023-migrate-parity-to-conformance/tasks.md b/specs/023-migrate-parity-to-conformance/tasks.md index f2442d4f..d82ee11d 100644 --- a/specs/023-migrate-parity-to-conformance/tasks.md +++ b/specs/023-migrate-parity-to-conformance/tasks.md @@ -563,13 +563,19 @@ Per Constitution I (Deferral Tracking); rationale in [research.md §4](./researc - **Acceptance**: each path has a three-axis behavior record and either a fix (a) or a backed tolerance (b); `case-up-container-identity-labels` reports `agree`/`allowed-difference` on every path. - **Resolved**: (a) deacon now emits `[]` — the `None` branch it returned was exactly the case where no inherited label existed, so the reasoning guarding it did not apply. (b) NOT an intentional divergence after all: the foreground `sleep` made SIGTERM undeliverable, costing **10,258 ms** per `docker stop` against the reference's 215 ms, with exit 137 rather than 0. Fixed on both the single-container and compose paths (245 ms / 138 ms), with regression tests. The `StateSnapshot` doc claiming this had "no observable behavioral difference" was corrected in place — that claim is what kept the field uncompared, and uncompared is where such a claim never gets tested. -- [ ] T115 [Deferral, 024 Phase 5] deacon stamps SUBSTITUTED paths into `devcontainer.metadata`; the reference stamps the template +- [X] T115 [Deferral, 024 Phase 5] deacon stamps SUBSTITUTED paths into `devcontainer.metadata`; the reference stamps the template — **RESOLVED BY FIXING DEACON**, and the spec says so - **Found by**: `case-state-single-container` / `case-state-mount-variety` / `case-state-dockerfile-nonroot` on their first live run against the pinned oracle 0.87.0. Measured on one fixture: deacon writes `"source=/tmp/…/ws-d/sib,target=/workspaces/sib,type=bind"`, the reference writes `"source=${localWorkspaceFolder}/sib,target=/workspaces/sib,type=bind"`. The `containerEnv` half of the same entry agrees. - **Why it looks like a deacon defect**: the label exists to describe the configuration to whoever reads the container later. Baking in the recording machine's absolute path makes it host-specific and leaks a host path into container metadata; the template is what survives being read on another machine. Normalization cannot paper over it either — tokenizing deacon's side yields `/sib`, which still differs in FORM from `${localWorkspaceFolder}/sib`. - **Why it is not fixed here**: `config_metadata_entry` runs on the already-substituted `DevContainerConfig`, so matching the reference means threading the PRE-substitution config to the stamp site — a real change needing its own tests, not a follow-on edit. - **Deliberately NOT tolerated**: the three cases report `diverge`, exactly as the T113 families do. A tolerance needs a backing record, and authoring one before deciding whether deacon should change would characterize a suspected defect as intentional. - **Blocks**: deleting `parity_state_diff`. The replacement cases are STRICTER than the legacy carrier (which never compared this), and `equivalence-report` blocks deletion on a `stricter` verdict with no `characterizedAs` — correctly, since unproven-stricter is indistinguishable from a new bug. - **Acceptance**: a three-axis behavior record plus either a deacon fix or a backed tolerance; the three cases report `agree`/`allowed-difference`. + - **Resolved (024 Phase 5b)**: FIXED, not tolerated — and the deciding evidence was not the reference at all. `conformance/spec/113500f4/image-metadata.md`'s Merge Logic section closes with *"Variables in string values will be substituted at the time the value is applied"* (`clu-image-metadata-…-desc-31cd8289`). Recording is not applying, so the recorded form is the template. This turns the entry's own framing on its head: it was written as "match the reference" (`reference: divergent` → align), and it is actually `spec: nonconformant` → now `conformant` / `reference: aligned` / `follow-spec`. The measurement widened it too: a probe fixture showed the reference stamps templates for `mounts`, `containerEnv`, `remoteEnv` AND `postCreateCommand` — not just `mounts` — while applying the substituted `containerEnv` value to `Config.Env`. So the defect was every picked string field, not one. + - **The fix**: `ConfigLoader::load_with_overrides_and_substitution_raw` now returns `(raw, substituted, report)` — the reference's `SubstitutedConfig { raw, config }` — surfaced as `ConfigLoadResult::raw_config`. `up` picks the metadata entry from `raw_config` at the top of the flow and threads the resulting `serde_json::Value` into both the single-container and compose stamp sites. `build_container_metadata_label` takes that value rather than a `&DevContainerConfig`, so a substituted config can no longer be passed by accident — the signature carries the invariant. Verified by a docker-gated end-to-end test (`up_stamps_authored_templates_in_devcontainer_metadata`) that was **demonstrated to fail** on the pre-fix behavior, printing the leaked `/tmp/.tmpKlx2Jh` paths. + - **A second divergence the fix would have CREATED, found by measuring instead of stopping**: with templates in the label, the `--container-id` read-back surfaces them literally. Measured on the reference: `devcontainer exec --container-id` prints `LWF=[${localWorkspaceFolder}]`, `CWF=[${containerWorkspaceFolder}]` — literal — but `LENV=[/home/vscode]`, i.e. it re-substitutes the tokens it *can* resolve without a workspace. deacon previously matched on `${localEnv:…}` only by accident (the value was baked in at stamp time). So `config_from_metadata_label` now applies `SubstitutionContext::host_env_only()`: `${localEnv:VAR}` / `${env:VAR}` resolve, workspace/container tokens stay literal, `${containerEnv:VAR}` defers to the env-probe pass. Fixing only the stamp would have traded one divergence for another. + - **Normalization change, and why it is not a blanket rule**: `case-state-dockerfile-nonroot` still diverged after the fix, on nothing but key insertion order and two spaces — `[{"remoteUser":"dev","containerUser":"dev",…}]` versus `[ {"containerEnv":…,"containerUser":"dev","remoteUser":"dev"} ]`. Added the named, single-key rule `label_json_document` (scope `channel:chan-container-state`): the one label whose value IS a JSON document is parsed and compared structurally. It is `label_semantic` applied one level deeper — a label SET is a mapping rather than an opaque string, and so is a label VALUE that is a document. It removes nothing: every key and value is still compared, array order is preserved (fragment order carries precedence), a malformed value is left verbatim so it still diverges, and a label outside the enumerated set is untouched. The alternative — aligning deacon's pick order with upstream's `pickConfigProperties` — would pin deacon to an implementation detail of the reference's serializer with no meaning and no stability guarantee. `NORMALIZER_VERSION` 5 → 6 and the one committed snapshot was refreshed through the reviewed path (diff: the version field only). + - **A stale claim corrected in lockstep**: `POST_BRANCH_BEHAVIORS`'s `bhv-container-identity-labels` entry and `src-obs-container-identity-labels` both asserted `devcontainer.metadata` "compares byte-equal". That was measured on `fx-up-basic`, whose bare config contributes no picked property so both sides stamp `[]` — true there, false in general. A measurement on one fixture is not a claim about the field, which is the same lesson the keep-alive `cmd` field taught in T114. + - **Result**: all three cases report `agree` on the live differential against pinned oracle 0.87.0. The 51 remaining diverging cases are entirely `chan-structured-output` (the T113 families), untouched by this change. --- From e3629cc6e723b004ee55516ecb709554f8149b61 Mon Sep 17 00:00:00 2001 From: Paul O'Fallon Date: Sun, 26 Jul 2026 01:21:08 +0000 Subject: [PATCH 2/2] fix(tests): make the T115 read-back test env-var-name agnostic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test hard-coded `${localEnv:PATH}` and compared against `std::env::var("PATH")`. On Windows the variable is spelled `Path`: `std::env::var` matches case-insensitively, but the substitution context looks it up in a `HashMap` built from `env::vars()`, which does not. The token resolved to the empty string and only the Windows lane failed. It now takes the probe variable from `env::vars()` — the same iteration the substitution context uses — so the test and the code agree about the name by construction rather than by coincidence. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017UCGzJEFZd85HJqz1Wx2kE --- .../src/commands/shared/container_metadata.rs | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/crates/deacon/src/commands/shared/container_metadata.rs b/crates/deacon/src/commands/shared/container_metadata.rs index 948c5a8b..fb4fabc4 100644 --- a/crates/deacon/src/commands/shared/container_metadata.rs +++ b/crates/deacon/src/commands/shared/container_metadata.rs @@ -106,18 +106,26 @@ mod tests { /// the later env-probe pass. #[test] fn read_back_resolves_host_env_and_leaves_workspace_tokens_literal() { - // `PATH` rather than a var this test sets: `unsafe_code` is denied - // workspace-wide, so `set_var` is unavailable, and PATH is always present. - let want = std::env::var("PATH").expect("PATH is set"); + // The probe variable is taken from the environment rather than set, because + // `unsafe_code` is denied workspace-wide so `set_var` is unavailable. It is also + // read through `env::vars()` — the same iteration the substitution context uses — + // rather than `env::var("PATH")`: on Windows the variable is spelled `Path`, and + // `env::var` matches it case-insensitively while a `HashMap` lookup does not. The + // first draft hard-coded `PATH` and failed only on the Windows lane. + let (probe_name, want) = std::env::vars() + .find(|(k, v)| { + !v.is_empty() && k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') + }) + .expect("at least one non-empty environment variable with a simple name"); - let cfg = config_from_metadata_label(&container_with_label( - r#"[{"remoteEnv":{ - "HOSTENV":"${localEnv:PATH}", - "LWF":"${localWorkspaceFolder}", - "CWF":"${containerWorkspaceFolder}", - "CENV":"${containerEnv:PATH}" - }}]"#, - )) + let cfg = config_from_metadata_label(&container_with_label(&format!( + r#"[{{"remoteEnv":{{ + "HOSTENV":"${{localEnv:{probe_name}}}", + "LWF":"${{localWorkspaceFolder}}", + "CWF":"${{containerWorkspaceFolder}}", + "CENV":"${{containerEnv:PATH}}" + }}}}]"# + ))) .unwrap() .expect("label present");