diff --git a/.apm/architecture/owners/contracts-tooling.json b/.apm/architecture/owners/contracts-tooling.json
index 31ef286093..0d10ee0b61 100644
--- a/.apm/architecture/owners/contracts-tooling.json
+++ b/.apm/architecture/owners/contracts-tooling.json
@@ -1,6 +1,13 @@
{
"version": 1,
"owners": [
+ {
+ "id": "spec-assessment-selection",
+ "decision": "One active specification artifact, informative manifest, exact assessment identity, and fresh static binding inventory",
+ "owner": "tests/spec_conformance/_manifest.py",
+ "selectors": ["tests/spec_conformance/_manifest.py"],
+ "guards": ["contracts-tooling-spec-assessment"]
+ },
{
"id": "dependency-identity-materialization",
"decision": "Dependency comparison identity and policy casing vs display-cased materialization path; embedded git URL subpath validation",
diff --git a/.apm/architecture/owners/core-runtime.json b/.apm/architecture/owners/core-runtime.json
index e07a27a0f4..2075d1b5bf 100644
--- a/.apm/architecture/owners/core-runtime.json
+++ b/.apm/architecture/owners/core-runtime.json
@@ -10,9 +10,12 @@
},
{
"id": "effective-install-target-selection",
- "decision": "Effective install target selection",
+ "decision": "Effective install and audit target selection",
"owner": "core/target_detection.py (EffectiveTargetDecision)",
- "selectors": ["src/apm_cli/core/target_detection.py"],
+ "selectors": [
+ "src/apm_cli/core/target_detection.py",
+ "src/apm_cli/install/audit_target_roots.py"
+ ],
"guards": ["registry-delegation-install-target-selection"]
},
{
diff --git a/.apm/architecture/owners/install-deployment.json b/.apm/architecture/owners/install-deployment.json
index a0872223aa..a189ce069b 100644
--- a/.apm/architecture/owners/install-deployment.json
+++ b/.apm/architecture/owners/install-deployment.json
@@ -95,6 +95,16 @@
"selectors": ["src/apm_cli/commands/install.py"],
"guards": ["install-deployment-install-scope-selection"]
},
+ {
+ "id": "local-dependency-scope-admission",
+ "decision": "Declaring-source provenance and local dependency scope admission",
+ "owner": "deps/apm_resolver.py (_source_kind_for_dependency) projects acquisition provenance onto APMPackage; install/package_resolution.py (user_scope_rejection_reason) owns admission",
+ "selectors": [
+ "src/apm_cli/install/package_resolution.py",
+ "src/apm_cli/deps/apm_resolver.py"
+ ],
+ "guards": ["install-deployment-local-scope-admission"]
+ },
{
"id": "mcp-registry-url-resolution",
"decision": "MCP registry URL resolution precedence",
diff --git a/.github/workflows/spec-conformance.yml b/.github/workflows/spec-conformance.yml
index 0daa414319..622fecd067 100644
--- a/.github/workflows/spec-conformance.yml
+++ b/.github/workflows/spec-conformance.yml
@@ -1,6 +1,6 @@
name: Spec conformance
-# Enforces the OpenAPM v0.1 spec-vs-implementation 4-way bind:
+# Enforces the selected OpenAPM revision's spec-vs-implementation 4-way bind:
# spec body anchors == requirements manifest == Appendix C rows == pytest req markers
# Then runs the conformance suite, regenerates CONFORMANCE.{json,md},
# and gates on a clean git diff so contributors must commit any
@@ -11,6 +11,7 @@ on:
branches: [ main ]
paths:
- 'docs/src/content/docs/specs/**'
+ - 'docs/public/specs/**'
- 'tests/fixtures/spec-conformance/**'
- 'tests/spec_conformance/**'
- 'src/apm_cli/**'
@@ -46,7 +47,7 @@ jobs:
env:
BASE_REF: origin/${{ github.event.pull_request.base.ref || 'main' }}
GH_PR_BODY: ${{ github.event.pull_request.body }}
- run: bash tests/spec_conformance/mode_b_detector.sh
+ run: uv run --frozen --extra dev bash tests/spec_conformance/mode_b_detector.sh
- name: Conformance test suite
run: |
diff --git a/.gitignore b/.gitignore
index 09218eb718..05749cd214 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,6 +16,7 @@ __pycache__/
build/build/
build/apm/
build/conformance-coverage.json
+build/conformance-coverage-*.json
*.bak
develop-eggs/
dist/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4ef4721095..ac51c556b7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -23,6 +23,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- The shared gh-aw APM pack job now declares `contents: read` (previously `permissions: {}`), the minimum the explicit built-in-token path needs. No write scope is added, and the token is not forwarded to restore or agent jobs. (#2706)
- Dependency policy `allow`, `deny`, and exact `require` matching now follows canonical owner/repository casing, fixing mixed-case blocks and deny fail-open behavior while retaining lazy shared required-package lookup. APM 0.30.0 and earlier match patterns byte-exactly against the lowercased identity; lowercase patterns keep matching in every release, so drop workaround duplicates only after every runner uses a release carrying this fix. (#2706)
+### Fixed
+
+- Global installs anchor local children to established declaring sources without treating disguised remote names as local, and audit replays current target intent without modifying live configuration or native state. The corrective OpenAPM draft is assessed explicitly as `v0.2.0`; previous exact-version assessments remain available, and human ratification and activation are pending. (#2820)
+
## [0.30.0] - 2026-09-07
### Security
diff --git a/CONFORMANCE.json b/CONFORMANCE.json
index b2fbc74e4d..ef37effdae 100644
--- a/CONFORMANCE.json
+++ b/CONFORMANCE.json
@@ -1,10 +1,23 @@
{
+ "activation": "UNSATISFIED",
+ "assessment_limitations": [
+ "The reference CLI's bare content audit uses source-derived drift replay, not the stored-hash baseline required by req-lk-017's unqualified audit obligation. The stored-hash and full-SHA consistency baselines are exercised in CI/conformance audit. This inventory does not claim full Consumer conformance in bare audit mode.",
+ "The native Cowork audit controls use a controlled pre-existing standalone-skill snapshot; they do not establish a successful Cowork install/audit round trip. The Grok user-scope control does exercise install and audit.",
+ "A selected native runtime without an isolated replay backend is reported as unsupported before its live writer. No new native database scratch backend or hosted-runtime evidence is supplied by this inventory.",
+ "A source-only coupled probe shows that local acquisition dereferences an admitted internal resource symlink, but inherited replay plans from the original source representation and can falsely report the deployed regular file as orphaned during unchanged CI audit. The narrowly expected-failing regression separately verifies content integrity and unchanged live state; an escaping-link refusal control remains unsuppressed. This is a replay limitation, not evidence of an escape, external-file read or security bypass.",
+ "The retained manifest schema rejects git entries with a path modifier and id entries with an explicit registry modifier. It also accepts malformed or wrong-length policy.hash strings structurally. Schema acceptance is not evidence of Consumer digest-envelope enforcement under req-mf-018 or req-lk-016.",
+ "Inherited Git-tree boundaries for symlink blobs, gitlinks/submodules and CRLF/LFS-filtered checkout bytes lack cross-platform execution evidence in this assessment. The req-lk-015 obligation and digest construction remain unchanged."
+ ],
+ "assessment_status": "DRAFT",
"consumer_user_scope": {
"lockfile_location": "~/.apm/apm.lock.yaml",
"manifest_location": "~/.apm/apm.yml",
"target_capability_declaration": "MCPClientAdapter.supports_user_scope (OpenAPM Target Registry v0.1 implementation profile)"
},
- "generator": "gen_statement.py v1",
+ "generator": "gen_statement.py v2",
+ "human_ratification": "UNSATISFIED",
+ "inventory_kind": "static-test-bindings",
+ "manifest_sha256": "4d9d605f50e02318bda055320e2d15e4039352ca5b9da0e8d12196979718d582",
"requirements": [
{
"conformance_class": "consumer",
@@ -259,7 +272,7 @@
"tests/spec_conformance/test_lockfile_reqs.py::test_lockfile_should_record_publish_timestamp"
],
"waivers": [
- "Publish-timestamp recording is a publisher-side SHOULD that requires registry interaction to exercise end-to-end. The schema affordance (generated_at) is asserted above; full publisher coverage requires the registry wire conformance module which is not in v0.1 scope."
+ "Publish-timestamp recording is a publisher-side SHOULD that requires registry interaction to exercise end-to-end. The schema affordance (generated_at) is asserted above; full publisher coverage requires the registry wire conformance module which remains outside this revision's scope."
]
},
{
@@ -312,6 +325,50 @@
"tests/spec_conformance/test_lockfile_reqs.py::test_materialization_spelling_migrates_one_case_variant_transactionally"
]
},
+ {
+ "conformance_class": "consumer",
+ "id": "req-lk-023",
+ "keyword": "MUST",
+ "section": "5.5",
+ "status": "active",
+ "test_count": 34,
+ "tests": [
+ "tests/spec_conformance/test_audit_current_intent_contract.py::test_admitted_internal_resource_link_survives_unchanged_audit",
+ "tests/spec_conformance/test_audit_current_intent_contract.py::test_configured_target_keeps_integrity_and_membership_checks[content]",
+ "tests/spec_conformance/test_audit_current_intent_contract.py::test_configured_target_keeps_integrity_and_membership_checks[missing-claim]",
+ "tests/spec_conformance/test_audit_current_intent_contract.py::test_current_intent_overrides_old_target_ownership[config]",
+ "tests/spec_conformance/test_audit_current_intent_contract.py::test_current_intent_overrides_old_target_ownership[detection]",
+ "tests/spec_conformance/test_audit_current_intent_contract.py::test_current_intent_overrides_old_target_ownership[manifest]",
+ "tests/spec_conformance/test_audit_current_intent_contract.py::test_full_audit_does_not_recreate_absent_configuration",
+ "tests/spec_conformance/test_audit_current_intent_contract.py::test_historical_native_claims_cannot_pass_filesystem_only_audit[both]",
+ "tests/spec_conformance/test_audit_current_intent_contract.py::test_historical_native_claims_cannot_pass_filesystem_only_audit[canonical]",
+ "tests/spec_conformance/test_audit_current_intent_contract.py::test_historical_native_claims_cannot_pass_filesystem_only_audit[legacy]",
+ "tests/spec_conformance/test_audit_current_intent_contract.py::test_invalid_current_selection_cannot_pass_empty_replay[disabled]",
+ "tests/spec_conformance/test_audit_current_intent_contract.py::test_invalid_current_selection_cannot_pass_empty_replay[manifest]",
+ "tests/spec_conformance/test_audit_current_intent_contract.py::test_malformed_saved_intent_fails_only_when_selected[False-42]",
+ "tests/spec_conformance/test_audit_current_intent_contract.py::test_malformed_saved_intent_fails_only_when_selected[False-None]",
+ "tests/spec_conformance/test_audit_current_intent_contract.py::test_malformed_saved_intent_fails_only_when_selected[False-claudee]",
+ "tests/spec_conformance/test_audit_current_intent_contract.py::test_malformed_saved_intent_fails_only_when_selected[False-invalid1]",
+ "tests/spec_conformance/test_audit_current_intent_contract.py::test_malformed_saved_intent_fails_only_when_selected[True-42]",
+ "tests/spec_conformance/test_audit_current_intent_contract.py::test_malformed_saved_intent_fails_only_when_selected[True-None]",
+ "tests/spec_conformance/test_audit_current_intent_contract.py::test_malformed_saved_intent_fails_only_when_selected[True-claudee]",
+ "tests/spec_conformance/test_audit_current_intent_contract.py::test_malformed_saved_intent_fails_only_when_selected[True-invalid1]",
+ "tests/spec_conformance/test_audit_current_intent_contract.py::test_native_user_skill_replay_preserves_layout_and_comparison[clean]",
+ "tests/spec_conformance/test_audit_current_intent_contract.py::test_native_user_skill_replay_preserves_layout_and_comparison[contracted]",
+ "tests/spec_conformance/test_audit_current_intent_contract.py::test_native_user_skill_replay_preserves_layout_and_comparison[forged-content]",
+ "tests/spec_conformance/test_audit_current_intent_contract.py::test_native_user_skill_replay_preserves_layout_and_comparison[legacy-contracted]",
+ "tests/spec_conformance/test_audit_current_intent_contract.py::test_native_user_skill_replay_preserves_layout_and_comparison[legacy-directory]",
+ "tests/spec_conformance/test_audit_current_intent_contract.py::test_native_user_skill_replay_preserves_layout_and_comparison[missing-claim]",
+ "tests/spec_conformance/test_audit_current_intent_contract.py::test_native_user_skill_replay_preserves_layout_and_comparison[symlink]",
+ "tests/spec_conformance/test_audit_current_intent_contract.py::test_native_user_skill_replay_preserves_layout_and_comparison[unavailable-root-no-config]",
+ "tests/spec_conformance/test_audit_current_intent_contract.py::test_native_user_skill_replay_preserves_layout_and_comparison[unavailable-root]",
+ "tests/spec_conformance/test_audit_current_intent_contract.py::test_native_workflow_replay_fails_before_writer",
+ "tests/spec_conformance/test_audit_current_intent_contract.py::test_saved_explicit_only_target_replays_without_detection",
+ "tests/spec_conformance/test_audit_current_intent_contract.py::test_saved_target_drives_read_only_source_replay[cold]",
+ "tests/spec_conformance/test_audit_current_intent_contract.py::test_saved_target_drives_read_only_source_replay[warm]",
+ "tests/spec_conformance/test_audit_current_intent_contract.py::test_user_scope_replay_keeps_user_target_bytes"
+ ]
+ },
{
"conformance_class": "producer",
"id": "req-mf-001",
@@ -482,13 +539,56 @@
"id": "req-mf-016",
"keyword": "MUST",
"section": "4.3.5",
- "status": "skipped",
- "test_count": 1,
- "tests": [
- "tests/spec_conformance/test_manifest_reqs.py::test_consumer_rejects_absolute_paths_in_apm_source"
- ],
- "waivers": [
- "Path-shape negative test requires apm_cli's path-policy loader to be invokable from the test harness; the JSON Schema currently models `path` as a free-form string. Tracked as a follow-up: tighten the schema to forbid leading `/` and document the absolute-path rejection in the schema additionalProperties."
+ "status": "active",
+ "test_count": 47,
+ "tests": [
+ "tests/spec_conformance/test_audit_current_intent_contract.py::test_local_resource_escape_is_rejected_before_audit",
+ "tests/spec_conformance/test_local_path_reqs.py::test_direct_local_source_is_anchored_before_copy[project-relative]",
+ "tests/spec_conformance/test_local_path_reqs.py::test_direct_local_source_is_anchored_before_copy[user-absolute]",
+ "tests/spec_conformance/test_local_path_reqs.py::test_direct_local_source_is_anchored_before_copy[user-home]",
+ "tests/spec_conformance/test_local_path_reqs.py::test_local_package_rejects_file_symlink_cycle",
+ "tests/spec_conformance/test_local_path_reqs.py::test_local_package_rejects_uncontained_or_unresolvable_symlink[broken]",
+ "tests/spec_conformance/test_local_path_reqs.py::test_local_package_rejects_uncontained_or_unresolvable_symlink[directory-cycle]",
+ "tests/spec_conformance/test_local_path_reqs.py::test_local_package_rejects_uncontained_or_unresolvable_symlink[outside-directory]",
+ "tests/spec_conformance/test_local_path_reqs.py::test_local_package_rejects_uncontained_or_unresolvable_symlink[outside-file]",
+ "tests/spec_conformance/test_local_path_reqs.py::test_local_path_prefixes_are_recognized[../child]",
+ "tests/spec_conformance/test_local_path_reqs.py::test_local_path_prefixes_are_recognized[..\\\\child]",
+ "tests/spec_conformance/test_local_path_reqs.py::test_local_path_prefixes_are_recognized[./child]",
+ "tests/spec_conformance/test_local_path_reqs.py::test_local_path_prefixes_are_recognized[.\\\\child]",
+ "tests/spec_conformance/test_local_path_reqs.py::test_local_path_prefixes_are_recognized[/child]",
+ "tests/spec_conformance/test_local_path_reqs.py::test_local_path_prefixes_are_recognized[~/child]",
+ "tests/spec_conformance/test_local_path_reqs.py::test_local_path_prefixes_are_recognized[~\\\\child]",
+ "tests/spec_conformance/test_local_path_reqs.py::test_local_sibling_uses_original_source_in_each_scope[InstallScope.PROJECT]",
+ "tests/spec_conformance/test_local_path_reqs.py::test_local_sibling_uses_original_source_in_each_scope[InstallScope.USER]",
+ "tests/spec_conformance/test_local_path_reqs.py::test_missing_user_anchor_does_not_use_other_scope_install",
+ "tests/spec_conformance/test_local_path_reqs.py::test_remote_parent_cannot_expand_into_host_files[absolute-inside]",
+ "tests/spec_conformance/test_local_path_reqs.py::test_remote_parent_cannot_expand_into_host_files[absolute]",
+ "tests/spec_conformance/test_local_path_reqs.py::test_remote_parent_cannot_expand_into_host_files[escape]",
+ "tests/spec_conformance/test_local_path_reqs.py::test_remote_parent_cannot_expand_into_host_files[home]",
+ "tests/spec_conformance/test_local_path_reqs.py::test_remote_parent_cannot_expand_into_host_files[symlink]",
+ "tests/spec_conformance/test_local_path_reqs.py::test_remote_parent_cannot_expand_into_host_files[windows-absolute]",
+ "tests/spec_conformance/test_local_path_reqs.py::test_remote_paths_are_routed_before_local_admission[absolute-_local/parent-InstallScope.PROJECT]",
+ "tests/spec_conformance/test_local_path_reqs.py::test_remote_paths_are_routed_before_local_admission[absolute-_local/parent-InstallScope.USER]",
+ "tests/spec_conformance/test_local_path_reqs.py::test_remote_paths_are_routed_before_local_admission[absolute-org/repo-InstallScope.PROJECT]",
+ "tests/spec_conformance/test_local_path_reqs.py::test_remote_paths_are_routed_before_local_admission[absolute-org/repo-InstallScope.USER]",
+ "tests/spec_conformance/test_local_path_reqs.py::test_remote_paths_are_routed_before_local_admission[escape-_local/parent-InstallScope.PROJECT]",
+ "tests/spec_conformance/test_local_path_reqs.py::test_remote_paths_are_routed_before_local_admission[escape-_local/parent-InstallScope.USER]",
+ "tests/spec_conformance/test_local_path_reqs.py::test_remote_paths_are_routed_before_local_admission[escape-org/repo-InstallScope.PROJECT]",
+ "tests/spec_conformance/test_local_path_reqs.py::test_remote_paths_are_routed_before_local_admission[escape-org/repo-InstallScope.USER]",
+ "tests/spec_conformance/test_local_path_reqs.py::test_remote_paths_are_routed_before_local_admission[sibling-_local/parent-InstallScope.PROJECT]",
+ "tests/spec_conformance/test_local_path_reqs.py::test_remote_paths_are_routed_before_local_admission[sibling-_local/parent-InstallScope.USER]",
+ "tests/spec_conformance/test_local_path_reqs.py::test_remote_paths_are_routed_before_local_admission[sibling-org/repo-InstallScope.PROJECT]",
+ "tests/spec_conformance/test_local_path_reqs.py::test_remote_paths_are_routed_before_local_admission[sibling-org/repo-InstallScope.USER]",
+ "tests/spec_conformance/test_local_path_reqs.py::test_remote_relative_child_retains_repository_and_ref[../child]",
+ "tests/spec_conformance/test_local_path_reqs.py::test_remote_relative_child_retains_repository_and_ref[..\\\\child]",
+ "tests/spec_conformance/test_local_path_reqs.py::test_selected_local_root_allows_only_internal_symlink_content[resolved-source-alias]",
+ "tests/spec_conformance/test_local_path_reqs.py::test_selected_local_root_allows_only_internal_symlink_content[source-directory]",
+ "tests/spec_conformance/test_local_path_reqs.py::test_user_multihop_chain_keeps_each_original_anchor",
+ "tests/spec_conformance/test_local_path_reqs.py::test_user_relative_admission_requires_proven_local_parent[direct]",
+ "tests/spec_conformance/test_local_path_reqs.py::test_user_relative_admission_requires_proven_local_parent[missing-parent]",
+ "tests/spec_conformance/test_local_path_reqs.py::test_user_relative_admission_requires_proven_local_parent[missing-source]",
+ "tests/spec_conformance/test_local_path_reqs.py::test_user_relative_admission_requires_proven_local_parent[relative-source]",
+ "tests/spec_conformance/test_local_path_reqs.py::test_user_relative_admission_requires_proven_local_parent[remote]"
]
},
{
@@ -508,9 +608,11 @@
"keyword": "MUST",
"section": "4.6.1",
"status": "active",
- "test_count": 1,
+ "test_count": 3,
"tests": [
- "tests/spec_conformance/test_manifest_reqs.py::test_consumer_restricts_policy_hash_algorithm_to_strong_set"
+ "tests/spec_conformance/test_manifest_reqs.py::test_consumer_restricts_policy_hash_algorithm_to_strong_set",
+ "tests/spec_conformance/test_manifest_reqs.py::test_retained_schema_accepts_invalid_policy_hash_without_semantic_evidence[not-a-digest]",
+ "tests/spec_conformance/test_manifest_reqs.py::test_retained_schema_accepts_invalid_policy_hash_without_semantic_evidence[sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa]"
]
},
{
@@ -543,7 +645,7 @@
"status": "active",
"test_count": 1,
"tests": [
- "tests/spec_conformance/test_manifest_reqs.py::test_producer_workspaces_must_not_use_in_v0_1"
+ "tests/spec_conformance/test_manifest_reqs.py::test_producer_workspaces_remain_reserved"
]
},
{
@@ -752,8 +854,9 @@
"keyword": "MUST",
"section": "6.8",
"status": "active",
- "test_count": 1,
+ "test_count": 2,
"tests": [
+ "tests/spec_conformance/test_audit_current_intent_contract.py::test_configured_target_does_not_authorize_invalid_owners",
"tests/spec_conformance/test_policy_reqs.py::test_deployment_ledger_owner_is_hard_integrity_failure"
]
},
@@ -1423,11 +1526,14 @@
]
}
],
- "spec_version": "v0.1.1",
+ "spec_citation": "https://microsoft.github.io/apm/spec/v0.2.0",
+ "spec_path": "docs/src/content/docs/specs/openapm-v0.2.md",
+ "spec_sha256": "280851a128e403d16cddb766ddbb868d42cee7c6147efdaf4118abbe37e993dc",
+ "spec_version": "v0.2.0",
"summary_by_class": {
"consumer": {
- "active": 90,
- "skipped": 1,
+ "active": 92,
+ "skipped": 0,
"unbound": 0,
"xfail": 0
},
@@ -1450,5 +1556,5 @@
"xfail": 0
}
},
- "total_requirements": 122
+ "total_requirements": 123
}
diff --git a/CONFORMANCE.md b/CONFORMANCE.md
index a6c0ce79fd..f0120c3e44 100644
--- a/CONFORMANCE.md
+++ b/CONFORMANCE.md
@@ -1,164 +1,184 @@
-# OpenAPM Conformance Statement -- v0.1.1
+# OpenAPM Conformance Binding Inventory -- v0.2.0 (DRAFT)
-Generator: gen_statement.py v1.
-Spec: [docs/src/content/docs/specs/openapm-v0.1.md](docs/src/content/docs/specs/openapm-v0.1.md)
+Generator: gen_statement.py v2.
+Spec: [docs/src/content/docs/specs/openapm-v0.2.md](docs/src/content/docs/specs/openapm-v0.2.md)
+Exact revision citation: https://microsoft.github.io/apm/spec/v0.2.0
This file is generated. Do NOT edit by hand. Run
`uv run python -m tests.spec_conformance.gen_statement` to regenerate.
## Honesty contract
-There is NO automated CI detector for spec-vs-behaviour drift beyond the four sets enforced by `orphan_check.py`: spec anchors, manifest entries, Appendix C rows, and `@pytest.mark.req` markers. A requirement marked `status=active` is exercised by at least one assertion. A requirement marked `status=skipped` carries a written waiver below; this is debt, not coverage. A requirement with `status=xfail` is asserted-but-known-broken.
+There is NO automated CI detector for spec-vs-behaviour drift beyond the four sets enforced by `orphan_check.py`: spec anchors, manifest entries, Appendix C rows, and `@pytest.mark.req` markers. Statuses are a static binding inventory from fresh full-suite collection, not executed test results or a runtime pass certificate. `status=active` means a collected binding is not statically marked skipped or xfail; it does not prove that an assertion ran or passed. `status=skipped` and `status=xfail` describe static markers or waiver calls, not measured execution outcomes. Waivers are listed below as debt. Separate test execution and implementation evidence remain necessary.
+
+This inventory assesses only the selected DRAFT corrective revision. It does not establish historical CLI conformance to the previous minor's req-mf-016 blanket project-root refusal. Human ratification and activation are UNSATISFIED. A prepared specification and collected bindings do not establish publication or ratification.
+
+Two qualified nonauthor human approvals (one with implementation experience and one with consumer/integrator experience), the process-issue label, and explicit human ratification/publication remain required. No human approval is recorded by this inventory. Only the public-comment requirement was waived by the [recorded decision](https://github.com/microsoft/apm/issues/2818#issuecomment-5558647529). Automated reviews and passing spec-conformance checks do not ratify.
## Conformance classes
-All four conformance classes (Producer, Consumer, Registry, Governance) carry active coverage in this statement. The Registry class is exercised via the trust-anchor invariant test in `tests/spec_conformance/test_registry_reqs.py`, which hashes the committed Registry-archive fixture and asserts equality with the digest the paired lockfile advertises (sec.11.3.3, req-rg-001).
+The four conformance classes (Producer, Consumer, Registry, Governance) are inventoried below, not certified by this report. The Registry binding includes the trust-anchor invariant test in `tests/spec_conformance/test_registry_reqs.py`, which hashes the committed Registry-archive fixture and asserts equality with the digest the paired lockfile advertises (sec.11.3.3, req-rg-001).
## Repository case rules
Repository-coordinate segments are case-insensitive for `github.com`, GitHub Enterprise Cloud hosts ending in `.ghe.com`, the literal GitHub Enterprise Server host selected by `GITHUB_HOST`, and registry-sourced dependencies (including registry prefixes). Local paths, marketplace identities, and every other host remain case-sensitive. Policy matching and repository identity use the same rule (req-rs-016 clause 3; req-pl-018).
+## Assessment limitations
+
+The reference CLI's bare content audit uses source-derived drift replay, not the stored-hash baseline required by req-lk-017's unqualified audit obligation. The stored-hash and full-SHA consistency baselines are exercised in CI/conformance audit. This inventory does not claim full Consumer conformance in bare audit mode.
+
+The native Cowork audit controls use a controlled pre-existing standalone-skill snapshot; they do not establish a successful Cowork install/audit round trip. The Grok user-scope control does exercise install and audit.
+
+A selected native runtime without an isolated replay backend is reported as unsupported before its live writer. No new native database scratch backend or hosted-runtime evidence is supplied by this inventory.
+
+A source-only coupled probe shows that local acquisition dereferences an admitted internal resource symlink, but inherited replay plans from the original source representation and can falsely report the deployed regular file as orphaned during unchanged CI audit. The narrowly expected-failing regression separately verifies content integrity and unchanged live state; an escaping-link refusal control remains unsuppressed. This is a replay limitation, not evidence of an escape, external-file read or security bypass.
+
+The retained manifest schema rejects git entries with a path modifier and id entries with an explicit registry modifier. It also accepts malformed or wrong-length policy.hash strings structurally. Schema acceptance is not evidence of Consumer digest-envelope enforcement under req-mf-018 or req-lk-016.
+
+Inherited Git-tree boundaries for symlink blobs, gitlinks/submodules and CRLF/LFS-filtered checkout bytes lack cross-platform execution evidence in this assessment. The req-lk-015 obligation and digest construction remain unchanged.
+
## Consumer user-scope disclosure
- Manifest: `~/.apm/apm.yml`
- Lockfile: `~/.apm/apm.lock.yaml`
- Target capability declaration: `MCPClientAdapter.supports_user_scope (OpenAPM Target Registry v0.1 implementation profile)`
-## Coverage summary
+## Binding summary
| Class | Active | Skipped | Xfail | Unbound |
|-------|-------:|--------:|------:|--------:|
| Producer | 12 | 0 | 0 | 0 |
-| Consumer | 90 | 1 | 0 | 0 |
+| Consumer | 92 | 0 | 0 | 0 |
| Registry | 1 | 0 | 0 | 0 |
| Governance | 18 | 0 | 0 | 0 |
-## Per-requirement coverage
+## Per-requirement bindings
| Req ID | Keyword | Sec | Class | Status | Tests | Oracle |
|--------|---------|----:|-------|--------|------:|--------|
-| [req-cf-001](docs/src/content/docs/specs/openapm-v0.1.md#req-cf-001) | MUST | 12.5 | consumer | active | 6 | - |
-| [req-cf-002](docs/src/content/docs/specs/openapm-v0.1.md#req-cf-002) | MUST | 12.3 | consumer | active | 1 | - |
-| [req-ext-001](docs/src/content/docs/specs/openapm-v0.1.md#req-ext-001) | MUST | 4.1 | consumer | active | 1 | - |
-| [req-ext-002](docs/src/content/docs/specs/openapm-v0.1.md#req-ext-002) | MUST | 4.1 | producer | active | 1 | - |
-| [req-lk-001](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-001) | MUST | 5.1 | consumer | active | 1 | - |
-| [req-lk-002](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-002) | MUST | 5.4 | consumer | active | 1 | - |
-| [req-lk-003](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-003) | MUST | 5.2 | consumer | active | 2 | - |
-| [req-lk-004](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-004) | MUST | 5.4 | consumer | active | 1 | - |
-| [req-lk-005](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-005) | MUST | 5.5 | consumer | active | 2 | - |
-| [req-lk-006](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-006) | MUST | 5.5 | consumer | active | 1 | - |
-| [req-lk-007](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-007) | SHOULD | 5.5 | consumer | active | 1 | - |
-| [req-lk-008](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-008) | MUST | 5.6 | consumer | active | 1 | - |
-| [req-lk-009](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-009) | MUST | 5.6 | consumer | active | 1 | - |
-| [req-lk-010](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-010) | MUST | 5.6 | consumer | active | 1 | - |
-| [req-lk-011](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-011) | MUST | 5.2 | consumer | active | 1 | - |
-| [req-lk-012](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-012) | MUST | 5.2 | consumer | active | 1 | - |
-| [req-lk-013](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-013) | MUST | 5.2 | consumer | active | 2 | - |
-| [req-lk-014](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-014) | MUST | 5.2 | consumer | active | 1 | - |
-| [req-lk-015](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-015) | MUST | 5.6.4 | consumer | active | 1 | - |
-| [req-lk-016](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-016) | MUST | 5.2 | consumer | active | 1 | - |
-| [req-lk-017](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-017) | MUST | 5.2 | consumer | active | 1 | - |
-| [req-lk-018](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-018) | SHOULD | 5.5 | consumer | active | 1 | - |
-| [req-lk-019](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-019) | MUST | 5.2 | consumer | active | 1 | - |
-| [req-lk-020](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-020) | MUST | 5.2 | consumer | active | 3 | - |
-| [req-lk-021](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-021) | MUST | 5.2 | consumer | active | 2 | - |
-| [req-lk-022](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-022) | MUST | 5.2 | consumer | active | 4 | - |
-| [req-mf-001](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-001) | MUST | 4.1 | producer | active | 1 | - |
-| [req-mf-002](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-002) | MUST | 4.1 | producer | active | 1 | - |
-| [req-mf-003](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-003) | MUST | 4.1 | producer | active | 1 | - |
-| [req-mf-004](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-004) | SHOULD | 4.1 | producer | active | 1 | - |
-| [req-mf-005](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-005) | MUST | 4.2.1 | producer | active | 1 | - |
-| [req-mf-006](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-006) | MUST | 4.1 | consumer | active | 1 | - |
-| [req-mf-007](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-007) | MUST | 4.3.1 | consumer | active | 1 | - |
-| [req-mf-008](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-008) | MUST | 4.3.3 | consumer | active | 1 | - |
-| [req-mf-009](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-009) | MUST | 4.3.4 | consumer | active | 1 | - |
-| [req-mf-010](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-010) | MUST | 4.3.2 | consumer | active | 1 | - |
-| [req-mf-011](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-011) | MUST | 4.3.2 | consumer | active | 1 | - |
-| [req-mf-012](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-012) | MUST | 4.3.6 | consumer | active | 1 | - |
-| [req-mf-013](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-013) | MUST | 4.5 | consumer | active | 1 | - |
-| [req-mf-014](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-014) | MUST | 4.2.3 | producer | active | 1 | - |
-| [req-mf-015](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-015) | MUST | 4.2.3 | producer | active | 1 | - |
-| [req-mf-016](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-016) | MUST | 4.3.5 | consumer | skipped | 1 | - |
-| [req-mf-017](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-017) | MUST | 4.7 | producer | active | 1 | - |
-| [req-mf-018](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-018) | MUST | 4.6.1 | consumer | active | 1 | - |
-| [req-mf-019](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-019) | MUST | 4.2.4 | consumer | active | 1 | - |
-| [req-mf-020](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-020) | MUST | 4.1 | consumer | active | 1 | - |
-| [req-mf-021](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-021) | MUST | 4.8 | producer | active | 1 | - |
-| [req-mf-022](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-022) | MUST | 4.3.2 | consumer | active | 2 | - |
-| [req-mf-023](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-023) | MUST | 4.5 | consumer | active | 1 | - |
-| [req-mf-024](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-024) | MUST | 4.3.2 | consumer | active | 1 | - |
-| [req-pl-001](docs/src/content/docs/specs/openapm-v0.1.md#req-pl-001) | MUST | 6.1 | governance | active | 1 | - |
-| [req-pl-002](docs/src/content/docs/specs/openapm-v0.1.md#req-pl-002) | MUST | 6.2 | governance | active | 1 | - |
-| [req-pl-003](docs/src/content/docs/specs/openapm-v0.1.md#req-pl-003) | MUST | 6.4 | governance | active | 1 | - |
-| [req-pl-004](docs/src/content/docs/specs/openapm-v0.1.md#req-pl-004) | MUST | 6.4 | governance | active | 1 | - |
-| [req-pl-005](docs/src/content/docs/specs/openapm-v0.1.md#req-pl-005) | MUST | 6.5 | governance | active | 1 | - |
-| [req-pl-006](docs/src/content/docs/specs/openapm-v0.1.md#req-pl-006) | MUST | 6.4 | governance | active | 1 | - |
-| [req-pl-007](docs/src/content/docs/specs/openapm-v0.1.md#req-pl-007) | MUST | 6.3.1 | governance | active | 1 | - |
-| [req-pl-008](docs/src/content/docs/specs/openapm-v0.1.md#req-pl-008) | MUST | 6.3.1 | governance | active | 1 | - |
-| [req-pl-009](docs/src/content/docs/specs/openapm-v0.1.md#req-pl-009) | MUST | 6.6 | governance | active | 1 | - |
-| [req-pl-010](docs/src/content/docs/specs/openapm-v0.1.md#req-pl-010) | MUST | 6.2 | governance | active | 1 | - |
-| [req-pl-011](docs/src/content/docs/specs/openapm-v0.1.md#req-pl-011) | MUST | 6.1.1 | governance | active | 2 | - |
-| [req-pl-012](docs/src/content/docs/specs/openapm-v0.1.md#req-pl-012) | MUST | 6.1.1 | governance | active | 1 | - |
-| [req-pl-013](docs/src/content/docs/specs/openapm-v0.1.md#req-pl-013) | MUST | 6.8 | governance | active | 1 | - |
-| [req-pl-014](docs/src/content/docs/specs/openapm-v0.1.md#req-pl-014) | MUST | 6.8 | governance | active | 1 | - |
-| [req-pl-015](docs/src/content/docs/specs/openapm-v0.1.md#req-pl-015) | MUST | 6.3.5 | governance | active | 1 | - |
-| [req-pl-016](docs/src/content/docs/specs/openapm-v0.1.md#req-pl-016) | MUST | 6.8 | governance | active | 1 | - |
-| [req-pl-017](docs/src/content/docs/specs/openapm-v0.1.md#req-pl-017) | MUST | 6.8 | governance | active | 1 | - |
-| [req-pl-018](docs/src/content/docs/specs/openapm-v0.1.md#req-pl-018) | MUST | 6.3.1 | governance | active | 1 | - |
-| [req-pr-001](docs/src/content/docs/specs/openapm-v0.1.md#req-pr-001) | MUST | 8.2 | consumer | active | 1 | - |
-| [req-pr-002](docs/src/content/docs/specs/openapm-v0.1.md#req-pr-002) | MUST | 8.3 | consumer | active | 1 | - |
-| [req-pr-003](docs/src/content/docs/specs/openapm-v0.1.md#req-pr-003) | MUST | 8.3 | consumer | active | 1 | - |
-| [req-pr-004](docs/src/content/docs/specs/openapm-v0.1.md#req-pr-004) | MUST | 7.8 | producer | active | 10 | - |
-| [req-pr-005](docs/src/content/docs/specs/openapm-v0.1.md#req-pr-005) | SHOULD | 7.8 | producer | active | 1 | - |
-| [req-pr-006](docs/src/content/docs/specs/openapm-v0.1.md#req-pr-006) | MUST | 8.1 | consumer | active | 1 | - |
-| [req-pr-007](docs/src/content/docs/specs/openapm-v0.1.md#req-pr-007) | MUST | 8.1 | consumer | active | 1 | - |
-| [req-rg-001](docs/src/content/docs/specs/openapm-v0.1.md#req-rg-001) | MUST | 11.3.3 | registry | active | 1 | - |
-| [req-rs-001](docs/src/content/docs/specs/openapm-v0.1.md#req-rs-001) | MUST | 7.2 | consumer | active | 1 | - |
-| [req-rs-002](docs/src/content/docs/specs/openapm-v0.1.md#req-rs-002) | MUST | 7.3 | consumer | active | 1 | - |
-| [req-rs-003](docs/src/content/docs/specs/openapm-v0.1.md#req-rs-003) | MUST | 7.3 | consumer | active | 1 | - |
-| [req-rs-004](docs/src/content/docs/specs/openapm-v0.1.md#req-rs-004) | MUST | 7.5 | consumer | active | 1 | - |
-| [req-rs-005](docs/src/content/docs/specs/openapm-v0.1.md#req-rs-005) | MUST | 7.6 | consumer | active | 1 | - |
-| [req-rs-006](docs/src/content/docs/specs/openapm-v0.1.md#req-rs-006) | MUST | 7.2 | consumer | active | 1 | - |
-| [req-rs-007](docs/src/content/docs/specs/openapm-v0.1.md#req-rs-007) | MUST | 7.3 | consumer | active | 1 | - |
-| [req-rs-008](docs/src/content/docs/specs/openapm-v0.1.md#req-rs-008) | MUST | 7.1 | consumer | active | 7 | - |
-| [req-rs-009](docs/src/content/docs/specs/openapm-v0.1.md#req-rs-009) | MUST | 7.5.1 | consumer | active | 1 | - |
-| [req-rs-010](docs/src/content/docs/specs/openapm-v0.1.md#req-rs-010) | MUST | 7.2 | consumer | active | 1 | - |
-| [req-rs-011](docs/src/content/docs/specs/openapm-v0.1.md#req-rs-011) | MUST | 7.7 | consumer | active | 4 | - |
-| [req-rs-012](docs/src/content/docs/specs/openapm-v0.1.md#req-rs-012) | MUST | 7.7 | consumer | active | 1 | - |
-| [req-rs-013](docs/src/content/docs/specs/openapm-v0.1.md#req-rs-013) | MUST | 7.2 | consumer | active | 1 | - |
-| [req-rs-014](docs/src/content/docs/specs/openapm-v0.1.md#req-rs-014) | MUST | 7.3.1 | consumer | active | 1 | - |
-| [req-rs-015](docs/src/content/docs/specs/openapm-v0.1.md#req-rs-015) | MUST | 7.5 | consumer | active | 1 | - |
-| [req-rs-016](docs/src/content/docs/specs/openapm-v0.1.md#req-rs-016) | MUST | 7.2 | consumer | active | 7 | - |
-| [req-rs-017](docs/src/content/docs/specs/openapm-v0.1.md#req-rs-017) | MUST | 7.7 | consumer | active | 15 | - |
-| [req-sc-001](docs/src/content/docs/specs/openapm-v0.1.md#req-sc-001) | MUST | 10.4 | consumer | active | 2 | - |
-| [req-sc-002](docs/src/content/docs/specs/openapm-v0.1.md#req-sc-002) | MUST | 10.9 | consumer | active | 1 | - |
-| [req-sc-003](docs/src/content/docs/specs/openapm-v0.1.md#req-sc-003) | MUST | 10.3 | consumer | active | 1 | - |
-| [req-sc-004](docs/src/content/docs/specs/openapm-v0.1.md#req-sc-004) | MUST | 10.5 | consumer | active | 1 | - |
-| [req-sc-005](docs/src/content/docs/specs/openapm-v0.1.md#req-sc-005) | MUST | 10.3 | consumer | active | 1 | - |
-| [req-sc-006](docs/src/content/docs/specs/openapm-v0.1.md#req-sc-006) | MUST | 4.2.3 | consumer | active | 1 | - |
-| [req-sc-007](docs/src/content/docs/specs/openapm-v0.1.md#req-sc-007) | MUST | 10.3 | consumer | active | 1 | - |
-| [req-sc-008](docs/src/content/docs/specs/openapm-v0.1.md#req-sc-008) | SHOULD | 10.3 | consumer | active | 1 | - |
-| [req-sc-009](docs/src/content/docs/specs/openapm-v0.1.md#req-sc-009) | MUST | 10.13 | consumer | active | 1 | - |
-| [req-sc-010](docs/src/content/docs/specs/openapm-v0.1.md#req-sc-010) | MUST | 10.13 | consumer | active | 1 | - |
-| [req-sc-011](docs/src/content/docs/specs/openapm-v0.1.md#req-sc-011) | MUST | 10.14 | consumer | active | 1 | - |
-| [req-sc-012](docs/src/content/docs/specs/openapm-v0.1.md#req-sc-012) | MUST | 10.14 | consumer | active | 1 | - |
-| [req-sc-013](docs/src/content/docs/specs/openapm-v0.1.md#req-sc-013) | MUST | 10.3 | consumer | active | 1 | - |
-| [req-sc-014](docs/src/content/docs/specs/openapm-v0.1.md#req-sc-014) | MUST | 10.15 | consumer | active | 1 | - |
-| [req-sc-015](docs/src/content/docs/specs/openapm-v0.1.md#req-sc-015) | MUST | 10.16 | consumer | active | 3 | tests/fixtures/spec-conformance/source-plan/req-sc-015.json |
-| [req-tg-001](docs/src/content/docs/specs/openapm-v0.1.md#req-tg-001) | MUST | 8.4 | consumer | active | 1 | - |
-| [req-tg-002](docs/src/content/docs/specs/openapm-v0.1.md#req-tg-002) | MUST | 8.5 | consumer | active | 1 | - |
-| [req-tg-003](docs/src/content/docs/specs/openapm-v0.1.md#req-tg-003) | MUST | 8.5 | consumer | active | 1 | - |
-| [req-tg-004](docs/src/content/docs/specs/openapm-v0.1.md#req-tg-004) | MUST | 4.2.1 | consumer | active | 1 | - |
-| [req-tg-005](docs/src/content/docs/specs/openapm-v0.1.md#req-tg-005) | MUST | 8.5 | consumer | active | 1 | - |
-| [req-tg-006](docs/src/content/docs/specs/openapm-v0.1.md#req-tg-006) | MUST | 8.5 | consumer | active | 1 | - |
-| [req-tg-007](docs/src/content/docs/specs/openapm-v0.1.md#req-tg-007) | MUST | 8.5 | consumer | active | 1 | - |
-| [req-tg-008](docs/src/content/docs/specs/openapm-v0.1.md#req-tg-008) | MUST | 8.5.3 | consumer | active | 1 | - |
-| [req-tg-009](docs/src/content/docs/specs/openapm-v0.1.md#req-tg-009) | MUST | 8.5.1 | consumer | active | 1 | - |
-| [req-tg-010](docs/src/content/docs/specs/openapm-v0.1.md#req-tg-010) | MUST | 8.5.4 | consumer | active | 1 | - |
-| [req-tg-011](docs/src/content/docs/specs/openapm-v0.1.md#req-tg-011) | MUST | 8.5.5 | consumer | active | 2 | - |
-| [req-tg-012](docs/src/content/docs/specs/openapm-v0.1.md#req-tg-012) | MUST | 8.5.6 | consumer | active | 1 | - |
-| [req-tg-013](docs/src/content/docs/specs/openapm-v0.1.md#req-tg-013) | MUST | 8.5.7 | consumer | active | 7 | - |
-| [req-tg-014](docs/src/content/docs/specs/openapm-v0.1.md#req-tg-014) | MUST | 8.5.8 | consumer | active | 1 | - |
+| [req-cf-001](docs/src/content/docs/specs/openapm-v0.2.md#req-cf-001) | MUST | 12.5 | consumer | active | 6 | - |
+| [req-cf-002](docs/src/content/docs/specs/openapm-v0.2.md#req-cf-002) | MUST | 12.3 | consumer | active | 1 | - |
+| [req-ext-001](docs/src/content/docs/specs/openapm-v0.2.md#req-ext-001) | MUST | 4.1 | consumer | active | 1 | - |
+| [req-ext-002](docs/src/content/docs/specs/openapm-v0.2.md#req-ext-002) | MUST | 4.1 | producer | active | 1 | - |
+| [req-lk-001](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-001) | MUST | 5.1 | consumer | active | 1 | - |
+| [req-lk-002](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-002) | MUST | 5.4 | consumer | active | 1 | - |
+| [req-lk-003](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-003) | MUST | 5.2 | consumer | active | 2 | - |
+| [req-lk-004](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-004) | MUST | 5.4 | consumer | active | 1 | - |
+| [req-lk-005](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-005) | MUST | 5.5 | consumer | active | 2 | - |
+| [req-lk-006](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-006) | MUST | 5.5 | consumer | active | 1 | - |
+| [req-lk-007](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-007) | SHOULD | 5.5 | consumer | active | 1 | - |
+| [req-lk-008](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-008) | MUST | 5.6 | consumer | active | 1 | - |
+| [req-lk-009](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-009) | MUST | 5.6 | consumer | active | 1 | - |
+| [req-lk-010](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-010) | MUST | 5.6 | consumer | active | 1 | - |
+| [req-lk-011](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-011) | MUST | 5.2 | consumer | active | 1 | - |
+| [req-lk-012](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-012) | MUST | 5.2 | consumer | active | 1 | - |
+| [req-lk-013](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-013) | MUST | 5.2 | consumer | active | 2 | - |
+| [req-lk-014](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-014) | MUST | 5.2 | consumer | active | 1 | - |
+| [req-lk-015](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-015) | MUST | 5.6.4 | consumer | active | 1 | - |
+| [req-lk-016](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-016) | MUST | 5.2 | consumer | active | 1 | - |
+| [req-lk-017](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-017) | MUST | 5.2 | consumer | active | 1 | - |
+| [req-lk-018](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-018) | SHOULD | 5.5 | consumer | active | 1 | - |
+| [req-lk-019](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-019) | MUST | 5.2 | consumer | active | 1 | - |
+| [req-lk-020](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-020) | MUST | 5.2 | consumer | active | 3 | - |
+| [req-lk-021](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-021) | MUST | 5.2 | consumer | active | 2 | - |
+| [req-lk-022](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-022) | MUST | 5.2 | consumer | active | 4 | - |
+| [req-lk-023](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-023) | MUST | 5.5 | consumer | active | 34 | - |
+| [req-mf-001](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-001) | MUST | 4.1 | producer | active | 1 | - |
+| [req-mf-002](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-002) | MUST | 4.1 | producer | active | 1 | - |
+| [req-mf-003](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-003) | MUST | 4.1 | producer | active | 1 | - |
+| [req-mf-004](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-004) | SHOULD | 4.1 | producer | active | 1 | - |
+| [req-mf-005](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-005) | MUST | 4.2.1 | producer | active | 1 | - |
+| [req-mf-006](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-006) | MUST | 4.1 | consumer | active | 1 | - |
+| [req-mf-007](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-007) | MUST | 4.3.1 | consumer | active | 1 | - |
+| [req-mf-008](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-008) | MUST | 4.3.3 | consumer | active | 1 | - |
+| [req-mf-009](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-009) | MUST | 4.3.4 | consumer | active | 1 | - |
+| [req-mf-010](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-010) | MUST | 4.3.2 | consumer | active | 1 | - |
+| [req-mf-011](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-011) | MUST | 4.3.2 | consumer | active | 1 | - |
+| [req-mf-012](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-012) | MUST | 4.3.6 | consumer | active | 1 | - |
+| [req-mf-013](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-013) | MUST | 4.5 | consumer | active | 1 | - |
+| [req-mf-014](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-014) | MUST | 4.2.3 | producer | active | 1 | - |
+| [req-mf-015](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-015) | MUST | 4.2.3 | producer | active | 1 | - |
+| [req-mf-016](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-016) | MUST | 4.3.5 | consumer | active | 47 | - |
+| [req-mf-017](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-017) | MUST | 4.7 | producer | active | 1 | - |
+| [req-mf-018](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-018) | MUST | 4.6.1 | consumer | active | 3 | - |
+| [req-mf-019](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-019) | MUST | 4.2.4 | consumer | active | 1 | - |
+| [req-mf-020](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-020) | MUST | 4.1 | consumer | active | 1 | - |
+| [req-mf-021](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-021) | MUST | 4.8 | producer | active | 1 | - |
+| [req-mf-022](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-022) | MUST | 4.3.2 | consumer | active | 2 | - |
+| [req-mf-023](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-023) | MUST | 4.5 | consumer | active | 1 | - |
+| [req-mf-024](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-024) | MUST | 4.3.2 | consumer | active | 1 | - |
+| [req-pl-001](docs/src/content/docs/specs/openapm-v0.2.md#req-pl-001) | MUST | 6.1 | governance | active | 1 | - |
+| [req-pl-002](docs/src/content/docs/specs/openapm-v0.2.md#req-pl-002) | MUST | 6.2 | governance | active | 1 | - |
+| [req-pl-003](docs/src/content/docs/specs/openapm-v0.2.md#req-pl-003) | MUST | 6.4 | governance | active | 1 | - |
+| [req-pl-004](docs/src/content/docs/specs/openapm-v0.2.md#req-pl-004) | MUST | 6.4 | governance | active | 1 | - |
+| [req-pl-005](docs/src/content/docs/specs/openapm-v0.2.md#req-pl-005) | MUST | 6.5 | governance | active | 1 | - |
+| [req-pl-006](docs/src/content/docs/specs/openapm-v0.2.md#req-pl-006) | MUST | 6.4 | governance | active | 1 | - |
+| [req-pl-007](docs/src/content/docs/specs/openapm-v0.2.md#req-pl-007) | MUST | 6.3.1 | governance | active | 1 | - |
+| [req-pl-008](docs/src/content/docs/specs/openapm-v0.2.md#req-pl-008) | MUST | 6.3.1 | governance | active | 1 | - |
+| [req-pl-009](docs/src/content/docs/specs/openapm-v0.2.md#req-pl-009) | MUST | 6.6 | governance | active | 1 | - |
+| [req-pl-010](docs/src/content/docs/specs/openapm-v0.2.md#req-pl-010) | MUST | 6.2 | governance | active | 1 | - |
+| [req-pl-011](docs/src/content/docs/specs/openapm-v0.2.md#req-pl-011) | MUST | 6.1.1 | governance | active | 2 | - |
+| [req-pl-012](docs/src/content/docs/specs/openapm-v0.2.md#req-pl-012) | MUST | 6.1.1 | governance | active | 1 | - |
+| [req-pl-013](docs/src/content/docs/specs/openapm-v0.2.md#req-pl-013) | MUST | 6.8 | governance | active | 1 | - |
+| [req-pl-014](docs/src/content/docs/specs/openapm-v0.2.md#req-pl-014) | MUST | 6.8 | governance | active | 1 | - |
+| [req-pl-015](docs/src/content/docs/specs/openapm-v0.2.md#req-pl-015) | MUST | 6.3.5 | governance | active | 1 | - |
+| [req-pl-016](docs/src/content/docs/specs/openapm-v0.2.md#req-pl-016) | MUST | 6.8 | governance | active | 2 | - |
+| [req-pl-017](docs/src/content/docs/specs/openapm-v0.2.md#req-pl-017) | MUST | 6.8 | governance | active | 1 | - |
+| [req-pl-018](docs/src/content/docs/specs/openapm-v0.2.md#req-pl-018) | MUST | 6.3.1 | governance | active | 1 | - |
+| [req-pr-001](docs/src/content/docs/specs/openapm-v0.2.md#req-pr-001) | MUST | 8.2 | consumer | active | 1 | - |
+| [req-pr-002](docs/src/content/docs/specs/openapm-v0.2.md#req-pr-002) | MUST | 8.3 | consumer | active | 1 | - |
+| [req-pr-003](docs/src/content/docs/specs/openapm-v0.2.md#req-pr-003) | MUST | 8.3 | consumer | active | 1 | - |
+| [req-pr-004](docs/src/content/docs/specs/openapm-v0.2.md#req-pr-004) | MUST | 7.8 | producer | active | 10 | - |
+| [req-pr-005](docs/src/content/docs/specs/openapm-v0.2.md#req-pr-005) | SHOULD | 7.8 | producer | active | 1 | - |
+| [req-pr-006](docs/src/content/docs/specs/openapm-v0.2.md#req-pr-006) | MUST | 8.1 | consumer | active | 1 | - |
+| [req-pr-007](docs/src/content/docs/specs/openapm-v0.2.md#req-pr-007) | MUST | 8.1 | consumer | active | 1 | - |
+| [req-rg-001](docs/src/content/docs/specs/openapm-v0.2.md#req-rg-001) | MUST | 11.3.3 | registry | active | 1 | - |
+| [req-rs-001](docs/src/content/docs/specs/openapm-v0.2.md#req-rs-001) | MUST | 7.2 | consumer | active | 1 | - |
+| [req-rs-002](docs/src/content/docs/specs/openapm-v0.2.md#req-rs-002) | MUST | 7.3 | consumer | active | 1 | - |
+| [req-rs-003](docs/src/content/docs/specs/openapm-v0.2.md#req-rs-003) | MUST | 7.3 | consumer | active | 1 | - |
+| [req-rs-004](docs/src/content/docs/specs/openapm-v0.2.md#req-rs-004) | MUST | 7.5 | consumer | active | 1 | - |
+| [req-rs-005](docs/src/content/docs/specs/openapm-v0.2.md#req-rs-005) | MUST | 7.6 | consumer | active | 1 | - |
+| [req-rs-006](docs/src/content/docs/specs/openapm-v0.2.md#req-rs-006) | MUST | 7.2 | consumer | active | 1 | - |
+| [req-rs-007](docs/src/content/docs/specs/openapm-v0.2.md#req-rs-007) | MUST | 7.3 | consumer | active | 1 | - |
+| [req-rs-008](docs/src/content/docs/specs/openapm-v0.2.md#req-rs-008) | MUST | 7.1 | consumer | active | 7 | - |
+| [req-rs-009](docs/src/content/docs/specs/openapm-v0.2.md#req-rs-009) | MUST | 7.5.1 | consumer | active | 1 | - |
+| [req-rs-010](docs/src/content/docs/specs/openapm-v0.2.md#req-rs-010) | MUST | 7.2 | consumer | active | 1 | - |
+| [req-rs-011](docs/src/content/docs/specs/openapm-v0.2.md#req-rs-011) | MUST | 7.7 | consumer | active | 4 | - |
+| [req-rs-012](docs/src/content/docs/specs/openapm-v0.2.md#req-rs-012) | MUST | 7.7 | consumer | active | 1 | - |
+| [req-rs-013](docs/src/content/docs/specs/openapm-v0.2.md#req-rs-013) | MUST | 7.2 | consumer | active | 1 | - |
+| [req-rs-014](docs/src/content/docs/specs/openapm-v0.2.md#req-rs-014) | MUST | 7.3.1 | consumer | active | 1 | - |
+| [req-rs-015](docs/src/content/docs/specs/openapm-v0.2.md#req-rs-015) | MUST | 7.5 | consumer | active | 1 | - |
+| [req-rs-016](docs/src/content/docs/specs/openapm-v0.2.md#req-rs-016) | MUST | 7.2 | consumer | active | 7 | - |
+| [req-rs-017](docs/src/content/docs/specs/openapm-v0.2.md#req-rs-017) | MUST | 7.7 | consumer | active | 15 | - |
+| [req-sc-001](docs/src/content/docs/specs/openapm-v0.2.md#req-sc-001) | MUST | 10.4 | consumer | active | 2 | - |
+| [req-sc-002](docs/src/content/docs/specs/openapm-v0.2.md#req-sc-002) | MUST | 10.9 | consumer | active | 1 | - |
+| [req-sc-003](docs/src/content/docs/specs/openapm-v0.2.md#req-sc-003) | MUST | 10.3 | consumer | active | 1 | - |
+| [req-sc-004](docs/src/content/docs/specs/openapm-v0.2.md#req-sc-004) | MUST | 10.5 | consumer | active | 1 | - |
+| [req-sc-005](docs/src/content/docs/specs/openapm-v0.2.md#req-sc-005) | MUST | 10.3 | consumer | active | 1 | - |
+| [req-sc-006](docs/src/content/docs/specs/openapm-v0.2.md#req-sc-006) | MUST | 4.2.3 | consumer | active | 1 | - |
+| [req-sc-007](docs/src/content/docs/specs/openapm-v0.2.md#req-sc-007) | MUST | 10.3 | consumer | active | 1 | - |
+| [req-sc-008](docs/src/content/docs/specs/openapm-v0.2.md#req-sc-008) | SHOULD | 10.3 | consumer | active | 1 | - |
+| [req-sc-009](docs/src/content/docs/specs/openapm-v0.2.md#req-sc-009) | MUST | 10.13 | consumer | active | 1 | - |
+| [req-sc-010](docs/src/content/docs/specs/openapm-v0.2.md#req-sc-010) | MUST | 10.13 | consumer | active | 1 | - |
+| [req-sc-011](docs/src/content/docs/specs/openapm-v0.2.md#req-sc-011) | MUST | 10.14 | consumer | active | 1 | - |
+| [req-sc-012](docs/src/content/docs/specs/openapm-v0.2.md#req-sc-012) | MUST | 10.14 | consumer | active | 1 | - |
+| [req-sc-013](docs/src/content/docs/specs/openapm-v0.2.md#req-sc-013) | MUST | 10.3 | consumer | active | 1 | - |
+| [req-sc-014](docs/src/content/docs/specs/openapm-v0.2.md#req-sc-014) | MUST | 10.15 | consumer | active | 1 | - |
+| [req-sc-015](docs/src/content/docs/specs/openapm-v0.2.md#req-sc-015) | MUST | 10.16 | consumer | active | 3 | tests/fixtures/spec-conformance/source-plan/req-sc-015.json |
+| [req-tg-001](docs/src/content/docs/specs/openapm-v0.2.md#req-tg-001) | MUST | 8.4 | consumer | active | 1 | - |
+| [req-tg-002](docs/src/content/docs/specs/openapm-v0.2.md#req-tg-002) | MUST | 8.5 | consumer | active | 1 | - |
+| [req-tg-003](docs/src/content/docs/specs/openapm-v0.2.md#req-tg-003) | MUST | 8.5 | consumer | active | 1 | - |
+| [req-tg-004](docs/src/content/docs/specs/openapm-v0.2.md#req-tg-004) | MUST | 4.2.1 | consumer | active | 1 | - |
+| [req-tg-005](docs/src/content/docs/specs/openapm-v0.2.md#req-tg-005) | MUST | 8.5 | consumer | active | 1 | - |
+| [req-tg-006](docs/src/content/docs/specs/openapm-v0.2.md#req-tg-006) | MUST | 8.5 | consumer | active | 1 | - |
+| [req-tg-007](docs/src/content/docs/specs/openapm-v0.2.md#req-tg-007) | MUST | 8.5 | consumer | active | 1 | - |
+| [req-tg-008](docs/src/content/docs/specs/openapm-v0.2.md#req-tg-008) | MUST | 8.5.3 | consumer | active | 1 | - |
+| [req-tg-009](docs/src/content/docs/specs/openapm-v0.2.md#req-tg-009) | MUST | 8.5.1 | consumer | active | 1 | - |
+| [req-tg-010](docs/src/content/docs/specs/openapm-v0.2.md#req-tg-010) | MUST | 8.5.4 | consumer | active | 1 | - |
+| [req-tg-011](docs/src/content/docs/specs/openapm-v0.2.md#req-tg-011) | MUST | 8.5.5 | consumer | active | 2 | - |
+| [req-tg-012](docs/src/content/docs/specs/openapm-v0.2.md#req-tg-012) | MUST | 8.5.6 | consumer | active | 1 | - |
+| [req-tg-013](docs/src/content/docs/specs/openapm-v0.2.md#req-tg-013) | MUST | 8.5.7 | consumer | active | 7 | - |
+| [req-tg-014](docs/src/content/docs/specs/openapm-v0.2.md#req-tg-014) | MUST | 8.5.8 | consumer | active | 1 | - |
## Waivers
@@ -166,8 +186,5 @@ Repository-coordinate segments are case-insensitive for `github.com`, GitHub Ent
- CONFORMANCE.{md,json} not yet generated in this checkout. Run `uv run python -m tests.spec_conformance.gen_statement`.
### req-lk-018
-- Publish-timestamp recording is a publisher-side SHOULD that requires registry interaction to exercise end-to-end. The schema affordance (generated_at) is asserted above; full publisher coverage requires the registry wire conformance module which is not in v0.1 scope.
-
-### req-mf-016
-- Path-shape negative test requires apm_cli's path-policy loader to be invokable from the test harness; the JSON Schema currently models `path` as a free-form string. Tracked as a follow-up: tighten the schema to forbid leading `/` and document the absolute-path rejection in the schema additionalProperties.
+- Publish-timestamp recording is a publisher-side SHOULD that requires registry interaction to exercise end-to-end. The schema affordance (generated_at) is asserted above; full publisher coverage requires the registry wire conformance module which remains outside this revision's scope.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 21d4f63556..19cd71fa1c 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -423,9 +423,10 @@ Avoid these anti-patterns:
- Do not read `is_enabled()` at module import time.
- Do not persist flag state anywhere other than `~/.apm/config.json` via `update_config`.
-## Adding or changing a normative requirement (OpenAPM v0.1)
+## Adding or changing a normative requirement (OpenAPM)
-The OpenAPM v0.1 spec (`docs/src/content/docs/specs/openapm-v0.1.md`)
+The selected OpenAPM spec (`tests/spec_conformance/_manifest.py` owns
+the assessment artifact and exact revision)
and APM the implementation are co-evolved in this repo. APM is the
sole implementation of the spec. To prevent the spec from rotting
into a document of lies, every normative change MUST land as three
@@ -438,8 +439,8 @@ Three-step ritual:
1. **Spec edit.** Add or change a `` anchor with
prose in the spec body. Add or change the matching Appendix C row.
2. **Manifest edit.** Add or change the entry in
- `docs/src/content/docs/specs/manifests/openapm-v0.1.requirements.yml`
- so it stays a byte-equivalent projection of the canonical anchors.
+ the selected informative manifest under `docs/public/specs/manifests/`
+ so its IDs, keywords, sections, and classes match the canonical anchors.
3. **Test edit.** Add or extend a `@pytest.mark.req("req-XXX")` test
under `tests/spec_conformance/`. If a real assertion is not yet
possible, call `waive("...")` from `_helpers.py` with a one-line
@@ -453,7 +454,12 @@ uv run --extra dev python -m tests.spec_conformance.gen_statement
```
and commit the resulting `CONFORMANCE.{md,json}` at repo root. CI
-gates a clean diff.
+gates a clean diff. Generation collects the full selected suite afresh and
+records static bindings, not runtime pass results. Run the suite separately
+and retain execution evidence. Normative reconciliation is complete for the
+current corrective-draft candidate; preserve the previous minor.
+[Qualified-human review, ratification, and activation remain unsatisfied](docs/src/content/docs/specs/conformance.md).
+Assessment selection and green CI do not satisfy those gates.
Common modes the ritual catches:
diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs
index c9554c293d..8e7efcc77e 100644
--- a/docs/astro.config.mjs
+++ b/docs/astro.config.mjs
@@ -73,6 +73,10 @@ export default defineConfig({
'/spec': '/apm/specs/openapm-v01/',
'/spec/latest': '/apm/specs/openapm-v01/',
'/spec/v0.1': '/apm/specs/openapm-v01/',
+ // Exact revision slug is pinned; a later patch needs a distinct artifact.
+ // Keep /spec and /spec/latest unchanged until actual ratification.
+ '/spec/v0.2': '/apm/specs/openapm-v020/',
+ '/spec/v0.2.0': '/apm/specs/openapm-v020/',
},
integrations: [
sitemap(),
@@ -295,6 +299,7 @@ export default defineConfig({
// (/spec, /spec/v0.1, /spec/latest) bridge this in the
// redirects block above for external citers.
{ label: 'OpenAPM v0.1', slug: 'specs/openapm-v01' },
+ { label: 'OpenAPM v0.2.0 (draft)', slug: 'specs/openapm-v020' },
{ label: 'Conformance', slug: 'specs/conformance' },
],
},
diff --git a/docs/public/specs/manifests/openapm-v0.2.requirements.yml b/docs/public/specs/manifests/openapm-v0.2.requirements.yml
new file mode 100644
index 0000000000..69844cd7be
--- /dev/null
+++ b/docs/public/specs/manifests/openapm-v0.2.requirements.yml
@@ -0,0 +1,544 @@
+# OpenAPM v0.2.0 -- informative requirements manifest (draft).
+#
+# Reconciled from the exact current-main previous-minor inventory and
+# the corrective draft, then maintained with every spec edit. This file
+# is an ID/keyword/section/class projection of the HTML anchors in the spec
+# body (the canonical source); Appendix C is a reader-aid derived
+# from the same anchors.
+#
+# Do NOT edit this file alone. Before selecting this draft for an
+# executable assessment, the complete binding ritual is:
+# 1. Add the anchor + prose to openapm-v0.2.md
+# and the corresponding Appendix C row.
+# 2. Add the entry below.
+# 3. Add or extend a test under tests/spec_conformance/ with
+# @pytest.mark.req("req-XXX") (and optionally cite a fixture
+# path).
+# The combined local-source and audit successor owns step 3 and the complete
+# executable assessment. This foundation is inactive; it does not switch the
+# active v0.1 selector or claim that draft bindings or runtime checks pass.
+#
+# Informative candidate CI input; Section 12.6's normative wire-format reservation
+# remains in place. requirements-v0.1.schema.json describes this unchanged
+# format, not the assessed spec version. Publication and ratification are pending.
+
+requirements_format_version: "1"
+spec_version: "v0.2.0"
+requirements:
+ - id: req-mf-001
+ keyword: MUST
+ section: "4.1"
+ conformance_class: producer
+ - id: req-mf-002
+ keyword: MUST
+ section: "4.1"
+ conformance_class: producer
+ - id: req-mf-003
+ keyword: MUST
+ section: "4.1"
+ conformance_class: producer
+ - id: req-mf-004
+ keyword: SHOULD
+ section: "4.1"
+ conformance_class: producer
+ - id: req-mf-005
+ keyword: MUST
+ section: "4.2.1"
+ conformance_class: producer
+ - id: req-mf-006
+ keyword: MUST
+ section: "4.1"
+ conformance_class: consumer
+ - id: req-mf-007
+ keyword: MUST
+ section: "4.3.1"
+ conformance_class: consumer
+ - id: req-mf-008
+ keyword: MUST
+ section: "4.3.3"
+ conformance_class: consumer
+ - id: req-mf-009
+ keyword: MUST
+ section: "4.3.4"
+ conformance_class: consumer
+ - id: req-mf-010
+ keyword: MUST
+ section: "4.3.2"
+ conformance_class: consumer
+ - id: req-mf-011
+ keyword: MUST
+ section: "4.3.2"
+ conformance_class: consumer
+ - id: req-mf-012
+ keyword: MUST
+ section: "4.3.6"
+ conformance_class: consumer
+ - id: req-mf-013
+ keyword: MUST
+ section: "4.5"
+ conformance_class: consumer
+ - id: req-mf-014
+ keyword: MUST
+ section: "4.2.3"
+ conformance_class: producer
+ - id: req-mf-015
+ keyword: MUST
+ section: "4.2.3"
+ conformance_class: producer
+ - id: req-mf-016
+ keyword: MUST
+ section: "4.3.5"
+ conformance_class: consumer
+ notes: "Clauses (a)-(d) govern source anchoring, user-scope admission, remote-repository containment, and local-content symlinks. Remote _local/ coordinates do not establish local provenance. The combined successor owns executable bindings against v0.2.0, not the previous minor's blanket project-root refusal; this inactive foundation claims no completed binding."
+ - id: req-mf-017
+ keyword: MUST
+ section: "4.7"
+ conformance_class: producer
+ - id: req-mf-018
+ keyword: MUST
+ section: "4.6.1"
+ conformance_class: consumer
+ - id: req-mf-019
+ keyword: MUST
+ section: "4.2.4"
+ conformance_class: consumer
+ - id: req-mf-020
+ keyword: MUST
+ section: "4.1"
+ conformance_class: consumer
+ - id: req-mf-021
+ keyword: MUST
+ section: "4.8"
+ conformance_class: producer
+ - id: req-mf-022
+ keyword: MUST
+ section: "4.3.2"
+ conformance_class: consumer
+ - id: req-mf-023
+ keyword: MUST
+ section: "4.5"
+ conformance_class: consumer
+ - id: req-mf-024
+ keyword: MUST
+ section: "4.3.2"
+ conformance_class: consumer
+ - id: req-ext-001
+ keyword: MUST
+ section: "4.1"
+ conformance_class: consumer
+ - id: req-ext-002
+ keyword: MUST
+ section: "4.1"
+ conformance_class: producer
+ - id: req-lk-001
+ keyword: MUST
+ section: "5.1"
+ conformance_class: consumer
+ - id: req-lk-002
+ keyword: MUST
+ section: "5.4"
+ conformance_class: consumer
+ - id: req-lk-003
+ keyword: MUST
+ section: "5.2"
+ conformance_class: consumer
+ notes: "full-SHA manifest pins must match resolved_commit during conformance audit"
+ - id: req-lk-004
+ keyword: MUST
+ section: "5.4"
+ conformance_class: consumer
+ - id: req-lk-005
+ keyword: MUST
+ section: "5.5"
+ conformance_class: consumer
+ notes: "generated_at is optional advisory metadata; consumers omit it from new lockfiles by default and preserve an existing omission unless explicitly configured otherwise"
+ - id: req-lk-006
+ keyword: MUST
+ section: "5.5"
+ conformance_class: consumer
+ notes: "frozen validation covers direct package pins and MCP state before durable mutation"
+ - id: req-lk-007
+ keyword: SHOULD
+ section: "5.5"
+ conformance_class: consumer
+ - id: req-lk-008
+ keyword: MUST
+ section: "5.6"
+ conformance_class: consumer
+ - id: req-lk-009
+ keyword: MUST
+ section: "5.6"
+ conformance_class: consumer
+ - id: req-lk-010
+ keyword: MUST
+ section: "5.6"
+ conformance_class: consumer
+ - id: req-lk-011
+ keyword: MUST
+ section: "5.2"
+ conformance_class: consumer
+ - id: req-lk-012
+ keyword: MUST
+ section: "5.2"
+ conformance_class: consumer
+ - id: req-lk-013
+ keyword: MUST
+ section: "5.2"
+ conformance_class: consumer
+ - id: req-lk-014
+ keyword: MUST
+ section: "5.2"
+ conformance_class: consumer
+ - id: req-lk-015
+ keyword: MUST
+ section: "5.6.4"
+ conformance_class: consumer
+ - id: req-lk-016
+ keyword: MUST
+ section: "5.2"
+ conformance_class: consumer
+ - id: req-lk-017
+ keyword: MUST
+ section: "5.2"
+ conformance_class: consumer
+ - id: req-lk-018
+ keyword: SHOULD
+ section: "5.5"
+ conformance_class: consumer
+ - id: req-lk-019
+ keyword: MUST
+ section: "5.2"
+ conformance_class: consumer
+ notes: "name and dependency-apm.yml-derived version are self-asserted inventory metadata, never trust anchors"
+ - id: req-lk-020
+ keyword: MUST
+ section: "5.2"
+ conformance_class: consumer
+ notes: "target and identity reconciliation preserves legitimate, indeterminate, and freshly redeployed paths while dropping inactive-target ghosts"
+ - id: req-lk-021
+ keyword: MUST
+ section: "5.2"
+ conformance_class: consumer
+ notes: "extends req-lk-020's preserve/remove decision to merge-based hook configuration and its ownership record"
+ - id: req-lk-022
+ keyword: MUST
+ section: "5.2"
+ conformance_class: consumer
+ notes: "source-cased materialization_repo_url remains non-identity metadata and drives rollback-safe, collision-closed materialization/link spelling"
+ - id: req-lk-023
+ keyword: MUST
+ section: "5.5"
+ conformance_class: consumer
+ notes: "Current manifest then selected saved configuration then detection determines source-derived replay; prior claims widen comparison only. Live project/user configuration, deployment bytes, native databases and sidecars stay unchanged. Unsupported native replay fails before its writer. The combined successor owns complete replay bindings, including explicit-only selection, historical native ownership refusal, and internal-resource-link composition; invalid-owner attribution remains req-pl-016. This inactive foundation claims no completed binding or successful runtime assessment."
+ - id: req-pl-001
+ keyword: MUST
+ section: "6.1"
+ conformance_class: governance
+ - id: req-pl-002
+ keyword: MUST
+ section: "6.2"
+ conformance_class: governance
+ - id: req-pl-003
+ keyword: MUST
+ section: "6.4"
+ conformance_class: governance
+ - id: req-pl-004
+ keyword: MUST
+ section: "6.4"
+ conformance_class: governance
+ - id: req-pl-005
+ keyword: MUST
+ section: "6.5"
+ conformance_class: governance
+ - id: req-pl-006
+ keyword: MUST
+ section: "6.4"
+ conformance_class: governance
+ - id: req-pl-007
+ keyword: MUST
+ section: "6.3.1"
+ conformance_class: governance
+ - id: req-pl-008
+ keyword: MUST
+ section: "6.3.1"
+ conformance_class: governance
+ - id: req-pl-009
+ keyword: MUST
+ section: "6.6"
+ conformance_class: governance
+ - id: req-pl-010
+ keyword: MUST
+ section: "6.2"
+ conformance_class: governance
+ - id: req-pl-011
+ keyword: MUST
+ section: "6.1.1"
+ conformance_class: governance
+ - id: req-pl-012
+ keyword: MUST
+ section: "6.1.1"
+ conformance_class: governance
+ - id: req-pl-013
+ keyword: MUST
+ section: "6.8"
+ conformance_class: governance
+ - id: req-pl-014
+ keyword: MUST
+ section: "6.8"
+ conformance_class: governance
+ - id: req-pl-015
+ keyword: MUST
+ section: "6.3.5"
+ conformance_class: governance
+ - id: req-pl-016
+ keyword: MUST
+ section: "6.8"
+ conformance_class: governance
+ notes: "a canonical deployment-ledger owner absent from apm.lock.yaml is a hard integrity failure independent of security.audit.fail_on_drift; audit exits non-zero in both default and CI modes and refuses to mutate deployed bytes while ownership is invalid"
+ - id: req-pl-017
+ keyword: MUST
+ section: "6.8"
+ conformance_class: governance
+ notes: "Azure DevOps organization-policy discovery uses a distinct primary apm/apm-policy cache first and permits a distinct _apm/_apm fallback only after HTTP 404; non-404 failures do not fall back and legacy success warns"
+ - id: req-pl-018
+ keyword: MUST
+ section: "6.3.1"
+ conformance_class: governance
+ notes: "allow, deny, and exact require package operands use the documented per-host repository case rule of req-rs-016 plus the registry-coordinate case rule; ASCII normalization does not cross virtual paths, refs, registry names, MCP names, unmanaged paths, or case-sensitive host/source identities; recursive-glob ambiguity remains case-sensitive and deny precedence remains unchanged"
+ - id: req-rs-001
+ keyword: MUST
+ section: "7.2"
+ conformance_class: consumer
+ - id: req-rs-002
+ keyword: MUST
+ section: "7.3"
+ conformance_class: consumer
+ - id: req-rs-003
+ keyword: MUST
+ section: "7.3"
+ conformance_class: consumer
+ - id: req-rs-004
+ keyword: MUST
+ section: "7.5"
+ conformance_class: consumer
+ - id: req-rs-005
+ keyword: MUST
+ section: "7.6"
+ conformance_class: consumer
+ - id: req-rs-006
+ keyword: MUST
+ section: "7.2"
+ conformance_class: consumer
+ - id: req-rs-007
+ keyword: MUST
+ section: "7.3"
+ conformance_class: consumer
+ - id: req-rs-008
+ keyword: MUST
+ section: "7.1"
+ conformance_class: consumer
+ - id: req-rs-009
+ keyword: MUST
+ section: "7.5.1"
+ conformance_class: consumer
+ - id: req-rs-010
+ keyword: MUST
+ section: "7.2"
+ conformance_class: consumer
+ - id: req-rs-011
+ keyword: MUST
+ section: "7.7"
+ conformance_class: consumer
+ - id: req-rs-012
+ keyword: MUST
+ section: "7.7"
+ conformance_class: consumer
+ - id: req-rs-013
+ keyword: MUST
+ section: "7.2"
+ conformance_class: consumer
+ - id: req-rs-014
+ keyword: MUST
+ section: "7.3.1"
+ conformance_class: consumer
+ - id: req-rs-015
+ keyword: MUST
+ section: "7.5"
+ conformance_class: consumer
+ - id: req-rs-016
+ keyword: MUST
+ section: "7.2"
+ conformance_class: consumer
+ notes: "minimum repository identity binds every cache layer; distinct identities must not share source material"
+ - id: req-rs-017
+ keyword: MUST
+ section: "7.7"
+ conformance_class: consumer
+ notes: "full-SHA update extensions deterministically select the highest eligible non-prerelease annotated tag, retain pins with no eligible tag, persist git-literal provenance, and stop before writes on malformed or failed remote resolution"
+ - id: req-pr-001
+ keyword: MUST
+ section: "8.2"
+ conformance_class: consumer
+ - id: req-pr-002
+ keyword: MUST
+ section: "8.3"
+ conformance_class: consumer
+ - id: req-pr-003
+ keyword: MUST
+ section: "8.3"
+ conformance_class: consumer
+ - id: req-pr-004
+ keyword: MUST
+ section: "7.8"
+ conformance_class: producer
+ - id: req-pr-005
+ keyword: SHOULD
+ section: "7.8"
+ conformance_class: producer
+ - id: req-tg-001
+ keyword: MUST
+ section: "8.4"
+ conformance_class: consumer
+ - id: req-tg-002
+ keyword: MUST
+ section: "8.5"
+ conformance_class: consumer
+ - id: req-tg-003
+ keyword: MUST
+ section: "8.5"
+ conformance_class: consumer
+ - id: req-tg-004
+ keyword: MUST
+ section: "4.2.1"
+ conformance_class: consumer
+ - id: req-tg-005
+ keyword: MUST
+ section: "8.5"
+ conformance_class: consumer
+ - id: req-tg-006
+ keyword: MUST
+ section: "8.5"
+ conformance_class: consumer
+ - id: req-tg-007
+ keyword: MUST
+ section: "8.5"
+ conformance_class: consumer
+ - id: req-tg-008
+ keyword: MUST
+ section: "8.5.3"
+ conformance_class: consumer
+ - id: req-tg-009
+ keyword: MUST
+ section: "8.5.1"
+ conformance_class: consumer
+ - id: req-tg-010
+ keyword: MUST
+ section: "8.5.4"
+ conformance_class: consumer
+ notes: "project-scoped native hooks use a portable project-directory environment anchor and reject shell-expansion path syntax"
+ - id: req-tg-011
+ keyword: MUST
+ section: "8.5.5"
+ conformance_class: consumer
+ notes: "a schema-bearing Agent Plugins v1 dependency stays opaque to legacy projection at deployment; target exclusion may retain materialization and lock state but creates no native registration or primitive projection, remains nonfatal to ordinary mixed-batch dependencies, and is reported consistently by dry-run"
+ - id: req-tg-012
+ keyword: MUST
+ section: "8.5.6"
+ conformance_class: consumer
+ notes: "implementation-defined plugin-root placeholders preserve balanced expandable double quoting across split-quote forward- or backslash path spelling and unresolved placeholders emit a default-visible diagnostic"
+ - id: req-tg-013
+ keyword: MUST
+ section: "8.5.7"
+ conformance_class: consumer
+ notes: "schema, effective-target, integrity, security, and executable admission drives one in-place aggregate registration opaque to legacy projection without lifecycle host-binary probing; only admitted dependencies participate in claimant selection and registration, direct dependencies win plugin-name collisions over transitive dependencies, equal-precedence collisions fail during admission, advisory cleanup omits ambiguous or changed-owner entries, and ledger-primary ownership permits exact-entry recovery of a reserved namespace while rejecting foreign collisions and invalid JSON, preserving unrelated JSON semantics, and rolling back catalog, ledger, and settings together"
+ - id: req-tg-014
+ keyword: MUST
+ section: "8.5.8"
+ conformance_class: consumer
+ notes: "user-scoped MCP target selection uses a disclosed versioned capability contract, ignores project-only detection signals, applies first-source precedence without fallback, filters mixed sets, and refuses zero-supported selections before user manifest, lockfile, or target-config mutation"
+ - id: req-pr-006
+ keyword: MUST
+ section: "8.1"
+ conformance_class: consumer
+ notes: "legacy plugin skills declarations replace discovery; invalid, escaping, symlinked, and duplicate-derived entries fail closed"
+ - id: req-pr-007
+ keyword: MUST
+ section: "8.1"
+ conformance_class: consumer
+ notes: "declared Plugin component copying canonicalizes its non-symlink source root and prunes the current operation's materialization subtree"
+ - id: req-sc-001
+ keyword: MUST
+ section: "10.4"
+ conformance_class: consumer
+ - id: req-sc-002
+ keyword: MUST
+ section: "10.9"
+ conformance_class: consumer
+ - id: req-sc-003
+ keyword: MUST
+ section: "10.3"
+ conformance_class: consumer
+ - id: req-sc-004
+ keyword: MUST
+ section: "10.5"
+ conformance_class: consumer
+ - id: req-sc-005
+ keyword: MUST
+ section: "10.3"
+ conformance_class: consumer
+ - id: req-sc-006
+ keyword: MUST
+ section: "4.2.3"
+ conformance_class: consumer
+ - id: req-sc-007
+ keyword: MUST
+ section: "10.3"
+ conformance_class: consumer
+ - id: req-sc-008
+ keyword: SHOULD
+ section: "10.3"
+ conformance_class: consumer
+ - id: req-sc-009
+ keyword: MUST
+ section: "10.13"
+ conformance_class: consumer
+ - id: req-sc-010
+ keyword: MUST
+ section: "10.13"
+ conformance_class: consumer
+ - id: req-sc-011
+ keyword: MUST
+ section: "10.14"
+ conformance_class: consumer
+ - id: req-sc-012
+ keyword: MUST
+ section: "10.14"
+ conformance_class: consumer
+ - id: req-sc-013
+ keyword: MUST
+ section: "10.3"
+ conformance_class: consumer
+ notes: "configured authority overlap selects one documented host class before credential resolution; only that class's credentials reach requests and child processes; explicit non-default ports remain scoped"
+ - id: req-sc-014
+ keyword: MUST
+ section: "10.15"
+ conformance_class: consumer
+ notes: "per-invocation consent for bin/ deployment defaults to deny when stdout is not a TTY; explicit opt-in overrides; allowExecutables policy gate evaluated first"
+ - id: req-sc-015
+ keyword: MUST
+ section: "10.16"
+ conformance_class: consumer
+ oracle: tests/fixtures/spec-conformance/source-plan/req-sc-015.json
+ notes: "one post-authorization source-file set drives every install and uninstall re-integration scan and primitive materialization; source-only files and symlink entries are excluded"
+ - id: req-rg-001
+ keyword: MUST
+ section: "11.3.3"
+ conformance_class: registry
+ - id: req-cf-001
+ keyword: MUST
+ section: "12.5"
+ conformance_class: consumer
+ - id: req-cf-002
+ keyword: MUST
+ section: "12.3"
+ conformance_class: consumer
diff --git a/docs/src/content/docs/reference/cli/audit.md b/docs/src/content/docs/reference/cli/audit.md
index ca34ddcbfa..7362b9c3a0 100644
--- a/docs/src/content/docs/reference/cli/audit.md
+++ b/docs/src/content/docs/reference/cli/audit.md
@@ -179,6 +179,40 @@ For the full workflow, see [Enforce in CI](../../../enterprise/enforce-in-ci/).
### Drift detection
+Audit evaluates **current target intent**: `apm.yml target(s)` first, then
+[`apm config set target`](../config/), then existing directory detection.
+It does not reconstruct an earlier one-shot `apm install --target` override.
+Audit rejects a malformed saved target only when that fallback is selected;
+a valid manifest target wins over irrelevant stale invalid configuration.
+Keep the intended target in the manifest or saved configuration; experimental
+targets such as `grok-cloud` use the saved configuration because manifest
+target declarations do not accept experimental names.
+
+Changing that intent changes the expected output. Old recorded deployments
+remain in the comparison, and missing ownership does not remove source-derived
+expectations. Recorded native roots are comparison-only, not replay authority;
+an unavailable former root fails comparison instead of passing. Filesystem-backed
+native targets retain their scoped roots and layout, with replay destinations
+rebased into scratch.
+
+Experimental targets still require enablement and runtime prerequisites;
+an unavailable selected target fails CI as `target-resolution`. Saving a target
+name does not guarantee replay support: native nonfilesystem targets without
+an isolated filesystem backend (currently `copilot-app`) produce an explicit
+scratch replay failure. Audit does not invoke their live workflow writer or
+modify their database or sidecars.
+
+Audit leaves absent user configuration absent and existing configuration
+unchanged, including during package parsing, MCP inspection, and content scanning.
+Cold replay also reads transport preferences without creating configuration.
+It also skips opportunistic startup update checks, which otherwise write an
+update cache; use `apm self-update --check` separately.
+
+Known limitation: a local package's internal resource symlink can install as
+regular-file content yet be reported as `orphaned` by unchanged CI audit.
+This is a replay mismatch, not permission to follow escaping links.
+See the [conformance limitations](../../../specs/conformance/#what-conformance-does-not-cover).
+
The default audit replays the install pipeline into a scratch tree and diffs
the result against the working tree. It catches hand-edits, missing
integrations, orphaned files, and `unrecorded` files. `unrecorded` applies when
diff --git a/docs/src/content/docs/reference/cli/config.md b/docs/src/content/docs/reference/cli/config.md
index 3709d06f80..1bcb48dc9c 100644
--- a/docs/src/content/docs/reference/cli/config.md
+++ b/docs/src/content/docs/reference/cli/config.md
@@ -65,7 +65,7 @@ Remove `KEY` from `~/.apm/config.json`. No-op if the key is not set. Supported u
| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `auto-integrate` | boolean | `true` | Auto-discover `.prompt.md` files under `.github/prompts/` and `.apm/prompts/` and merge them into compiled `AGENTS.md` output. |
-| `target` | target token | unset | Default target for package, MCP, and LSP phases of `apm install` and `apm update` when `--target` and `apm.yml target(s)` are absent. Uses the same parser as `apm install --target` (single or comma-separated). |
+| `target` | target token | unset | Default target for package, MCP, and LSP phases of `apm install` and `apm update` when `--target` and `apm.yml target(s)` are absent. Also supplies [audit's current target intent](../audit/#drift-detection) when the manifest declares no targets. Uses the `apm install --target` parser (single or comma-separated). |
| `self-update.channel` | enum | `stable` | Default release channel for `apm self-update`: `stable` selects the latest stable release; `prerelease` selects the newest non-draft prerelease. Both pass the selected release to the installer as one normalized `VERSION`. `APM_SELF_UPDATE_CHANNEL` overrides config. |
| `self-update.install-dir` | path | unset | Optional launcher preference; `APM_INSTALL_DIR` overrides it. On Unix, a set value must match the existing launcher and unset preserves it; neither migrates. Windows uses the installer destination/default. |
| `temp-dir` | path | system temp | Directory used for clone and download operations. Useful when the OS temp directory is locked down (for example, corporate Windows endpoints rejecting `%TEMP%` with `[WinError 5]`). |
@@ -246,7 +246,7 @@ See [External scanners](../../../integrations/external-scanners/).
- **Location:** `~/.apm/config.json`
- **Format:** JSON object, one entry per stored key.
-- **Created on first read** with `{"default_client": "vscode"}`. Hand-editing is supported but `apm config set` is preferred -- it validates input and normalizes paths.
+- **Created on first read** with `{"default_client": "vscode"}`, except during [audit](../audit/#drift-detection), which leaves absent configuration absent. Prefer `apm config set` over hand-editing: it validates input and normalizes paths.
Internal JSON keys use snake_case (`auto_integrate`, `install_target`, `self_update_channel`, `self_update_install_dir`, `temp_dir`, `allow_protocol_fallback`, `prefer_ssh`, `copilot_cowork_skills_dir`); CLI keys use kebab-case or dotted namespaces (the CLI `target` key is stored as `install_target`). The CLI translates between the two.
diff --git a/docs/src/content/docs/reference/cli/install.md b/docs/src/content/docs/reference/cli/install.md
index 7b092b560d..2c5dd57d22 100644
--- a/docs/src/content/docs/reference/cli/install.md
+++ b/docs/src/content/docs/reference/cli/install.md
@@ -19,6 +19,16 @@ With no arguments it installs everything from `apm.yml`. With one or more `PACKA
`PACKAGE_REF` accepts: shorthand (`owner/repo`), HTTPS or SSH Git URLs, FQDN shorthand (`host/owner/repo`), local paths (`./path`, `/abs/path`, `~/path`), packed bundles (`./bundle.zip`, `./bundle.tar.gz`), and marketplace refs (`NAME@MARKETPLACE[#ref]`).
+With `--global`, direct local dependencies must use absolute paths (`~/path`
+also works). A local package can still declare a relative child such as
+`../child`: APM resolves it from that declaring package's original source
+directory, not the current working directory or `~/.apm/`. Direct or unanchored
+relative local references remain unsupported at user scope.
+This source anchor does not change deployment scope. APM does not look in
+another scope's installed packages for a missing local source. Remote-declared
+relative paths stay inside their authenticated repository; symlinks within a
+selected local package must stay inside that package's resolved source directory.
+
:::caution
`http://` dependencies are refused unless you pass `--allow-insecure` (direct) or `--allow-insecure-host HOSTNAME` (transitive).
:::
diff --git a/docs/src/content/docs/specs/conformance.md b/docs/src/content/docs/specs/conformance.md
index c0b10e1a05..a72960d2cc 100644
--- a/docs/src/content/docs/specs/conformance.md
+++ b/docs/src/content/docs/specs/conformance.md
@@ -1,37 +1,59 @@
---
title: Conformance statement
-description: The conformance statement for the APM CLI's implementation of OpenAPM v0.1, generated on every build.
+description: The APM CLI's version-qualified OpenAPM requirement bindings and their limits.
sidebar:
order: 2
---
-This page is the entry point to the conformance statement APM publishes for [OpenAPM v0.1](/apm/specs/openapm-v01/). Per req-cf-002, a conformance statement MUST cite the test invocation per requirement. APM's statement is regenerated by the spec-conformance CI gate when relevant spec, fixture, test, implementation, or statement files change.
+The OpenAPM assessment inventory binds requirements to collected tests. It is not a runtime pass certificate or a claim of ratification. [OpenAPM v0.1](../openapm-v01/) remains active with its original contract.
+
+:::note[Planned]
+Normative reconciliation is complete for this candidate's [OpenAPM v0.2.0 corrective draft](../openapm-v020/). Qualified-human review, ratification, and activation remain unsatisfied. [Section 9.3](../openapm-v020/#93-amendment-process) requires a labelled process issue and at least two qualified nonauthor human approvals: one implementation reviewer and one consumer/integrator reviewer. No qualifying human approvals are recorded for this candidate.
+
+Only the public-comment period is [waived](https://github.com/microsoft/apm/issues/2818#issuecomment-5558647529). Explicit human ratification/publication remains required; there is no dedicated automatic ratification job, and green `spec-conformance` does not ratify. Assessment work does not advance `latest`, activate or publish the draft, or establish implementation conformance.
+:::
## Where the statement lives
-Two artifacts ship at the repository root and update when the conformance inputs change:
+Two artifacts live at the repository root and update when the conformance inputs change:
-- [`CONFORMANCE.md`](https://github.com/microsoft/apm/blob/main/CONFORMANCE.md) -- human-readable per-requirement table (pass / skip / fail), with a link to the pytest source for each row. Read this first.
-- [`CONFORMANCE.json`](https://github.com/microsoft/apm/blob/main/CONFORMANCE.json) -- machine-readable equivalent for tooling and downstream conformance aggregators. The fields are stable across patch versions of the spec.
+- [`CONFORMANCE.md`](https://github.com/microsoft/apm/blob/main/CONFORMANCE.md) -- per-requirement static bindings (`active`, `skipped`, `xfail`, or `unbound`), requirement links, and waiver rationale.
+- [`CONFORMANCE.json`](https://github.com/microsoft/apm/blob/main/CONFORMANCE.json) -- the same inventory with collected test node IDs, exact specification identity, and input fingerprints.
-The CI workflow that emits both is [`.github/workflows/spec-conformance.yml`](https://github.com/microsoft/apm/blob/main/.github/workflows/spec-conformance.yml). The gate fails the build if the generated statement drifts from the committed copy, so the version of `CONFORMANCE.md` you see on `main` is exactly what CI produced from the matching spec commit.
+The [spec-conformance workflow](https://github.com/microsoft/apm/blob/main/.github/workflows/spec-conformance.yml) compares regenerated artifacts with the committed copies. Selection is owned by `tests/spec_conformance/_manifest.py`; it validates the manifest identity against the specification artifact. Generation collects the full selected suite afresh and rejects failed collection or mismatched fingerprints instead of reusing an older map.
## How to verify yourself
-The conformance suite is shipped in-tree and runs against the published spec text. To reproduce the statement locally:
+The conformance suite runs against the selected in-tree specification. At the source revision being assessed, run:
```bash
-git clone https://github.com/microsoft/apm.git
-cd apm
uv run --extra dev pytest tests/spec_conformance
uv run --extra dev python -m tests.spec_conformance.gen_statement
git diff -- CONFORMANCE.md CONFORMANCE.json # compare to the in-repo copies
```
-The first command runs every requirement-bound test and reports per-requirement results. The second regenerates the statement from those results; the final command shows whether the generated artifacts still match the checked-in copies.
+The pytest invocation executes the tests and reports test outcomes. The generator separately collects static bindings; it does not consume those execution outcomes. Retain the execution log and exact source or build pin as separate evidence. The final command compares generated artifacts with their committed copies.
## What conformance does NOT cover
-Per req-cf-001, a conformance statement covers the normative statements declared in the spec; it does not certify implementation quality, performance, or operational fitness. The statement currently records `req-mf-016` as skipped, with rationale in the statement itself -- an explicit, audited decision rather than silent drift.
+An `active` binding does not establish that a test ran or passed. Some bindings inspect schema or specification text rather than running a full lifecycle. Full conformance still needs the evidence and limitations required by Section 11.2 and req-cf-002; source-level results do not substitute for hosted-runtime evidence or certify implementation quality, performance, or operational fitness.
+
+The corrective-draft local-source cases assess req-mf-016 only. They do not prove that the CLI ever satisfied the previous minor's blanket project-root refusal. Preserving that artifact does not claim that the current suite proves historical compliance. The requirements manifest remains informative, and existing wire-schema identities are unchanged.
+
+The audit cases bind current target intent and read-only replay to req-lk-023. They do not erase inherited integrity requirements: the CLI's bare content audit uses source-derived drift, while its stored-hash and full-SHA consistency baselines run in CI/conformance audit. The unqualified audit obligation in req-lk-017 remains an explicit bare-mode conformance limitation. Native Cowork cases use pre-existing fixture state, not a successful native install round trip.
+
+One coupled source-level case is a strict expected failure: local installation
+correctly dereferences an internal resource link, but unchanged CI audit can
+report that deployed regular file as orphaned. The case still checks content
+integrity and unchanged project/HOME state; escaping links have a separate,
+unsuppressed refusal control. This limitation is not a conformance pass or a
+symlink-containment exception.
+
+The retained manifest schema is also incomplete as an acceptance oracle: it
+rejects some source-plus-modifier forms and structurally accepts invalid
+`policy.hash` strings. Those schema probes do not prove runtime digest
+enforcement. Git symlink, submodule and checkout-filter hashing boundaries lack
+cross-platform execution evidence here. The normative obligations remain intact;
+the generated inventory lists these limitations rather than waiving them.
-For the broader drift-detection story (how the spec authors prevent the spec from rotting relative to the only implementation), see [`CONTRIBUTING.md` -- Spec amendment workflow](https://github.com/microsoft/apm/blob/main/CONTRIBUTING.md#spec-amendment-workflow).
+For the amendment workflow, see [Adding or changing a normative requirement](https://github.com/microsoft/apm/blob/main/CONTRIBUTING.md#adding-or-changing-a-normative-requirement-openapm).
diff --git a/docs/src/content/docs/specs/openapm-v0.2.md b/docs/src/content/docs/specs/openapm-v0.2.md
new file mode 100644
index 0000000000..fe26afb0ab
--- /dev/null
+++ b/docs/src/content/docs/specs/openapm-v0.2.md
@@ -0,0 +1,4367 @@
+---
+title: OpenAPM v0.2.0
+description: Normative specification for the Agent Package Manager (APM) format and conformance.
+slug: specs/openapm-v020
+sidebar:
+ order: 1
+---
+
+OpenAPM v0.2.0 is the corrective specification of the APM package format, manifest, lockfile, and policy semantics. It is the contract implementers, conformance testers, and enterprise reviewers build against. If you are learning how to USE APM, start with the consumer, producer, or enterprise guides -- this page defines what APM IS, not how to operate it.
+
+## Status of This Document
+
+This document is an **editor's Working Draft** of OpenAPM, exact revision
+**v0.2.0**. It is the **inactive corrective-spec foundation** retained in
+[microsoft/apm#2820](https://github.com/microsoft/apm/pull/2820), not an
+implementation-conformance statement or an active replacement for v0.1.
+The combined local-source and audit successor owns the complete executable
+assessment and bindings against the exact reconciled draft. This foundation
+does not select that assessment, activate runtime changes, or record an
+announcement, publication, or ratification date. The existing v0.1 remains
+active. Qualifying human review and ratification remain pending; citing this
+draft as a ratified specification is inappropriate.
+
+This bounded corrective minor changes only local-source provenance and
+anchoring ([req-mf-016](#req-mf-016)) and the current-intent, read-only
+audit contract ([req-lk-023](#req-lk-023)) in Section 5.5. This is a
+review candidate, not a publication or ratification record.
+Features previously reserved for v0.2 remain reserved for a future
+revision; they are not activated by v0.2.0.
+
+OpenAPM is published under the **MIT License**.
+
+Version **v0.2.0** is a `0.x` editor's draft under semantic-version-zero
+discipline: no backward-compatibility guarantee applies until version
+**1.0**. Each `0.x` minor MAY introduce breaking changes with the
+migration window described in [Section 9](#9-versioning-and-amendment-process).
+
+Editors track feedback in `microsoft/apm` and amend per the process in
+[Section 9.3](#93-amendment-process).
+
+## Abstract
+
+OpenAPM defines the on-disk file formats, dependency-resolution semantics,
+primitive type system, deployment matrix, and governance policy format used
+by the Agent Package Manager (APM). A conforming producer authors an
+`apm.yml` manifest; a conforming consumer resolves the declared
+dependencies, writes an `apm.lock.yaml` lockfile, and deploys primitives
+to the directories the targets matrix specifies; a conforming governance
+implementation evaluates an `apm-policy.yml` policy against the install
+plan before any byte is written to disk. The wire contract between
+consumers and registry servers is **not normative in this revision** and is
+reserved for a future revision (see [Appendix B](#appendix-b-registry-http-api-reserved)).
+
+## Table of Contents
+
+1. [Introduction](#1-introduction)
+2. [Conventions](#2-conventions)
+3. [Terminology](#3-terminology)
+4. [Manifest format (apm.yml)](#4-manifest-format-apmyml)
+5. [Lockfile format (apm.lock.yaml)](#5-lockfile-format-apmlockyaml)
+6. [Policy format (apm-policy.yml)](#6-policy-format-apm-policyyml)
+7. [Dependency resolution](#7-dependency-resolution)
+8. [Primitive type system and target matrix](#8-primitive-type-system-and-target-matrix)
+9. [Versioning and amendment process](#9-versioning-and-amendment-process)
+10. [Security considerations](#10-security-considerations)
+11. [Conformance](#11-conformance)
+12. [Conformance test methodology](#12-conformance-test-methodology)
+13. [Appendix A: Normative JSON Schemas (inline)](#appendix-a-normative-json-schemas-inline)
+14. [Appendix B: Registry HTTP API (reserved)](#appendix-b-registry-http-api-reserved)
+15. [Appendix C: Index of normative statements](#appendix-c-index-of-normative-statements)
+16. [Appendix D: Revision history](#appendix-d-revision-history)
+17. [Appendix E: Editorial reconciliation notes](#appendix-e-editorial-reconciliation-notes)
+
+---
+
+## 1. Introduction
+
+### 1.1 Goals and non-goals
+
+**Goals.**
+
+- Define an interoperable on-disk format (`apm.yml`, `apm.lock.yaml`,
+ `apm-policy.yml`) that any conformant tool can read and write.
+- Specify dependency resolution semantics precisely enough that two
+ independent implementations produce equivalent lockfiles from the
+ same manifest and remote state.
+- Specify a target-matrix contract (detection signals and deploy
+ directories) so consumers can pin deploy paths against the spec
+ rather than against a single implementation.
+- Specify a governance policy format that lets organisations gate
+ installs without forking the consumer toolchain.
+
+**Non-goals (this revision).**
+
+- The registry HTTP wire contract. The companion document
+ [registry-http-api.md](../../reference/registry-http-api/) is
+ **informational** in this revision and is reserved for normative inclusion
+ in a future revision once independent server implementations exist.
+- The on-disk format of any third-party plugin distribution channel
+ (such as the Claude-Code plugin marketplace). The OPTIONAL
+ `marketplace:` block in the manifest is normative as **input**;
+ the generated `marketplace.json` artifact is governed externally
+ and tracked additively.
+- The runtime API of any harness or IDE. OpenAPM describes what is
+ written to disk and where; it does not describe what a harness
+ reads from that disk.
+- Account, billing, identity, or audit-log semantics for hosted
+ registries.
+- Publisher identity, signature verification, and attestation
+ envelopes remain out of scope and reserved for a future revision.
+ See [Section 10.12](#1012-publisher-provenance-and-attestations-reserved).
+- Reproducible-build determinism of registry archives
+ (mtime/uid/gid normalisation, tar member ordering beyond
+ filesystem-natural order) remains out of scope and
+ reserved for a future revision.
+- Version withdrawal (yank, deprecate, supersede) for published
+ versions remains out of scope and reserved for a future revision.
+ See [Section 7.9](#79-version-withdrawal-reserved).
+- Workspace / monorepo composition (shared lockfile across sibling
+ packages, intra-workspace resolve-to-local, workspace publish) is
+ out of scope and reserved for a future revision; current
+ monorepo usage is supported via local-path dependencies per
+ [Section 4.3.5](#435-local-path-dependencies). See
+ [Section 4.8](#48-workspaces-reserved).
+- The consumer-side integrity model supplies checks against
+ a non-conforming registry (hash-verify-before-extract,
+ re-verify-on-frozen); residual gaps (availability,
+ enforcement of version-immutability, publisher identity) remain outside
+ the consumer guarantee in this revision. The existing Registry
+ trust-anchor obligation [req-rg-001](#req-rg-001) remains operative.
+
+### 1.2 Relationship to existing APM reference documentation
+
+The reference pages under `docs/src/content/docs/reference/` are
+**non-normative companions** to this specification:
+
+| Companion page | Role |
+|--------------------------------------------------------------------------------------|-----------------------------------|
+| [`manifest-schema.md`](../../reference/manifest-schema/) | Field reference and examples for [Section 4](#4-manifest-format-apmyml). |
+| [`lockfile-spec.md`](../../reference/lockfile-spec/) | Lifecycle table and examples for [Section 5](#5-lockfile-format-apmlockyaml). |
+| [`policy-schema.md`](../../reference/policy-schema/) | Field reference and examples for [Section 6](#6-policy-format-apm-policyyml). |
+| [`primitive-types.md`](../../reference/primitive-types/) | Conceptual model for [Section 8](#8-primitive-type-system-and-target-matrix). |
+| [`package-types.md`](../../reference/package-types/) | Producer-side layout decision tree for [Section 8](#8-primitive-type-system-and-target-matrix). |
+| [`targets-matrix.md`](../../reference/targets-matrix/) | Per-target support matrix; informational supplement to [Section 8](#8-primitive-type-system-and-target-matrix). |
+| [`registry-http-api.md`](../../reference/registry-http-api/) | Informational only; normative wire surface remains reserved. |
+
+Where a companion describes behaviour, this specification binds it.
+Where a companion and this specification disagree, **this specification
+wins**. Editorial notes in this document call out reconciliations
+between the companion corpus and the implementation.
+
+### 1.3 Document conventions
+
+- This revision carries **123 normative statements (118 MUST, 5 SHOULD)** indexed in
+ [Appendix C](#appendix-c-index-of-normative-statements).
+- All on-disk files defined by this specification are **YAML 1.2**
+ parsed under the safe subset defined in
+ [req-mf-020](#req-mf-020). A complete YAML safe-subset profile
+ (anchor/alias handling, tag whitelist, octal-coercion treatment)
+ is reserved for a future revision, not activated by v0.2.0.
+- Field names are `snake_case` unless they mirror an external
+ contract (such as `tagPattern` in the OPTIONAL marketplace block).
+- All examples in this document are ASCII-only. Implementations
+ encountering non-ASCII bytes in OpenAPM files MUST treat them as
+ UTF-8 and either preserve them on round-trip or reject them at
+ parse time with a diagnostic. Unicode normalisation (NFC), IDNA
+ for hosts, bidi-safe rendering, and locale-of-diagnostics are
+ out of scope and reserved for a future revision (see
+ [Section 1.4](#14-terminology-preliminaries)).
+- "Implementation" means any program that produces, consumes, or
+ evaluates an OpenAPM file. A given implementation MAY claim more
+ than one conformance class.
+
+### 1.4 Terminology preliminaries
+
+This revision distinguishes two host-related concepts that earlier
+drafts conflated:
+
+- **Implementation-default host** -- the host an implementation uses
+ when the manifest omits `default_host:` (see
+ [Section 4.2.4](#424-default_host)). This is an implementation
+ choice, not a spec mandate; the reference APM CLI's
+ implementation-default host is `github.com`.
+- **Wire-format host** -- the host literal that appears in a
+ dependency identifier or lockfile entry. Canonical normalisation
+ (see [req-mf-009](#req-mf-009)) strips the project's
+ `default_host:` only.
+
+**Internationalization considerations (reserved).** This revision
+does not normatively specify IDNA normalisation for hosts, Unicode
+normalisation form (NFC) for package or owner names, bidi-safe
+rendering of identifiers, or diagnostic locale. These remain reserved
+for a future revision. Implementations SHOULD compare names byte-for-byte
+after the canonical normalisation defined in
+[req-mf-009](#req-mf-009).
+
+---
+
+## 2. Conventions
+
+The key words "**MUST**", "**MUST NOT**", "**REQUIRED**", "**SHALL**",
+"**SHALL NOT**", "**SHOULD**", "**SHOULD NOT**", "**RECOMMENDED**",
+"**MAY**", and "**OPTIONAL**" in this document are to be interpreted
+as described in BCP 14
+([RFC 2119](https://datatracker.ietf.org/doc/html/rfc2119),
+[RFC 8174](https://datatracker.ietf.org/doc/html/rfc8174)) when, and
+only when, they appear in all capitals.
+
+Lowercase variants of these words ("must", "should", and so on) carry
+no normative weight and are descriptive prose.
+
+Every normative statement in this document carries a stable identifier
+of the form `req--` (for example `req-mf-001`) anchored
+directly above the statement. These identifiers are stable across
+errata; a renumbering requires a minor version bump as defined in
+[Section 9.2](#92-breaking-vs-non-breaking-change-definition).
+
+Conformance classes are defined normatively in
+[Section 11.1](#111-conformance-classes-normative); the four roles are
+**Producer**, **Consumer**, **Registry** (one MUST applies in
+this revision; the wire contract remains reserved), and **Governance**.
+An implementation MUST declare which conformance class(es) it
+claims when asserting OpenAPM conformance (see
+[Section 11.2](#112-how-to-claim-conformance)).
+
+---
+
+## 3. Terminology
+
+This section defines the terms used throughout this document. Where a
+term has a separate normative definition (for example "primitive
+type"), the definition section is cross-linked.
+
+| Term | Definition |
+|---|---|
+| **Manifest** | The `apm.yml` file for one package or installation scope. Defined in [Section 4](#4-manifest-format-apmyml). |
+| **Lockfile** | The `apm.lock.yaml` file recording one installation scope's resolved state. Defined in [Section 5](#5-lockfile-format-apmlockyaml). |
+| **Installation scope** | The isolated manifest, lockfile, and target-configuration boundary for an install. A project scope is rooted at the consumer project. A user scope is independent of any project root and uses an implementation-defined user location disclosed by the consumer's conformance statement. |
+| **Source anchor** | The original declaring package's source directory used to resolve a relative dependency path. It is not the dependency's staging or deployment directory. See [Section 4.3.5](#435-local-path-dependencies). |
+| **Source containment root** | The boundary within which source content is permitted to resolve: the authenticated repository root for a remote-declared relative dependency, or the selected local package's resolved source directory for symlinks inside that package. It is distinct from installation scope and from the source anchor. |
+| **Policy** | An `apm-policy.yml` file evaluated by a Governance implementation. Defined in [Section 6](#6-policy-format-apm-policyyml). |
+| **Package** | A unit identified by a manifest (`apm.yml`) or by a recognised package layout (see [Section 8.1](#81-primitive-types)). |
+| **Primitive** | A typed unit of agent configuration (instruction, prompt, agent, skill, command, hook, or mcp server). Defined in [Section 8.1](#81-primitive-types). |
+| **Target** | A named runtime harness (for example `copilot`, `claude`, `cursor`). Defined in [Section 8.4](#84-target-detection-signals-normative). |
+| **Deploy directory** | The on-disk root under which a target's primitives are placed by `apm install`. Defined in [Section 8.5](#85-deploy-directory-contract-normative). |
+| **Source-declared capability restriction** | An agent-primitive field whose documented purpose is to narrow the tools, actions, or resources the converted agent may invoke (for example, a `tools` allowlist). |
+| **Direct dependency** | A dependency declared in the consumer's own `apm.yml`. |
+| **Transitive dependency** | A dependency declared in the `apm.yml` of a resolved package, not in the consumer's own `apm.yml`. |
+| **Virtual package** | A dependency targeting a subdirectory or file within a repository rather than the whole repository. Defined in [Section 4.3.3](#433-virtual-packages). |
+| **Registry** | A remote service that serves package archives over HTTP. The wire contract remains reserved for a future revision. |
+| **git-semver** | A dependency form whose `ref:` is a semver range matched against remote git tags. Defined in [Section 7.3](#73-git-semver-resolution). |
+| **Constraint** | The version selector recorded for a dependency (a semver range, a literal tag, a branch name, a commit SHA, or `None`). |
+| **Drift** | A divergence between the lockfile and either the manifest (declaration drift) or the deployed files on disk (integrity drift). |
+| **Self-entry** | The synthesized lockfile entry that accounts for primitives the project itself contributes. Defined in [Section 5.3](#53-self-entry-semantics). |
+| **Frozen install** | An install operation that refuses to write or rewrite the lockfile, fails on any missing package pin or MCP declaration mismatch, and gates validation before any lockfile, target-configuration, deployment, or cache write. Defined in [Section 5.5](#55-drift-and-integrity-model). |
+| **Host class** | The equivalence set of network hosts that share a single credential scope (see [Section 10.3](#103-token-leakage-across-hosts)). Two hosts are in the same class **iff** their registrable domain (the eTLD+1 per the Public Suffix List) is identical, OR they are explicitly aliased via `registries..aliases:` (see [Section 4.2.3](#423-registries)). For example, `github.contoso.com` shares a host class with `contoso.com`, not with `github.com`. Implementation-specific operator overrides to this default assignment are governed by [req-sc-013](#req-sc-013). |
+| **Configuration signal** | Any manifest declaration or implementation-specific operator setting that binds a hostname to a host class. Defined in [req-sc-013](#req-sc-013). |
+| **Implementation-default host** | The host an implementation uses when the manifest omits `default_host:`. The choice is implementation-defined; see [Section 1.4](#14-terminology-preliminaries). |
+| **Wire-format host** | The host literal as it appears in a dependency identifier or lockfile entry after canonical normalisation. |
+| **Hash envelope** | A digest serialised as `:` (for example `sha256:abcd...`). See [req-lk-016](#req-lk-016). |
+| **Conformance class** | One of the four roles defined in [Section 11.1](#111-conformance-classes-normative). |
+| **Conformant** | Satisfies all MUST-level requirements for a claimed conformance class. |
+| **Conforming file** | An OpenAPM file that parses without error under the rules of [Sections 4](#4-manifest-format-apmyml)-[6](#6-policy-format-apm-policyyml). |
+
+---
+
+## 4. Manifest format (apm.yml)
+
+### 4.1 Document structure and required fields
+
+The project-scope manifest is a single YAML 1.2 document located at the
+project root, filename `apm.yml`. A user-scope manifest, when supported,
+uses the user location declared under [req-tg-014](#req-tg-014).
+
+
+**[req-mf-001]** A conforming **producer** implementation MUST emit a
+manifest whose top-level document node is a YAML 1.2 mapping. A
+conforming **consumer** implementation MUST reject any manifest whose
+top-level node is not a mapping, with a diagnostic naming the file.
+
+
+**[req-mf-002]** A conforming **producer** implementation MUST include
+a top-level field `name` whose value is a non-empty string.
+
+
+**[req-mf-003]** A conforming **producer** implementation MUST include
+a top-level field `version` whose value is a string.
+
+
+**[req-mf-004]** A conforming **producer** implementation SHOULD emit
+a `version` value matching the official semver 2.0.0 reference
+regular expression (semver 2.0.0 Section 9):
+
+```
+^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$
+```
+
+A conforming **consumer** SHOULD emit a non-blocking diagnostic
+when `version` does not match this pattern. Numeric-looking version
+strings MUST be quoted in YAML to prevent integer/float coercion.
+
+
+**[req-mf-006]** A conforming **consumer** implementation MUST
+preserve unknown top-level keys when rewriting the manifest, so that
+manifests authored against a later revision of this specification
+round-trip through an older consumer without data loss.
+
+
+**[req-mf-020]** A conforming **consumer** implementation MUST parse
+manifest, lockfile, and policy documents under the YAML safe
+subset: (a) scalars are strings unless explicitly typed via the
+canonical YAML 1.2 tags `!!int`, `!!float`, or `!!bool`; (b)
+`&anchor` / `*alias` constructs MUST be rejected with a diagnostic;
+(c) custom (non-`!!`) tags MUST be rejected; (d) YAML 1.1 octal
+coercion (`0NN` interpreted as base-8) MUST NOT be applied. The
+complete safe-subset profile is reserved for a future revision; implementations
+MUST at minimum enforce clauses (a)-(d).
+
+
+**[req-ext-001]** A conforming **consumer** implementation MUST
+treat any mapping key matching the pattern `x-[a-z][a-z0-9-]*` at
+any nesting level of a manifest, lockfile, or policy document as a
+**vendor-extension key**. Vendor-extension keys MUST be ignored
+during semantic interpretation, MUST NOT cause parse-time errors,
+and MUST be preserved byte-equivalent on round-trip. The same rule
+applies to vendor-extension keys inside `dependencies.apm[]`,
+`dependencies.mcp[]`, lockfile per-entry mappings, and policy
+sub-blocks. See [Section 4.6.2](#462-vendor-extensions).
+
+
+**[req-ext-002]** This specification and all future revisions of
+OpenAPM MUST NOT define normative keys beginning with the prefix
+`x-`. The namespace is reserved exclusively for vendor extensions.
+
+A minimal conforming manifest:
+
+```yaml
+name: my-project
+version: 1.0.0
+```
+
+### 4.2 Field reference
+
+The manifest top-level fields are:
+
+| Field | Required | Type |
+|-----------------|----------|-----------------------------------------------------------------------------------------------|
+| `name` | yes | string |
+| `version` | yes | string (semver 2.0.0 per [req-mf-004](#req-mf-004)) |
+| `description` | no | string |
+| `author` | no | string |
+| `license` | no | string (SPDX identifier RECOMMENDED) |
+| `default_host` | no | string; see [Section 4.2.4](#424-default_host) and [req-mf-019](#req-mf-019) |
+| `target` | no | string, list of strings, or null; mutually exclusive with `targets` (see [req-tg-008](#req-tg-008)) |
+| `targets` | no | string or non-empty list of lowercase canonical identifiers or `all`; mutually exclusive with `target` (see [req-tg-008](#req-tg-008)) |
+| `type` | no | string (advisory, see [Section 4.2.2](#422-type-advisory)) |
+| `scripts` | no | mapping `string -> string` |
+| `includes` | no | literal `auto` or list of paths |
+| `registries` | no | mapping; see [Section 4.2.3](#423-registries) |
+| `dependencies` | no | mapping with OPTIONAL keys `apm`, `mcp`, `conflict_resolution` |
+| `devDependencies` | no | mapping with OPTIONAL keys `apm`, `mcp` |
+| `compilation` | no | mapping |
+| `policy` | no | mapping; consumer-side policy hooks |
+| `marketplace` | no | mapping; producer authoring block, see [Section 4.7](#47-marketplace-authoring-block-normative-input) |
+| `x-` | no | vendor extension, see [Section 4.6.2](#462-vendor-extensions) |
+
+#### 4.2.1 `target`
+
+The canonical set of `target` identifiers registered by this
+specification in this revision is:
+
+```
+copilot, claude, cursor, codex, gemini, antigravity, opencode, windsurf, agent-skills, all
+```
+
+The legacy aliases `vscode` and `agents` MAY appear in input manifests
+and MUST be normalised to `copilot` when the manifest is rewritten.
+The internal fallback value `minimal` MUST NOT be set explicitly in a
+manifest; it is reserved for the auto-detection fallback described in
+[Section 8.4](#84-target-detection-signals-normative). In an
+auto-detect context, `minimal` denotes the no-target-detected profile
+that emits `AGENTS.md` only; `all` denotes the union of every
+registered **auto-detectable** target (see
+[Section 8.4](#84-target-detection-signals-normative)). A target is
+**auto-detectable** when the OpenAPM Target Registry publishes at
+least one detection predicate for it; a target registered without a
+detection predicate is **explicit-only** and MUST be selected
+explicitly. In this revision the explicit-only targets are `agent-skills` and
+`antigravity`, so `all` excludes them.
+
+Concrete per-target detection signals and deploy roots are documented
+in the non-normative companion **"OpenAPM Target Registry v0.1"**
+(see [Section 8](#8-primitive-type-system-and-target-matrix)); the
+companion holds the table contents and is amended additively; this
+specification binds the schema only.
+
+
+**[req-mf-005]** A conforming **producer** implementation MUST reject
+any value of `target` (or any element of a `target` list) that is not
+either: (a) a member of the canonical set or a recognised alias; OR
+(b) a vendor-extension identifier matching
+`x-[a-z][a-z0-9-]*-[a-z][a-z0-9-]*` (the
+`x--` form). The diagnostic MUST name the offending
+token.
+
+
+**[req-tg-004]** A conforming **consumer** implementation MUST accept
+target identifiers matching `x-[a-z][a-z0-9-]*-[a-z][a-z0-9-]*` at
+parse time and MUST route detection, deployment, and conformance
+evaluation for such identifiers to a vendor-registered handler. In
+the absence of a registered handler, the consumer MUST emit a
+diagnostic naming the unsupported identifier and MUST NOT silently
+ignore the entry. Vendors MAY register new targets without spec
+amendment via this namespace.
+
+> **Editorial note.** Editorial reconciliation between the canonical
+> set above and the legacy aliases is consolidated in
+> [Appendix E](#appendix-e-editorial-reconciliation-notes).
+
+#### 4.2.2 `type` (advisory)
+
+The `type` field MAY take one of the values `instructions`, `skill`,
+`hybrid`, or `prompts`. Its semantic content is **advisory** in this revision:
+package behaviour is driven by the on-disk layout recognised in
+[Section 8.1](#81-primitive-types), not by this field. Future
+revisions MAY assign behavioural meaning; conformant consumers MUST
+NOT reject a manifest solely on the basis of the `type` field's value
+when that value is one of the four listed above. Editorial
+reconciliation with the companion is in
+[Appendix E](#appendix-e-editorial-reconciliation-notes).
+
+#### 4.2.3 `registries`
+
+The `registries` block MAY declare REST-based registries the project
+consumes. The block is OPTIONAL; absence of the block means
+git-resolution-only.
+
+
+**[req-mf-014]** A conforming **producer** implementation MUST ensure
+that every `registries..url` value begins with `https://` or
+`http://`. Other URL schemes MUST be rejected at parse time.
+
+
+**[req-mf-015]** A conforming **producer** implementation MUST reject
+unknown keys inside any `registries.` entry at parse time,
+**except** for vendor-extension keys matching `x-[a-z][a-z0-9-]*`
+(see [req-ext-001](#req-ext-001)). This constraint is a typo guard
+and prevents silent acceptance of mistyped keys.
+
+
+**[req-sc-006]** A conforming **consumer** implementation MUST treat
+any `registries..url` that uses the `http://` scheme as a
+parse-time error **unless** one of the following is true: (a) the
+entry sets `insecure: true` explicitly; OR (b) the host is the
+loopback address (`127.0.0.0/8`, `::1`) or an RFC 1918 private
+address (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`). The
+diagnostic MUST name the offending registry.
+
+Optional fields on a `registries.` mapping:
+
+| Field | Type | Notes |
+|------------|-----------|-----------------------------------------------------------------------|
+| `url` | string | REQUIRED; scheme constrained by [req-mf-014](#req-mf-014). |
+| `insecure` | boolean | When `true`, allows `http://`. See [req-sc-006](#req-sc-006). |
+| `aliases` | string[] | Additional host names that share this registry's host class. See [Section 10.3](#103-token-leakage-across-hosts). |
+
+Example:
+
+```yaml
+registries:
+ internal:
+ url: https://artifactory.example.com/artifactory/api/skills/internal
+ aliases:
+ - mirror.example.com
+ default: internal
+```
+
+#### 4.2.4 `default_host`
+
+The OPTIONAL `default_host:` top-level field selects the host that
+canonical normalisation strips from shorthand dependency identifiers
+(see [Section 4.3.4](#434-canonical-normalisation-writer-requirements)).
+When omitted, the consumer uses its implementation-default host;
+this specification does not mandate `github.com` or any other value
+for the implementation-default.
+
+
+**[req-mf-019]** A conforming **consumer** implementation that
+encounters a `default_host:` value MUST treat that value as the only
+host stripped by canonical normalisation per
+[req-mf-009](#req-mf-009). When the manifest omits `default_host:`,
+the consumer MAY apply its implementation-default host but MUST
+document that choice in its conformance statement (see
+[Section 11.2](#112-how-to-claim-conformance)). A consumer MUST NOT
+strip any host other than the one selected by `default_host:` or the
+declared implementation-default.
+
+### 4.3 Dependencies block (apm + mcp)
+
+The OPTIONAL `dependencies` block has two OPTIONAL list-valued keys:
+`apm` for agent primitive packages, and `mcp` for MCP servers.
+Implementations MAY encounter unknown sibling keys (such as future
+dependency kinds) and MUST preserve them on rewrite per
+[req-mf-006](#req-mf-006).
+
+#### 4.3.1 String form
+
+Each `dependencies.apm` entry MAY be a string conforming to the
+following grammar (RFC 5234 ABNF):
+
+```
+dependency = url-form / shorthand-form / local-path-form
+
+url-form = url-scheme clone-url
+url-scheme = "https://" / "http://" / "ssh://git@" / "git@"
+clone-url = host [ ":" port ] "/" owner "/" repo
+ [ "/" virtual-path ] [ "#" ref ]
+
+shorthand-form = [ host "/" ] owner "/" repo
+ [ "/" virtual-path ] [ "#" ref ]
+
+local-path-form = local-prefix path-tail
+local-prefix = "./" / "../" / "/" / "~/" / ".\" / "..\" / "~\"
+path-tail = 1*pchar
+
+host = 1*( ALPHA / DIGIT / "-" / "." )
+port = 1*DIGIT ; range 1-65535
+owner = 1*( ALPHA / DIGIT / "-" / "_" )
+repo = 1*( ALPHA / DIGIT / "-" / "_" / "." )
+virtual-path = segment *( "/" segment )
+segment = 1*( ALPHA / DIGIT / "-" / "_" / "." )
+ref = 1*VCHAR
+pchar = ALPHA / DIGIT / "/" / "\" / ":" / "." / "-" / "_" / "~"
+```
+
+`clone-url` MAY include a `:port` segment on `https://`, `http://`,
+and `ssh://git@` forms. `local-path-form` paths beginning with a
+backslash-prefixed `local-prefix` are normalised to POSIX form on
+read; see [Section 4.3.4](#434-canonical-normalisation-writer-requirements).
+
+ABNF productions are interpreted per RFC 5234. `ALPHA`, `DIGIT`, and
+`VCHAR` are the core rules from RFC 5234 Appendix B.1.
+
+
+**[req-mf-007]** A conforming **consumer** implementation MUST parse
+string-form `dependencies.apm` entries per the grammar above.
+Implementations MUST reject any string that does not satisfy one of
+the three productions, with a diagnostic identifying the offending
+entry.
+
+#### 4.3.2 Object form
+
+An object-form entry uses one of two identity keys, `git:` or `id:`,
+and MUST NOT use both on the same entry.
+
+| Field | Required | Notes |
+|----------|-----------------------------------------|-----------------------------------------------------------------------|
+| `git` | yes for git-sourced; mutually excl. `id`| Clone URL or shorthand. Special value `parent` defined below. |
+| `id` | yes for registry-sourced; mutually excl. `git` | `/` registry identity. |
+| `registry` | no | Registry name; defaults to project default if omitted. |
+| `version`| yes (registry form) | Opaque version selector; semver range when registry publishes semver. |
+| `ref` | no | Branch, tag, semver range, or commit SHA (git form). |
+| `path` | no / yes (local form) | Subpath within repo, or local filesystem path. |
+| `alias` | no | Local alias. |
+| `skills` | no | Skill-subset selection for dependencies that expose selectable skills (see [Section 8.1](#81-primitive-types)). |
+
+
+**[req-mf-011]** A conforming **consumer** implementation MUST reject
+any object-form entry that sets both `id:` and `git:` on the same
+entry. The diagnostic MUST name the entry and the conflicting keys.
+
+
+**[req-mf-022]** A conforming **consumer** implementation that applies
+a non-empty `skills:` subset to a dependency that exposes selectable
+skills (see [Section 8.1](#81-primitive-types)) and deploys zero skills
+from that dependency because no selected name matches an available
+skill MUST emit a default-visible diagnostic before the install
+operation returns. The diagnostic MUST identify the dependency, the
+requested skill names, and the available skill names (or state that
+none are available). The consumer MAY complete the overall install
+successfully when no other error exists; this requirement does not turn
+a stale persisted subset into an install failure.
+
+
+**[req-mf-010]** A conforming **consumer** implementation MUST treat
+the literal sentinel `git: parent` as valid **only** inside a
+transitively resolved package whose clone coordinates are known to
+the resolver. The resolver MUST expand `parent` to the parent
+package's `host`, `repo_url`, and resolved `ref`, with `virtual_path`
+taken from `path`. The literal `parent` MUST NOT appear in the
+lockfile as durable identity (`repo_url` or `source`).
+
+
+**[req-mf-024]** A conforming **consumer** implementation MUST NOT
+silently rewrite an existing `id:`-form (registry-sourced) manifest
+entry into a `git:`-form entry when persisting a subsequent CLI-driven
+manifest update (e.g. an additive `--skill` pin) for the same
+dependency identity. When a CLI-parsed reference is ambiguous about
+its source (git vs. registry) but an existing manifest entry for the
+same identity already resolves to the `registry` source, the
+implementation MUST honor the existing entry's source when
+serializing the updated entry. If an update would otherwise replace a
+registry-sourced entry with a non-registry-shaped entry, the
+implementation MUST reject the update with a diagnostic naming the
+identity, rather than silently converting it.
+
+#### 4.3.3 Virtual packages
+
+A dependency MAY target a subdirectory or a file within a repository
+rather than the whole repository.
+
+
+**[req-mf-008]** A conforming **consumer** implementation MUST
+classify virtual packages by **file extension only** and MUST NOT
+infer kind from path segments. A `virtual_path` ending in
+`.prompt.md`, `.instructions.md`, or `.agent.md` is a
+file; any other path is a subdirectory. On-disk shape of a
+subdirectory virtual package is resolved by probing for `apm.yml`
+first.
+
+#### 4.3.4 Canonical normalisation (writer requirements)
+
+
+**[req-mf-009]** A conforming **consumer** implementation MUST
+normalise dependency entries to canonical form when rewriting the
+manifest. The canonical form strips **only** the host that matches
+the project's `default_host:` value (per
+[req-mf-019](#req-mf-019)) or, if `default_host:` is omitted, the
+consumer's declared implementation-default host. SCP-style git
+URLs (`git@host:owner/repo.git`) and `https://` URLs targeting the
+selected default host MUST be normalised to the shorthand form
+`owner/repo`. Non-default hosts MUST retain their FQDN. A consumer
+MUST NOT hard-code stripping of any specific host literal; the
+selection is configured per project.
+
+#### 4.3.5 Local-path dependencies
+
+
+**[req-mf-016]** A conforming **consumer** implementation MUST
+recognise dependency strings beginning with `./`, `../`, `/`, `~/`,
+`.\`, `..\`, or `~\` as local-path entries. Admission and resolution
+depend on the declaring source, not merely on the presence of `..`.
+This prefix recognition is syntactic, not authorization to read an
+operator-local filesystem path. For remote-declared entries, apply
+clause (c) before operator-local admission under clause (b); a
+successfully derived Git reference is not an operator-local source:
+
+(a) **Selected local sources.** A consumer MAY admit operator-selected
+local sources at project and user scope, including absolute paths,
+home-expanded paths, and sibling packages outside the consumer
+project root. A consumer MAY restrict allowed local source roots
+through operator configuration or a documented implementation policy;
+this requirement does not mandate access to every local root. Such
+restrictions MUST be reported when they cause rejection, not presented
+as a successfully materialized dependency.
+
+An admitted direct project-scope relative dependency MUST resolve
+from the consumer project's source directory. An admitted absolute
+path MUST be resolved as an absolute source, after any home-directory
+expansion. An admitted relative dependency declared by an explicitly
+selected local package, or by another local package reached through
+that declared local dependency chain, MUST resolve from the declaring
+package's original source directory, including at user scope. The
+consumer MUST NOT substitute a staging directory, deployment directory,
+or unrelated current working directory for the declaring source anchor.
+
+(b) **Operator-local user-scope admission.** A direct relative local
+filesystem dependency at user scope MUST be rejected. A relative
+transitive local filesystem dependency
+at user scope MUST be rejected unless the consumer has established
+its declaring local parent and that parent's original absolute
+source directory. Provenance comes from the declaring dependency's
+established source kind, not its repository name or path spelling.
+A repository-name prefix such as `_local/`, a local-looking path,
+or a recorded path string alone is not proof that its parent is local.
+Unknown provenance does not authorize an operator-local read.
+The consumer MUST NOT
+search another installation scope's installed packages to supply a
+missing source anchor.
+
+(c) **Remote-declared paths.** A relative local-path entry declared
+by a remote Git package MUST resolve inside the authenticated parent
+repository root, after path normalisation and symlink resolution.
+An admitted entry MUST retain the parent's remote repository identity
+and ref, and MUST NOT become a read from the consumer's local package
+namespace. Absolute paths, including home-expanded and Windows
+absolute paths, and paths escaping that repository MUST be rejected.
+If the entry cannot be tied to that remote repository and ref, it
+MUST be rejected rather than treated as a trusted local dependency.
+
+(d) **Local package contents.** Once a local source directory is
+selected and resolved, that directory is the containment root for
+symlinks inside the package. An internal symlink whose resolved
+target stays inside that root MUST be materialized as the target's
+content, subject to the other applicable admission and content
+selection rules. A broken or cyclic internal symlink, or one whose
+target escapes that root, MUST cause local materialization to fail.
+Choosing a path that resolves to a source directory is distinct from
+dereferencing symlinks within the selected package's content.
+
+Rejections under this requirement MUST identify the offending path
+and the reason for refusal. These source rules do not change the
+manifest, lockfile, or target-configuration boundary of the selected
+installation scope.
+
+**Security note (informative).** Selecting a local source is an
+explicit trust decision about that source and its declared local
+dependency chain, not a claim that local content is harmless.
+Source anchoring, remote-repository containment, internal-symlink
+containment, and deployment eligibility are separate checks. This
+requirement does not promise atomicity of the entire install or
+race-free filesystem isolation against concurrent source mutation.
+An `alias` is destination naming, not source authorization or a substitute
+source anchor. It does not relax destination containment, including the
+registered target roots under [req-tg-002](#req-tg-002).
+
+**Local replay interoperability (informative).** A relative spelling
+alone does not identify a local source. Replaying a local dependency
+chain requires reestablishing the original declaring-source context;
+a recorded path string is not authorization to read it. An absolute
+local path does not imply that the same source exists or is approved
+on another machine. The reference CLI's `declaring_parent` and
+`anchored_local_path` metadata are implementation-specific; this
+requirement does not standardize those fields or a portable local
+lockfile representation.
+
+#### 4.3.6 MCP dependencies
+
+The OPTIONAL `dependencies.mcp` list declares MCP servers. Each entry
+is either a registry string or an object. Object-form fields are
+defined in [`manifest-schema.md` Section 4.2](../../reference/manifest-schema/#42-dependenciesmcp----listmcpdependency).
+
+
+**[req-mf-012]** A conforming **consumer** implementation MUST reject
+any self-defined MCP server entry (one where `registry: false`) that:
+(a) omits `transport`; (b) sets `transport: stdio` but omits
+`command`; (c) sets `transport` to `http`, `sse`, or `streamable-http`
+but omits `url`. When `transport: stdio` is in effect, the `command`
+value MUST be a single binary path with no embedded whitespace
+**unless** the entry also supplies an `args` key (including an
+explicit empty list); a path containing spaces without an `args`
+sibling MUST be rejected at parse time.
+
+### 4.4 devDependencies
+
+The OPTIONAL `devDependencies` block has the same structure as
+`dependencies`. Entries declared under `devDependencies` are
+installed locally but excluded from packed plugin bundles produced
+by the producer toolchain.
+
+### 4.5 Variable references in MCP env/headers and runtime arguments
+
+Values inside `mcp[].env` and `mcp[].headers` MAY contain three
+placeholder syntaxes:
+
+| Syntax | Source | Resolution |
+|-------------------|-----------------------|-------------------------------------------------------------------------|
+| `${VAR}` | host environment | Normalised to `${env:VAR}` for native interpolation; resolved at install for others. |
+| `${env:VAR}` | host environment | Passed through where natively supported; resolved at install otherwise. |
+| `${input:}` | interactive prompt | Native where supported; otherwise the placeholder MUST NOT be silently rendered as literal text. |
+
+GitHub Actions templates (`${{ ... }}`) MUST be left untouched.
+
+
+**[req-mf-013]** A conforming **consumer** implementation MUST
+resolve `${VAR}`, `${env:VAR}`, and `${input:}` placeholders per
+the dispatch matrix above and MUST NOT emit a generated config file
+in which an unsupported placeholder is silently passed through as
+literal text. When an unsupported placeholder is encountered for the
+active target, the consumer MUST emit a diagnostic and MAY refuse to
+write the generated config.
+
+Registry OCI/Docker package `runtime_arguments` and `package_arguments`
+entries MAY contain bare `{name}` templates in their `value` or `default`
+fields. An entry's `variables` map declares metadata for variable names
+across the package; `isSecret: true` marks a name secret. This syntax is
+distinct from the `${...}` env/header forms above.
+
+
+**[req-mf-023]** A conforming **consumer** implementation that renders
+a registry OCI/Docker MCP package to VS Code configuration MUST apply a
+resolved non-secret variable value to every `{name}` occurrence across
+the package's runtime and package arguments, including an occurrence
+whose argument does not repeat the variable metadata. Secret
+classification is package-scoped: once any entry declares a name with
+`isSecret: true`, the consumer MUST use the VS Code secret input
+reference for every occurrence of that name rather than write the
+resolved secret value into generated configuration bytes. The consumer
+MUST NOT write a literal unresolved `{name}` template to generated VS
+Code configuration; when a required runtime-argument variable cannot
+be resolved, it MUST emit a diagnostic and MAY decline that package's
+target configuration.
+
+### 4.6 Manifest extension surfaces
+
+#### 4.6.1 `policy` (consumer-side controls)
+
+The OPTIONAL `policy` block records consumer-side controls for
+governance integration (such as a pinned hash of the leaf
+`apm-policy.yml`). Its sub-fields are documented in the companion
+page and are enforced normatively in [Section 6](#6-policy-format-apm-policyyml).
+The manifest `policy:` block pins the **discovered** policy bytes
+by hash, defending against MITM or registry-side rewrite between
+discovery and evaluation; see [Section 6.1](#61-loading-and-discovery).
+
+
+**[req-mf-018]** A conforming **consumer** implementation MUST accept
+only `sha256`, `sha384`, or `sha512` as the value of
+`policy.hash_algorithm`. Values of `md5`, `sha1`, or any other
+algorithm MUST be rejected at parse time. When `policy.hash` is set
+and `policy.hash_algorithm` is omitted, the algorithm MUST be
+inferred from the `:` prefix of the digest value. The
+algorithm allow-list applies to the digest of the discovered policy
+bytes, not to a recursive hash of the manifest field itself.
+
+#### 4.6.2 Vendor extensions
+
+OpenAPM reserves the key prefix `x-` (lowercase ASCII) for
+vendor-defined extensions at every mapping level of every document
+this specification defines: manifest top-level, every nested
+mapping within `dependencies`, `devDependencies`, `registries`,
+`policy`, `compilation`, `marketplace`; lockfile top-level and
+per-entry mappings (see [Section 5.2](#52-per-entry-fields)); and
+policy top-level and every nested mapping (see
+[Section 6](#6-policy-format-apm-policyyml)).
+
+Key shape is `x-[a-z][a-z0-9-]*`. Vendors SHOULD further namespace
+their keys as `x--` (for example
+`x-acme-telemetry`) to avoid collision between independent vendors.
+
+[req-ext-001](#req-ext-001) and [req-ext-002](#req-ext-002) govern
+reader and writer behaviour for this namespace. Round-trip
+preservation is the load-bearing guarantee: a manifest authored
+with `x-acme-telemetry:` MUST survive a `read -> mutate -> write`
+cycle through any conforming consumer byte-equivalent, even if the
+consumer ascribes no semantics to the key.
+
+### 4.7 Marketplace authoring block (normative input)
+
+The OPTIONAL `marketplace` block declares the producer's marketplace
+authoring metadata. The on-disk shape of the `marketplace.json`
+artifact that a producer toolchain emits from this block is
+**outside the scope** of this specification; the input format defined
+here is normative.
+
+
+**[req-mf-017]** A conforming **producer** implementation MUST
+validate every `marketplace.packages[].source` value against the
+following rules and MUST reject any entry that fails them at parse
+time: (a) `..` path segments are refused; (b) URL forms with
+userinfo (`user@host`), ports, or query strings are refused;
+(c) URL schemes other than `https://` are refused for remote
+sources; (d) local sources MUST begin with `./`.
+
+### 4.8 Workspaces (reserved)
+
+Workspace / monorepo composition (shared lockfile across sibling
+packages, intra-workspace resolve-to-local, workspace publish) is
+**out of scope and reserved for a future revision; not activated by
+v0.2.0**. Local-path dependencies per
+[Section 4.3.5](#435-local-path-dependencies) do not imply workspace
+support. A future design may define a top-level `workspaces:` glob list,
+a shared root lockfile, and intra-workspace local resolution.
+
+
+**[req-mf-021]** In this revision, a conforming **producer** MUST NOT
+declare a top-level `workspaces:` key in `apm.yml`. A conforming
+**consumer** encountering a top-level `workspaces:` key in a
+manifest MUST emit a non-blocking diagnostic naming the key as
+reserved for a future revision and MUST NOT attach any semantics to its value.
+The diagnostic MUST NOT fail install.
+
+### 4.9 Conformance requirements (manifest)
+
+This section's normative statements are:
+
+- Producer: [req-mf-001](#req-mf-001), [req-mf-002](#req-mf-002),
+ [req-mf-003](#req-mf-003), [req-mf-005](#req-mf-005),
+ [req-mf-014](#req-mf-014), [req-mf-015](#req-mf-015),
+ [req-mf-017](#req-mf-017), [req-mf-021](#req-mf-021).
+- Producer (SHOULD): [req-mf-004](#req-mf-004).
+- Consumer: [req-mf-006](#req-mf-006), [req-mf-007](#req-mf-007),
+ [req-mf-008](#req-mf-008), [req-mf-009](#req-mf-009),
+ [req-mf-010](#req-mf-010), [req-mf-011](#req-mf-011),
+ [req-mf-012](#req-mf-012), [req-mf-013](#req-mf-013),
+ [req-mf-016](#req-mf-016), [req-mf-018](#req-mf-018),
+ [req-mf-019](#req-mf-019), [req-mf-020](#req-mf-020),
+ [req-mf-021](#req-mf-021), [req-mf-022](#req-mf-022),
+ [req-mf-023](#req-mf-023), [req-mf-024](#req-mf-024),
+ [req-ext-001](#req-ext-001),
+ [req-ext-002](#req-ext-002),
+ [req-tg-004](#req-tg-004), [req-sc-006](#req-sc-006).
+
+---
+
+## 5. Lockfile format (apm.lock.yaml)
+
+### 5.1 Top-level structure
+
+The project-scope lockfile is a single YAML 1.2 document at the project
+root, filename `apm.lock.yaml`. A user-scope lockfile, when supported,
+uses the user location declared under [req-tg-014](#req-tg-014). A
+lockfile records the pinned resolved state of every dependency the
+consumer has resolved from the manifest, plus the set of files the
+consumer itself contributes (the self-entry).
+
+
+**[req-lk-001]** A conforming **consumer** implementation MUST emit a
+lockfile whose top-level document node is a YAML 1.2 mapping with at
+minimum the keys `lockfile_version` (string) and `dependencies`
+(list). Additional top-level keys defined by this specification are
+`generated_at`, `apm_version`, `mcp_servers`, `mcp_configs`,
+`local_deployed_files`, `local_deployed_file_hashes`, and
+`attestations` (the last remains reserved per
+[Section 10.12](#1012-publisher-provenance-and-attestations-reserved)).
+Vendor-extension top-level keys (`x-*`) are permitted per
+[req-ext-001](#req-ext-001).
+
+Example (informative, minimal):
+
+```yaml
+lockfile_version: "1"
+apm_version: "0.6.4"
+dependencies:
+ - repo_url: github.com/octocat/example
+ resolved_commit: "7f3c9a4d2e1b8c7f0a9e6d5c4b3a2918f7e6d5c4"
+ resolved_ref: v1.2.0
+ tree_sha256: "sha256:a1b2c3d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff00"
+ depth: 1
+ deployed_files:
+ - .github/instructions/example.instructions.md
+```
+
+### 5.2 Per-entry fields
+
+Each element of `dependencies` describes one resolved package. The
+following fields are recognised by this specification; producers and
+consumers MUST emit only fields whose values are set and MUST preserve
+unknown fields on round-trip. Field availability is **monotonic** in
+`lockfile_version`: a field defined here is valid in both `"1"` and
+`"2"`. The `"v2 only"` annotation used in earlier drafts is removed
+(see [req-lk-002](#req-lk-002)).
+
+| Field | Notes |
+|---------------------------|---------------------------------------------------------------------------------|
+| `repo_url` | Canonical repo identity. REQUIRED for git-sourced entries. Cache isolation additionally follows [req-rs-016](#req-rs-016). |
+| `materialization_repo_url` | Optional source-cased repository identifier following the same host/owner/repo-path grammar as `repo_url`, used to reconstruct materialization and generated-link paths. See [req-lk-022](#req-lk-022). |
+| `host` | FQDN when not inferable from `repo_url`. |
+| `port` | Non-standard port. Validated to `1..65535` on read. |
+| `registry_prefix` | Path prefix when resolved via registry proxy. |
+| `resolved_ref` | User-supplied ref (branch, tag, SHA). |
+| `resolved_commit` | Exact 40-character lowercase hexadecimal commit SHA-1. |
+| `tree_sha256` | Hash envelope (`sha256:`) over the canonicalised git tree. See [req-lk-015](#req-lk-015). |
+| `version` | Registry entries carry the registry-resolved selector for reinstall; git/local entries MAY carry a dependency-`apm.yml`-derived inventory value per [req-lk-019](#req-lk-019). |
+| `virtual_path` | Subpath inside repo for virtual packages. |
+| `is_virtual` | Boolean. |
+| `depth` | Tree depth (0 = self, 1 = direct, >1 = transitive). |
+| `resolved_by` | `repo_url` of the parent that pulled this transitive dep. |
+| `package_type` | One of `apm_package`, `skill_bundle`, etc. |
+| `skill_subset` | Selected skill names for dependencies that expose selectable skills (see [Section 8.1](#81-primitive-types)). |
+| `deployed_files` | Project-relative paths the consumer wrote for this entry. |
+| `deployed_file_hashes` | `path -> :` for the files in `deployed_files`. |
+| `source` | `local` for path deps, `registry` for registry deps; absent for git. |
+| `resolved_url` | Registry archive download URL (advisory; see [req-rs-009](#req-rs-009)). |
+| `resolved_hash` | Hash envelope (`sha256:`) of the registry archive bytes. Trust anchor. |
+| `local_path` | Original path for local deps. |
+| `content_hash` | Hash envelope (`sha256:`) of a local package's source tree. |
+| `is_dev` | True when declared under `devDependencies`. |
+| `constraint` | git-semver discriminator: the original semver range from the manifest (verbatim). |
+| `resolved_tag` | git-semver selected tag, or advisory tag provenance for a full-SHA git-literal update under [req-rs-017](#req-rs-017). |
+| `resolved_at` | git-semver: ISO 8601 UTC timestamp; advisory (see [Section 7.3](#73-git-semver-resolution)). |
+| `name` | Self-asserted display/inventory name; non-identity (see [req-lk-019](#req-lk-019)). |
+| `attestations` | Reserved for a future revision (publisher provenance). |
+| `x-` | Vendor extension (per [req-ext-001](#req-ext-001)). |
+
+
+**[req-lk-003]** A conforming **consumer** implementation MUST record
+both `repo_url` and `resolved_commit` for every git-sourced
+dependency entry. For every registry-sourced dependency entry the
+consumer MUST instead record `resolved_url` and `resolved_hash`
+(in addition to `repo_url`, which carries package identity).
+When the manifest reference is a full 40-character commit SHA, a
+conformance audit MUST treat a different lockfile `resolved_commit` as
+a consistency failure.
+
+
+**[req-lk-011]** A conforming **consumer** implementation MUST omit
+fields whose values are unset (no `null` placeholders) and MUST
+preserve fields it does not recognise when round-tripping a lockfile.
+This includes vendor-extension keys per [req-ext-001](#req-ext-001).
+
+
+**[req-lk-012]** A conforming **consumer** implementation MUST
+compute `deployed_file_hashes` and `local_deployed_file_hashes` as
+SHA-256 hash envelopes (`sha256:`) over the *canonical
+content* of each deployed file. The canonical content is defined as
+follows: if the file's bytes decode as UTF-8 and contain no NUL
+(`0x00`) byte (a *text* file), the canonical content is those bytes
+with every `\r\n` sequence replaced by `\n` (a lone `\r` is left
+unchanged); otherwise (a *binary* file) the canonical content is the
+raw bytes as written to disk. Directory entries (paths ending in `/`)
+MUST NOT have a hash entry. The hash envelope `:` form
+defined by [req-lk-016](#req-lk-016) applies uniformly.
+
+The text-file line-ending normalization makes the recorded hash
+*platform-invariant*: a file that git materializes with `\r\n` on a
+`core.autocrlf=true` checkout and with `\n` on a POSIX checkout yields
+one identical hash, so the record side (install) and the verify side
+([req-lk-017](#req-lk-017), `apm audit`) agree across operating
+systems (apm#1952). Preserving a lone `\r` is deliberate: only the
+benign `\r\n` -> `\n` platform difference is made hash-invisible,
+while a bare carriage return -- which a terminal or parser may treat
+as an overwrite -- still changes the hash. This domain matches the
+drift-replay normalizer, so the two integrity surfaces agree on what
+constitutes a content change.
+
+
+**[req-lk-013]** A conforming **consumer** implementation MUST verify
+the `resolved_hash` against the actual SHA-256 of the registry
+archive bytes **before** extracting the archive to disk. On
+mismatch, the install MUST fail closed with a diagnostic naming the
+entry, the expected hash, and the actual hash, and MUST NOT extract
+or partially extract the archive.
+
+
+**[req-lk-014]** A conforming **consumer** implementation MUST
+preserve vendor-extension keys (`x-[a-z][a-z0-9-]*`) at every
+mapping level of the lockfile -- top-level and per-entry -- on
+round-trip. See [req-ext-001](#req-ext-001).
+
+
+**[req-lk-019]** A conforming **consumer** implementation MUST treat
+the optional `name` field, and any dependency-`apm.yml`-derived
+`version` value, as **self-asserted inventory metadata** only --
+recorded to support human-readable listing and audit reporting,
+never as a trust anchor. A consumer MUST preserve both fields on
+round-trip per [req-lk-011](#req-lk-011), and MUST NOT derive any
+identity or deduplication decision from them. For git/local entries,
+a dependency-`apm.yml`-derived `version` MUST NOT drive frozen
+replay; replay derives from `resolved_ref`, `resolved_commit`,
+`resolved_tag`/`constraint`, and the recorded hash envelopes (see
+[req-lk-003](#req-lk-003), [req-lk-008](#req-lk-008)). For registry
+entries, the registry-resolved `version` MAY remain the exact
+registry selection used for reinstall, but the integrity anchor is
+the recorded `resolved_hash` required by [req-lk-013](#req-lk-013).
+The presence of `name` or dependency-`apm.yml`-derived `version` is
+additive and MUST NOT change `lockfile_version` (both are valid in
+`"1"` and `"2"`).
+
+
+**[req-lk-022]** A conforming **consumer** implementation that
+case-folds any component of a repository identifier (authority or
+path) for identity comparison and retains a different source spelling
+MUST record that spelling in the optional
+`materialization_repo_url` field. The consumer MUST validate that
+`materialization_repo_url`, under the same host-specific repository
+normalization rule defined by [req-rs-016](#req-rs-016), identifies
+the same package as `repo_url`; a mismatch MUST fail closed. It MUST
+NOT use `materialization_repo_url` as an identity, deduplication,
+cache, sort, or trust key, and its presence MUST NOT change
+`lockfile_version`.
+
+> **Note:** When `materialization_repo_url` is absent the consumer
+> derives materialization paths from `repo_url` directly;
+> absence is not an error.
+
+When reconstructing a dependency, materializing it under
+`apm_modules/`, or generating a relative link back to that
+materialization, the consumer MUST prefer the retained source
+spelling. Case-folding applies only to repository-identity path
+components; an in-repository `virtual_path` and virtual-file leaf
+remain case-sensitive. If exactly one existing package path differs
+only in case-foldable repository components, the consumer MUST either
+migrate it transactionally to the retained spelling or fail without
+creating a duplicate. If multiple physical paths match one identity,
+the consumer MUST fail closed without deleting any candidate path.
+
+For this requirement, a transactional migration completes every
+case-only rename before the retained path is used, journals each
+completed rename, and restores the original spelling before returning
+from any caught failure. A consumer MAY use a temporary sibling path
+when its filesystem cannot apply a case-only rename directly; the
+temporary path MUST remain inside the materialization root and MUST
+NOT be treated as an installed package. This rollback contract does
+not claim process-crash atomicity; an interrupted temporary path is
+recovery state that a consumer MUST preserve for inspection rather
+than delete without verification.
+
+
+**[req-lk-020]** When an install that is not frozen under
+[req-lk-006](#req-lk-006) rewrites `deployed_files` or
+`local_deployed_files` and the manifest declares a `target` field, a
+conforming **consumer** implementation MUST preserve paths attributable
+to (a) the current install targets, (b) another declared target, or
+(c) an implementation-recognized target whose activation is outside
+the manifest target field. A path is attributable to a target when its
+top-level deploy root, Registry-documented filename pattern, or
+target-specific URI scheme identifies that target; shared deploy roots
+are partitioned by the filename patterns required by
+[req-tg-002](#req-tg-002). It MUST remove a prior path attributable to
+none of those targets. This reconciliation applies identically to
+per-entry `deployed_files` and top-level `local_deployed_files`, and the
+consumer MUST apply each preserve-or-remove decision to the
+corresponding `deployed_file_hashes` or `local_deployed_file_hashes`
+entry. If the manifest does not declare a `target` field, or the
+consumer cannot determine which target governs a prior path, the
+consumer MUST preserve that path and its corresponding hash entry
+rather than remove it solely because it was not written by the current
+install.
+During orphan cleanup, the consumer MUST preserve any path freshly
+deployed by an active dependency in the current install, even when the
+same path is also recorded by a prior lockfile entry under a different
+dependency identity.
+
+
+**[req-lk-021]** When a non-frozen install, compile, or update
+rewrites deployed state and the implementation maintains merge-based
+hook configuration (a shared,
+non-per-file configuration document for a target that supports the
+`hooks` primitive type, together with an ownership record identifying
+which entries the consumer itself wrote), a conforming **consumer**
+implementation MUST apply the same preserve-or-remove decision defined
+by [req-lk-020](#req-lk-020) to that merge-based hook configuration.
+For a consumer-owned entry attributable to a specific dependency,
+"current install targets" in clause (a) below means that dependency's
+effective intersection under
+[req-tg-008](#req-tg-008).
+It MUST remove only the consumer-owned entries -- and any ownership
+record left empty by that removal -- attributable to a target that is
+not attributable to (a) the current install targets, (b) another
+declared target, or (c) an implementation-recognized target whose
+activation is outside the manifest target field. It MUST preserve
+every entry that does not carry the consumer's own ownership
+attribution, regardless of target, and every consumer-owned entry for
+a target that remains attributable under (a)-(c).
+
+A well-formed ownership attribution to a foreign or unresolvable owner
+identity does not identify an entry as consumer-owned and MUST be
+preserved. If the manifest does
+not declare a `target` field, or the consumer cannot determine which
+target governs a prior entry, the consumer MUST preserve that entry
+and its ownership attribution, mirroring
+[req-lk-020](#req-lk-020)'s indeterminate case.
+If the merge-based hook configuration document is already absent for a
+target while its ownership record remains, a conforming consumer MUST
+still apply this requirement's preserve-or-remove decision to that
+orphaned ownership record: after verifying the record is well-formed,
+it MUST remove a record attributable to none of (a)-(c) above, and MUST
+preserve a record attributable to a target that remains attributable
+under (a)-(c). A consumer that encounters a merge-based hook
+configuration document or ownership record that is malformed or
+cannot be parsed MUST leave that document or record unmodified and
+emit an actionable diagnostic naming the affected path, rather than
+partially or silently repairing it. This requirement does not mandate
+how ownership is recorded (inline marker vs. a separate ownership
+record) or which merge-hook targets exist; it binds only the
+preserve-or-remove decision once ownership is determinate.
+
+
+**[req-lk-016]** A conforming **consumer** implementation MUST emit
+hash values as `:` envelopes (for example
+`sha256:abcd...`) in every position where this specification records
+a digest: `resolved_hash`, `deployed_file_hashes` (each value),
+`local_deployed_file_hashes` (each value), `content_hash`,
+`tree_sha256`, and any future hash field. The `` token MUST
+be one of `sha256`, `sha384`, or `sha512` per
+[req-mf-018](#req-mf-018). Readers MUST accept bare 64-character
+lowercase hexadecimal values as `sha256:` for
+backward-compatibility; writers MUST emit the explicit envelope
+form. This revision retains bare-hex reader tolerance and does not
+narrow the allowed envelope algorithms. Any removal of reader
+tolerance remains reserved for a future revision.
+
+
+**[req-lk-017]** A conforming **consumer** implementation
+executing a frozen install (see
+[req-lk-006](#req-lk-006)) MUST re-verify every entry in
+`deployed_file_hashes` and `local_deployed_file_hashes` against the
+on-disk content, hashed over the canonical domain defined by
+[req-lk-012](#req-lk-012), and MUST fail closed on mismatch. The
+diagnostic MUST name the offending path, the expected envelope, and
+the observed envelope. The same re-verification MUST run on `apm
+audit`.
+
+### 5.3 Self-entry semantics
+
+A project that ships its own primitives records the files it deploys
+under `local_deployed_files` and `local_deployed_file_hashes` at the
+top level. When the lockfile is loaded, consumers MAY synthesize an
+in-memory virtual dependency entry keyed by `"."` for uniform
+iteration over owned files. The synthesized entry MUST NOT be written
+back to YAML; the flat `local_deployed_*` fields are the on-disk
+source of truth.
+
+The synthesized entry, when present in memory, has:
+
+- `repo_url: `
+- `source: local`
+- `local_path: "."`
+- `depth: 0`
+- `is_dev: true`
+
+This isolation prevents the orphan-cleanup logic of one dependency
+from removing files attributed to another (see
+[Section 10.7](#107-unverified-content-cleanup-file-integrators)).
+
+### 5.4 Lockfile versions (1, 2) and bumping rules
+
+This specification defines two lockfile schema versions, `"1"` and
+`"2"`. Both are valid on-disk formats; `"2"` is a strict superset of
+`"1"`. Editorial reconciliation with the companion is consolidated
+in [Appendix E](#appendix-e-editorial-reconciliation-notes).
+
+
+**[req-lk-002]** A conforming **consumer** implementation MUST set
+`lockfile_version: "2"` when at least one entry in `dependencies`
+has `source: registry`. When no entry has `source: registry`, the
+consumer MAY emit either `"1"` or `"2"`. The version is
+**monotonic**: once a consumer writes `lockfile_version: "2"` to a
+given lockfile, subsequent rewrites of that lockfile by any
+conforming consumer MUST NOT demote the version to `"1"`, even if
+the registry-sourced entry is removed. A consumer SHOULD tolerate
+reading either `"1"` or `"2"` regardless of which version it
+prefers on write.
+
+
+**[req-lk-004]** A conforming **consumer** implementation MUST refuse
+to operate on a lockfile whose `lockfile_version` value is not one
+of the versions it recognises, with a diagnostic that explicitly
+offers the user a choice of either upgrading the consumer or
+regenerating the lockfile from the manifest.
+
+### 5.5 Drift and integrity model
+
+The lockfile supplies dependency identities and recorded deployment ownership
+for audit. Replay compares those records and the installed files against
+source-derived output under current target intent, not a historical target
+selection inferred from ownership.
+
+
+**[req-lk-023]** A conforming **consumer** that replays primitive integration
+to audit drift MUST select the current targets in this order: the applicable
+scope's validated manifest `target` / `targets` declaration, then the saved
+user target configuration, then the existing target-detection rules. A present
+invalid manifest declaration, or an invalid saved target when that fallback
+is selected, MUST produce a failing target-resolution result rather than fall
+through to a lower-precedence source.
+Explicit selection and detection are distinct: reading a saved target does
+not turn a filesystem signal into a registered detection predicate.
+
+The consumer MUST resolve the selected profiles in the live operation's
+scope and preserve their deployment layout and experimental or runtime
+prerequisites. Filesystem replay MUST rebase their destinations into isolated
+scratch roots; it MUST NOT carry live native or user roots into write
+operations. An unavailable selected target MUST produce a target-resolution
+failure, not a successful empty replay.
+
+Replay MUST derive expected paths and bytes from the resolved sources and
+current target intent, independently of deployment ownership records. It MUST
+NOT use those records to recover an earlier unsaved `install --target`
+override or to suppress an expected file whose ownership is missing. Changing
+current intent MUST NOT remove existing claimed files under a formerly
+selected target from drift comparison. For installed-file comparison, a
+directory claim covers its contained descendant files. A file-shaped claim,
+including one identified by a recorded hash, does not become a directory-prefix
+claim merely because its live path becomes a directory. Membership remains
+within the applicable deployment-root containment boundary. Claims widen
+installed-file comparison only, not expected output. An unavailable recorded
+native root MUST produce a failing comparison result rather than silently
+exclude its claims.
+
+Replay and comparison MUST NOT modify the live manifest, lockfile, saved
+configuration, or deployed bytes, including native databases and sidecars.
+If a selected native runtime has no isolated replay backend, the consumer MUST
+report an unsupported-replay failure before invoking its live writer.
+Successful target selection alone does not imply replay support. This does
+not authorize repair operations or weaken [req-sc-001](#req-sc-001)
+content-integrity checks or [req-pl-016](#req-pl-016) invalid-owner failures.
+
+**Local-content composition (informative).** Replay under
+[req-lk-023](#req-lk-023) derives expected output from content admitted under
+[req-mf-016](#req-mf-016) clause (d), then applies the
+[req-sc-015](#req-sc-015) source plan to that admitted content. An internal
+resource link correctly dereferenced into a regular file during acquisition
+is not obsolete merely because its original representation was a symlink.
+This does not authorize following symlinks in the target source plan or
+relaxing archive-link rejection.
+
+**Result and exit scope (informative).** A failing target-resolution,
+replay, or comparison result is not a successful empty replay, even when
+the command reports it advisory in default mode. This requirement does not
+add an unconditional nonzero exit for bare audit. Ordinary source-derived
+drift and incomplete or unsupported replay remain subject to
+[req-pl-014](#req-pl-014); CI/conformance audit gates failed aggregate
+checks, while a passed advisory cache-miss skip is not a failure.
+Independent hard integrity obligations remain in force, including
+[req-lk-003](#req-lk-003), [req-lk-017](#req-lk-017), and
+[req-pl-016](#req-pl-016). A separately requested repair is not replay
+and receives no mutation authorization from this requirement.
+
+
+**[req-lk-005]** A conforming **consumer** implementation MUST treat
+two lockfiles as semantically equivalent if they differ only in the
+presence or values of `generated_at` and `apm_version`. A no-op
+install operation MUST NOT rewrite a lockfile whose only changed
+fields would be these two. `generated_at` is optional, advisory
+metadata. Consumers MUST omit `generated_at` from newly created
+lockfiles unless explicit user or deployment configuration requests
+it. When an existing lockfile omits `generated_at`, a consumer MUST
+NOT reintroduce it solely as metadata during a later write unless
+that configuration opts in. Consumers operating in privacy-sensitive
+deployments SHOULD omit both provenance fields to avoid leaking tool
+version or build-time information. When a consumer writes a
+lockfile, the `dependencies` list MUST be
+ordered ascending lexicographically by the tuple (`repo_url`,
+`virtual_path`); entries without `virtual_path` sort as if
+`virtual_path` were the empty string. Two lockfiles differing
+only in entry order are semantically equivalent under this
+requirement, but a write-back MUST canonicalise to the pinned
+order so frozen-install diffs are stable across implementations.
+
+
+**[req-lk-006]** A conforming **consumer** implementation MUST
+support a frozen-install mode in which the lockfile is never written
+or rewritten. Before any lockfile, target configuration, deployment,
+or cache mutation (target configuration means on-disk state a
+target-deploy step may write per
+[Section 8.5](#85-deploy-directory-contract-normative); cache mutation
+means persistent resolver or materialiser state per
+[Section 7.2](#72-resolution-algorithm)), the install MUST fail when
+the lockfile is absent, when any direct package dependency has no pin,
+or when the manifest's direct MCP declarations differ from the
+lockfile's recorded MCP server names or configurations. Direct MCP
+declarations comprise entries under both `dependencies.mcp` and
+`devDependencies.mcp`. A mismatch exists when the set of declared MCP
+names differs from either the names in `mcp_servers` or the keys in
+`mcp_configs`, or when a shared name's derived configuration is not
+key-order-insensitive structurally equal to its `mcp_configs` value.
+Variable placeholders are compared as literal strings, not expanded.
+An operation whose effect would insert, remove, or modify a manifest
+dependency entry MUST be rejected in frozen mode before that
+modification takes effect. The frozen-install operation is opt-in in
+this revision via `--frozen` (or equivalent). A default of "frozen
+when a lockfile is present" remains reserved for a future revision
+(see
+[Section 9.2](#92-breaking-vs-non-breaking-change-definition)).
+
+
+**[req-lk-018]** A conforming **consumer** implementation SHOULD
+default to frozen-install behaviour when the `CI` environment
+variable is truthy (defined as: present and not the literal strings
+`""`, `"0"`, `"false"`, case-insensitive). The user MAY override
+the SHOULD-default via explicit non-frozen invocation. This
+SHOULD-on-CI rule does not activate the reserved general default
+change in [req-lk-006](#req-lk-006).
+
+
+**[req-lk-007]** A conforming **consumer** implementation SHOULD
+skip the download step when a local checkout already matches the
+locked commit. This optimisation MUST NOT change observable
+behaviour; the post-install workspace state MUST be identical to a
+fresh install.
+
+### 5.6 git-semver fields (constraint, resolved_tag, resolved_at)
+
+When the resolver picks a git tag from a semver range (see
+[Section 7.3](#73-git-semver-resolution)), it records three
+additional fields on the resolved entry. These fields are valid in
+both `lockfile_version: "1"` and `"2"` (see [req-lk-002](#req-lk-002)).
+The presence of `constraint` identifies the git-semver shape. A
+git-literal entry updated under [req-rs-017](#req-rs-017) has no
+`constraint`; its optional `resolved_tag` is advisory provenance and
+does not change replay or trust semantics.
+
+
+**[req-lk-008]** A conforming **consumer** implementation MUST
+record `constraint`, `resolved_tag`, and `resolved_at` on every
+git-semver lockfile entry. `constraint` MUST be the original semver
+range from the manifest (verbatim). `resolved_tag` MUST be the
+literal tag string the range resolved to. `resolved_at` MUST be an
+ISO 8601 UTC timestamp of the resolution event and is advisory; it
+MUST NOT be used as a tie-breaker in replay.
+
+
+**[req-lk-009]** A conforming **consumer** implementation MUST
+replay a previously locked git-semver resolution (reusing the
+locked `resolved_tag`) when the manifest's current semver constraint
+is **equal** to the locked `constraint`. A different manifest
+constraint MUST trigger re-resolution against the remote.
+
+
+**[req-lk-010]** A conforming **consumer** implementation MUST, when
+performing an explicit update operation against a direct git-semver
+dependency, purge the dependency's install path before re-resolving
+so that the download callback re-runs even when the resolved tag is
+unchanged. This guards against the regression where a cached
+install path masks a re-resolution event.
+
+#### 5.6.4 Git-source tree integrity hash
+
+`resolved_commit` is a SHA-1 identifier and serves as a stable
+content pointer in this revision, but SHA-1 alone is below the 2026
+collision-resistance floor and MUST NOT be relied on as the sole
+integrity anchor. To close the SHA-1 gap, every git-sourced lockfile
+entry carries a `tree_sha256` envelope.
+
+The **canonical git tree hash** for `tree_sha256` is the SHA-256
+over the following byte representation of the resolved tree:
+
+```
+ ::= SP SP LF
+ ::= * (entries sorted lexicographically by name)
+```
+
+`` is the four- or six-digit POSIX-style file mode
+(`100644`, `100755`, `120000`, `040000`); `` is the
+filesystem name; `` is the lowercase hexadecimal
+SHA-256 of the blob bytes. Subdirectories recurse: a subdirectory
+entry uses mode `040000` and its blob-sha256 is itself the SHA-256
+of the subdirectory's canonical tree representation. Lines are
+LF-terminated and UTF-8 encoded; entries are sorted byte-wise by
+name.
+
+
+**[req-lk-015]** A conforming **consumer** implementation MUST
+compute and record `tree_sha256` for every git-sourced lockfile
+entry. On a frozen install (see [req-lk-006](#req-lk-006)) and on
+`apm audit`, the consumer MUST re-compute `tree_sha256` from the
+working tree at `resolved_commit` and MUST fail closed when the
+recomputed value differs from the recorded value. The diagnostic
+MUST name the entry, the expected envelope, and the observed
+envelope.
+
+> **Retained interoperability limits (informative).** The canonical Git-tree
+> definition does not explicitly settle symlink blob bytes versus dereferenced
+> contents, gitlinks/submodules with mode `160000`, or raw Git blobs versus
+> CRLF/LFS-filtered checkout bytes. This corrective revision supplies no new
+> cross-platform canonical-tree evidence resolving those boundaries.
+> [req-lk-015](#req-lk-015) and the digest construction above remain unchanged;
+> these limitations are not exemptions. Trusted-local dereferencing semantics
+> are not imported into Git hashing. A canonical-byte clarification and
+> cross-platform fixtures require separately scoped work.
+
+> **Editorial note.** `resolved_commit` is a SHA-1 identifier. The
+> git project's SHA-1-to-SHA-256 object-format transition is
+> ongoing; until SHA-1 collisions are observed in the wild against
+> the git object format, `resolved_commit` is retained as the
+> canonical pointer, with `tree_sha256` providing collision-resistant
+> integrity. A future revision will track `resolved_commit_sha256`
+> once git's SHA-256 object-format is widely deployed.
+
+> **Editorial note.** Canonical-tree definition for local-path
+> `content_hash` remains reserved for a future revision; consumers MAY use
+> platform-native walk order but MUST document their choice in their
+> conformance statement.
+
+### 5.7 Conformance requirements (lockfile)
+
+This section's normative statements are:
+
+- Consumer: [req-lk-001](#req-lk-001), [req-lk-002](#req-lk-002),
+ [req-lk-003](#req-lk-003), [req-lk-004](#req-lk-004),
+ [req-lk-005](#req-lk-005), [req-lk-006](#req-lk-006),
+ [req-lk-008](#req-lk-008), [req-lk-009](#req-lk-009),
+ [req-lk-010](#req-lk-010), [req-lk-011](#req-lk-011),
+ [req-lk-012](#req-lk-012), [req-lk-013](#req-lk-013),
+ [req-lk-014](#req-lk-014), [req-lk-015](#req-lk-015),
+ [req-lk-016](#req-lk-016), [req-lk-017](#req-lk-017),
+ [req-lk-019](#req-lk-019), [req-lk-020](#req-lk-020),
+ [req-lk-021](#req-lk-021), [req-lk-022](#req-lk-022),
+ [req-lk-023](#req-lk-023).
+- Consumer (SHOULD): [req-lk-007](#req-lk-007),
+ [req-lk-018](#req-lk-018).
+
+---
+
+## 6. Policy format (apm-policy.yml)
+
+### 6.1 Loading and discovery
+
+A Governance implementation reads zero or one `apm-policy.yml` file
+per install operation.
+
+
+**[req-pl-001]** A conforming **governance** implementation MUST
+discover the active policy in the following priority order: (1) an
+explicit `--policy [` argument provided by the user; (2) any
+registered **discovery provider** invoked in the configured order
+(see [Section 6.1.1](#611-discovery-providers) and
+[req-pl-011](#req-pl-011)). No other discovery mechanism MAY be
+substituted for this order. When no provider yields a policy, no
+policy is applied.
+
+#### 6.1.1 Discovery providers
+
+This revision defines discovery as a **pluggable extension point**.
+A discovery provider is a named function that, given a project's
+remote git context, MAY return a policy reference (URL or local
+path). The reference initial provider registered by this
+specification is `github-owner-dotgithub`, which fetches
+`/.github/apm-policy.yml` from the same host as the
+project's remote when the remote host matches the consumer's
+implementation-default host. Additional providers (for example
+`gitlab-project-yml`, `local-fallback`) are registered in the
+non-normative **"OpenAPM Discovery Provider Registry v0.1"**
+companion document.
+
+
+**[req-pl-011]** A conforming **governance** implementation MUST
+expose discovery as a registered, ordered list of providers (the
+default order is implementation-defined and MUST be documented in
+the conformance statement). Providers MUST be selectable per
+project via the policy `discovery:` block. A consumer MUST NOT
+hard-code a host-specific discovery convention as the sole
+discovery path. The `github-owner-dotgithub` provider is the
+registered v0.1 default, NOT a specification-mandated discovery
+mechanism.
+
+
+**[req-pl-012]** A conforming **governance** implementation that
+needs to identify the "project's remote" for discovery MUST select
+the git remote named `origin` if present; if `origin` is absent but
+exactly one git remote exists, that remote MUST be used; if
+multiple non-`origin` remotes exist, discovery MUST fail closed
+with a diagnostic naming the candidates; if no remote exists, no
+discovery is attempted.
+
+#### 6.1.2 Interoperability note (informative)
+
+Because the default discovery host is implementation-defined (see
+[req-mf-019](#req-mf-019)), two conformant Consumers MAY yield
+different policy discoveries for the same project when they
+default to different hosts (for example, one defaults to
+`github.com`, another to `gitlab.com`). Projects wanting
+deterministic discovery across heterogeneous Consumers SHOULD (a)
+declare `default_host:` explicitly in `apm.yml`, and (b) select
+the discovery provider explicitly in `apm-policy.yml`'s
+`discovery:` block. No new normative statement is added by this
+note; it is interpretive guidance for the existing MUSTs in
+[Section 6.1.1](#611-discovery-providers).
+
+### 6.2 Enforcement modes
+
+A policy declares an `enforcement` value of `off`, `warn`, or `block`.
+
+| Mode | Behaviour |
+|---------|------------------------------------------------------------------------------|
+| `off` | Policy is reported but never gates an operation. |
+| `warn` | Violations print warnings, exit code remains 0. Default. |
+| `block` | Violations print errors and exit non-zero; install aborts before disk write. |
+
+When `fetch_failure` is unset, the effective value is `warn`. The
+default applies independently of the `enforcement` mode default and
+is the value a conforming governance implementation MUST use when
+the field is absent from the policy document.
+
+
+**[req-pl-002]** A conforming **governance** implementation MUST, when
+the effective `enforcement` value is `block` and at least one
+violation is detected, cause the install operation to abort
+**before** any byte is written to disk for the proposed install.
+
+
+**[req-pl-010]** A conforming **governance** implementation MUST,
+when the effective `fetch_failure` value is `block` and the policy
+cannot be fetched or parsed, abort the install operation with a
+fail-closed diagnostic. The same MUST hold when `fetch_failure` is
+`block` and a transitively `extends:`'d policy fails to fetch.
+
+### 6.3 Field reference
+
+#### 6.3.1 `dependencies`
+
+The `dependencies` policy block governs APM dependency declarations.
+
+| Field | Semantic |
+|------------------------|-------------------------------------------------------------------------------------------|
+| `allow` | List of patterns matched against the canonical host-blind dependency package path: repository coordinate plus any virtual path, with the `#` suffix excluded. Tri-state (see [Section 6.5](#65-allow-list--deny-list-tri-state-semantics)); case treatment per [req-pl-018](#req-pl-018). |
+| `deny` | Always wins over `allow`; case treatment per [req-pl-018](#req-pl-018). |
+| `require` | Exact packages every consumer manifest must include; case treatment per [req-pl-018](#req-pl-018). |
+| `require_resolution` | `project-wins` / `policy-wins` / `block` for required-package version conflicts. Default `project-wins` when unset. |
+| `max_depth` | Maximum transitive dependency depth. Default 50. |
+| `require_pinned_constraint` | When true, flags unbounded direct deps as violations. |
+
+
+**[req-pl-007]** A conforming **governance** implementation that
+honours `require_pinned_constraint: true` MUST flag, as a violation
+routed through the active `enforcement` value, every **direct** APM
+dependency whose constraint is one of: (a) no `ref` at all; (b) the
+literal `*`; (c) a bare branch name; (d) an unbounded lower-bound
+range such as `>=X.Y` without an upper bound. Transitive dependencies
+MUST NOT be flagged by this rule.
+
+
+**[req-pl-008]** A conforming **governance** implementation that
+honours `require_pinned_constraint: true` MUST classify the following
+as **pinned** (no violation): (a) a 40-character commit SHA; (b) a
+literal semver tag matching `v?\d+\.\d+\.\d+`; (c) a bounded semver
+range (containing an upper bound); (d) a dependency with
+`source: registry`; (e) a local-path dependency.
+
+
+**[req-pl-018]** A conforming **governance** implementation MUST apply
+the following dependency-policy identity rules:
+
+(a) The match subject is the canonical, host-blind dependency package
+path: its repository coordinate plus any virtual in-repository path,
+with any `#` reference suffix excluded. A registry name is never part
+of this match subject and is compared literally wherever it appears in
+policy operands. This
+statement governs glob matching for `dependencies.allow` and
+`dependencies.deny`, plus exact identity matching for
+`dependencies.require`. A `require` entry is exact: `*` characters
+are literal, and its package portion is the text before the first
+`#`. This statement changes case treatment only; it does not change
+which subject string is matched. Normalization under this statement
+applies at match time only. Policy-chain intersection, union, and
+deduplication under [Section 6.4](#64-inheritance-and-merge-rules)
+compare authored entries byte-exactly.
+
+(b) A Governance implementation MUST apply to policy operands the
+repository path case rule disclosed under
+[Section 11.2 item 6](#conformance-statement-case-rule). This is the same
+rule governed by [req-rs-016](#req-rs-016) clause (3) where the
+implementation also claims the Consumer class. A
+registry-sourced dependency, including one resolved through a
+registry prefix, has case-insensitive repository-coordinate segments;
+this source rule applies regardless of the case rule for its host.
+Local-path and marketplace dependencies, plus dependencies on every
+host not documented as case-insensitive under
+[Section 11.2](#112-how-to-claim-conformance), remain case-sensitive.
+The Governance implementation MUST NOT substitute a different case
+rule from the one disclosed for that host or registry source.
+
+(c) The dependency subject determines a repository-coordinate segment
+count N, before any virtual in-repository path. The same N bounds both
+operands: only the first N U+002F-separated segments of the subject and
+pattern are eligible for case-insensitive comparison. Normalization
+maps only U+0041 through U+005A to U+0061 through U+007A. Every other
+code point is compared literally; Unicode
+case folding, locale-sensitive mapping, and normalization forms are
+outside this revision per [Section 1.4](#14-terminology-preliminaries).
+For the avoidance of doubt, this statement never applies outside the
+fields named in clause (a): normalization MUST NOT extend into a
+virtual in-repository path, a reference suffix after `#`, a registry
+name, an MCP server name, or an unmanaged-file workspace path.
+
+(d) For a glob pattern, the eligible prefix from clause (c) is further
+truncated to the segments strictly before the first pattern segment
+that contains `**`. The effective case-insensitive prefix is therefore
+the lesser of N and that segment index; every remaining segment on
+both operands is compared byte-exactly. A fused `**` is a `**` token
+sharing a segment with one or more other characters, for example
+`Sec**`.
+
+(e) `dependencies.deny` MUST retain precedence over
+`dependencies.allow` after normalization. Where clause (b) or clause
+(d) requires byte-exact matching, a deny pattern that differs only in
+case does not match. Policy authors who intend to deny multiple
+distinct spellings on such a source or after such a truncation must
+enumerate those spellings. The residual security boundary is described
+in [Section 10.8](#108-policy-bypass-via-crafted-manifest).
+
+(f) A conforming governance implementation MUST evaluate every
+pattern-bearing policy field named in
+[Section 6.5](#65-allow-list--deny-list-tri-state-semantics) under that
+section's pattern grammar. It MUST NOT add character-class, brace, or
+escape expansion.
+
+#### 6.3.2 `mcp`
+
+The `mcp` block governs MCP server declarations, including
+transitive ones.
+
+#### 6.3.3 `compilation`
+
+The `compilation` block governs `apm compile` outputs. Sub-field
+semantics are documented in the companion `policy-schema.md` and
+are non-normative in this revision; the merge rules in
+[Section 6.4](#64-inheritance-and-merge-rules) reference only the
+field family `compilation.*`.
+
+#### 6.3.4 `manifest`
+
+The `manifest` block governs the shape of the consumer manifest
+itself (required fields, scripts allow/deny, explicit-includes
+requirement).
+
+#### 6.3.5 `unmanaged_files`
+
+The `unmanaged_files` block governs files in primitive target
+directories that are not recorded in `apm.lock.yaml`. `directories`
+names the managed primitive target trees to scan, `action` selects
+the response (`ignore` | `warn` | `deny`), and `exclude` is a glob
+allow-list of workspace paths to suppress from the report. Its glob
+patterns use the syntax in
+[Section 6.5](#65-allow-list--deny-list-tri-state-semantics), but
+workspace paths remain byte-exact and case-sensitive; dependency
+identity normalization under [req-pl-018](#req-pl-018) does not apply.
+
+
+**[req-pl-015]** A conforming **governance** implementation MUST,
+when it evaluates policy over a populated primitive target tree (for
+example during an audit), report unmanaged artifacts with the
+following completeness guarantees:
+
+(a) It MUST surface every file under a managed primitive target
+directory that is neither recorded in `apm.lock.yaml` nor matched by
+a configured `unmanaged_files.exclude` glob.
+
+(b) Each surfaced path MUST be reported with the reason it is
+unmanaged (that it is not tracked in `apm.lock.yaml`). Where the path
+also matches a configured dependency or MCP deny pattern, the report
+MUST additionally carry a supplemental conflict note naming that
+pattern; this note is enrichment only and never itself causes a
+tracked path to be surfaced. Where the primitive type is
+determinable, the surfaced entry MUST carry its
+inferred primitive type; where it is not determinable, the type
+annotation MUST be omitted.
+
+(c) A path matched by a configured `unmanaged_files.exclude` glob
+MUST NOT be surfaced, even when it also matches a deny pattern.
+
+This requirement governs the **completeness** of unmanaged-artifact
+reporting only: whether a surfaced artifact yields a non-passing
+audit result remains governed by `unmanaged_files.action` per the
+merge table in [Section 6.4](#64-inheritance-and-merge-rules), so
+req-pl-015 is not an enforcement claim.
+
+#### 6.3.6 `security`
+
+The `security` block declares opt-in supply-chain controls. Every
+key defaults to `false` (off), so a policy that omits the block
+behaves exactly as it did before these keys existed.
+
+- `security.integrity.require_hashes` (boolean, default `false`):
+ when `true`, the install operation fails closed if any resolved
+ non-local dependency selected for installation lacks a recorded
+ content hash (the `content_hash` lockfile field) in `apm.lock.yaml`.
+ Local dependencies are exempt (they are anchored by deployed-file
+ hashes, not a package digest).
+- `security.audit.fail_on_drift` (boolean, default `false`): when
+ `true`, a bare `apm audit` exits non-zero if lockfile drift is
+ detected or the drift check cannot complete.
+
+The normative behaviour for both keys is specified in
+[Section 6.8](#68-integrity-controls-governance). Both merge by
+logical OR across an `extends:` chain (see
+[Section 6.4](#64-inheritance-and-merge-rules)): once any ancestor
+sets a key to `true`, a descendant cannot relax it back to `false`.
+
+### 6.4 Inheritance and merge rules
+
+Policies form a chain via `extends:`.
+
+
+**[req-pl-003]** A conforming **governance** implementation MUST
+limit the `extends:` chain depth to **5** layers maximum and MUST
+reject cycles in the chain with a diagnostic naming the cycle
+members.
+
+
+**[req-pl-004]** A conforming **governance** implementation MUST
+pin an `extends:` reference to the host class (see
+[Section 10.3](#103-token-leakage-across-hosts)) of the **leaf**
+policy. A policy fetched from one host class MUST NOT extend a
+policy fetched from any other host class; cross-host-class
+`extends:` MUST be rejected at parse time.
+
+
+**[req-pl-006]** A conforming **governance** implementation MUST
+merge a policy chain per the following table:
+
+| Field family | Merge rule |
+|---------------------------------------|------------------------------------------------------------------------|
+| `enforcement` | Stricter wins (`block` > `warn` > `off`). |
+| `fetch_failure` | Child overrides if set. |
+| `*.allow` lists | Set intersection, byte-exact on each entry's UTF-8 text. `null` is transparent. |
+| `*.deny` lists | Union, deduplicated byte-exact, parent order preserved. |
+| `*.require` lists | Union, deduplicated byte-exact, parent order preserved. |
+| `dependencies.max_depth` | `min(parent, child)`. |
+| `dependencies.require_resolution` | Stricter wins (`block` > `policy-wins` > `project-wins`). |
+| `mcp.self_defined` | Stricter wins (`deny` > `warn` > `allow`). |
+| `mcp.trust_transitive` | Logical AND. |
+| `manifest.scripts` | Stricter wins (`deny` > `allow`). |
+| `unmanaged_files.action` | Stricter wins (`deny` > `warn` > `ignore`). |
+| `unmanaged_files.exclude` | Union, deduplicated (byte-exact on each pattern's UTF-8 string), parent order preserved (additive: a child adds exclusions and cannot clear a parent's; `null` and `[]` both preserve the parent list). |
+| `security.integrity.require_hashes` | Logical OR (once `true`, stays `true`). |
+| `security.audit.fail_on_drift` | Logical OR (once `true`, stays `true`). |
+
+These merge operations compare authored entries before dependency
+evaluation. Case-variant entries remain distinct during union and
+deduplication, and case-variant `allow` entries can intersect to an
+empty list. [req-pl-018](#req-pl-018) normalization applies only when
+the merged policy is matched against a dependency subject.
+
+### 6.5 Allow-list / deny-list tri-state semantics
+
+
+**[req-pl-005]** A conforming **governance** implementation MUST
+treat list-valued allow/deny/require fields as a three-state value:
+(a) field omitted (or explicit `null`) means "no opinion" and is
+transparent during merge; (b) explicit empty list `[]` means
+"explicitly empty" and overrides the parent for that field; (c)
+non-empty list `[...]` carries the listed entries and merges per the
+table in [Section 6.4](#64-inheritance-and-merge-rules).
+
+**Pattern grammar (normative through [req-pl-018](#req-pl-018)
+clause (f)).** Pattern-bearing `dependencies.allow`,
+`dependencies.deny`, MCP allow/deny, and `unmanaged_files.exclude`
+entries are matched against the whole subject and are anchored at
+both ends. `*` matches zero or more characters other than U+002F
+(`/`). `**` matches zero or more characters including U+002F,
+including when fused with other characters in one segment. Every
+other character is literal; there is no character-class, brace,
+or escape expansion. [req-pl-018](#req-pl-018) changes only case
+treatment for dependency identities and does not change this grammar.
+
+### 6.6 Forward compatibility
+
+
+**[req-pl-009]** A conforming **governance** implementation MUST
+emit a warning (never a parse error) when it encounters an unknown
+top-level key in `apm-policy.yml`. This guarantees newer policy
+files load on older clients without breaking the install.
+Vendor-extension keys matching `x-[a-z][a-z0-9-]*` are recognised
+per [req-ext-001](#req-ext-001) and MUST NOT produce a warning;
+they are preserved silently. See also
+[req-ext-001](#req-ext-001).
+
+### 6.7 Worked example (informative)
+
+A small policy chain:
+
+```yaml
+# .github/apm-policy.yml in contoso/.github
+name: contoso-baseline
+extends: contoso-enterprise/policy
+enforcement: block
+fetch_failure: block
+
+dependencies:
+ allow:
+ - contoso/*
+ - microsoft/apm-skills-*
+ deny:
+ - "*/legacy-*"
+ require:
+ - contoso/security-baseline
+ require_pinned_constraint: true
+ max_depth: 25
+
+manifest:
+ scripts: deny
+ required_fields:
+ - description
+ - license
+```
+
+### 6.8 Integrity controls (governance)
+
+The `security.integrity` and `security.audit` blocks declare opt-in,
+fail-closed controls. [req-pl-013](#req-pl-013) and
+[req-pl-014](#req-pl-014) are default-off; a policy that omits these
+two controls is unaffected by them. [req-pl-016](#req-pl-016) is an
+exception: it defines an unconditional integrity invariant that
+applies regardless of policy configuration, independent of any
+`security.audit` control.
+
+
+**[req-pl-013]** A conforming **governance** implementation that
+honours `security.integrity.require_hashes: true` MUST fail the
+install operation with a fail-closed diagnostic when any resolved
+non-local dependency selected for installation lacks a recorded
+content hash (the `content_hash` lockfile field) in `apm.lock.yaml`.
+The same fail-closed behaviour
+MUST hold when the lockfile is absent or unreadable at the point of
+the check. Local dependencies are exempt; they are anchored by
+deployed-file hashes rather than a package content hash.
+
+
+**[req-pl-014]** A conforming **governance** implementation that
+honours `security.audit.fail_on_drift: true` MUST cause the audit
+operation to terminate with a non-zero exit status when lockfile
+drift is detected, or when the drift check fails to complete (for
+example, an unreadable or corrupt local dependency graph). A drift
+check that is merely skipped for an advisory reason, such as a cache
+miss, does not by itself alter the exit status. When
+`security.audit.fail_on_drift` is absent or `false`, ordinary detected
+drift MUST be reported without, by itself, altering the default-mode
+audit exit status. This default-mode rule does not suppress failed
+CI/conformance checks or the independent hard integrity failure in
+[req-pl-016](#req-pl-016). A failed current-intent resolution, replay,
+or comparison under [req-lk-023](#req-lk-023) is an incomplete drift
+check, not the advisory passed skip described above.
+
+
+**[req-pl-016]** A conforming **governance** implementation MUST treat
+a canonical deployment-ledger owner (a dependency-identity reference
+recorded as an owner of a deployment row in `apm.lock.yaml`) that does
+not resolve to a dependency entry in `apm.lock.yaml` as a
+**hard integrity failure**, independent of the `security.audit.fail_on_drift` control. This
+failure is distinct from the ordinary deployed-file drift governed by
+[req-pl-014](#req-pl-014): an owner is a durable ownership record
+carried in the lockfile, not an edit to a deployed file, so a stale
+owner MUST surface even when `security.audit.fail_on_drift` is absent
+or `false`. When at least one such stale ownership record is present,
+an audit operation MUST terminate with a non-zero exit status in
+**both** its default and CI modes, and MUST NOT mutate any deployed
+byte (for example under a strip operation) while the ownership record
+remains invalid. When a deployment locator (the target-qualified
+identifier of a single tracked deployment row in the ledger) resolves
+to more than one owner and any one of those owners does not resolve in
+`apm.lock.yaml`, the hard-failure and mutation-block obligations apply
+to the entire audit operation, not only to the paths co-owned by the
+stale owner. The diagnostic MUST name each affected deployment locator
+together with its invalid owner reference(s), and MUST carry a single
+remediation directing the operator to reconcile ownership (prune the
+departed owners, then re-audit).
+
+
+**[req-pl-017]** A conforming **governance** implementation discovering
+an organization policy from Azure DevOps MUST request
+`apm/apm-policy` as its primary project and repository coordinate. It
+MAY use a fresh cache entry for that primary coordinate; primary and
+legacy cache entries MUST remain distinct. It MAY request or use the
+legacy `_apm/_apm` coordinate only when the primary request received
+an HTTP 404 response. It MUST NOT try that legacy coordinate after
+authentication, authorization, network, timeout, rate-limit,
+malformed-response, or other non-404 failures. When the legacy
+coordinate supplies a policy, the implementation MUST emit one
+actionable migration warning naming `apm/apm-policy`.
+
+### 6.9 Conformance requirements (governance)
+
+This section's normative statements are:
+
+- Governance: [req-pl-001](#req-pl-001), [req-pl-002](#req-pl-002),
+ [req-pl-003](#req-pl-003), [req-pl-004](#req-pl-004),
+ [req-pl-005](#req-pl-005), [req-pl-006](#req-pl-006),
+ [req-pl-007](#req-pl-007), [req-pl-008](#req-pl-008),
+ [req-pl-009](#req-pl-009), [req-pl-010](#req-pl-010),
+ [req-pl-011](#req-pl-011), [req-pl-012](#req-pl-012),
+ [req-pl-013](#req-pl-013), [req-pl-014](#req-pl-014),
+ [req-pl-015](#req-pl-015), [req-pl-016](#req-pl-016),
+ [req-pl-017](#req-pl-017), [req-pl-018](#req-pl-018).
+
+---
+
+## 7. Dependency resolution
+
+### 7.1 Reference kinds
+
+A consumer MUST classify every dependency declaration into exactly
+one of the following five reference kinds, evaluated in the order
+listed:
+
+1. **local** -- `local-path-form` strings or object entries lacking
+ a `git:` and `id:` key with a `path:` to the filesystem.
+2. **registry** -- object entries with `id:` and (implicit or
+ explicit) `registry:`.
+3. **git-semver** -- object or string entries whose `ref:` value
+ matches a semver-range pattern (for example `^1.2.0`, `~2.0`,
+ `>=1.0,<2.0`).
+4. **git-literal** -- git URL or shorthand with a literal `ref:`
+ (commit SHA, tag, branch).
+5. **marketplace** -- non-normative in this revision; producer-side
+ authoring artifact only.
+
+
+**[req-rs-008]** A conforming **consumer** implementation MUST
+classify every dependency by the priority above as a deterministic
+function of the entry alone (no remote calls, no implementation
+defaults). Two conforming consumers presented with the same entry
+MUST produce the same kind classification.
+
+### 7.2 Resolution algorithm
+
+
+**[req-rs-001]** A conforming **consumer** implementation MUST
+resolve dependencies by **breadth-first** traversal of the
+dependency tree, in the **declaration order** of each manifest.
+When the same package identity (`/` or registry
+identity) is reached via multiple constraint paths (a "diamond"),
+the consumer MUST apply the following tri-modal policy:
+
+1. **Intersection-pick (default).** If every reachable constraint
+ for the identity has a non-empty intersection, the consumer
+ MUST select the **highest** version satisfying every constraint
+ in the intersection. The selected version is recorded in the
+ lockfile; the chain that contributed the binding tightest
+ constraint is recorded as `resolved_by`.
+2. **Empty-intersection fail-closed.** If the intersection of
+ reachable constraints is empty, the install MUST fail with a
+ diagnostic naming both root-to-conflict chains. Silent
+ first-wins resolution MUST NOT be substituted.
+3. **Nest mode (reserved).** Multiple versions of the same identity
+ under distinct deploy paths and the corresponding on-disk layout
+ remain reserved for a future revision (see
+ [Section 4.8](#48-workspaces-reserved)). A conforming **consumer**
+ encountering `dependencies.conflict_resolution: nest` in a manifest
+ MUST refuse the install with a normative diagnostic naming the
+ key as reserved for a future revision and citing this section.
+
+
+**[req-rs-013]** A conforming **consumer** implementation MUST
+refuse to install a manifest declaring
+`dependencies.conflict_resolution: nest`, emitting a normative
+diagnostic that names the `conflict_resolution: nest` key as
+reserved for a future revision and cites
+[Section 7.2](#72-resolution-algorithm) clause (3).
+
+
+**[req-rs-010]** A conforming **consumer** implementation
+producing an empty-intersection diagnostic per
+[req-rs-001](#req-rs-001) clause (2) MUST format the diagnostic
+so that it lists, for each chain, the ordered sequence of
+`/@` entries from the root manifest to
+the conflicting entry, separated by `->`. Both chains MUST be
+named; the diagnostic MUST be deterministic for a given install
+plan.
+
+
+**[req-rs-016]** A conforming **consumer** implementation MUST
+preserve a **minimum safe repository identity** through dependency
+resolution, every in-memory or persistent cache layer, shared clone
+reuse, and materialisation. This identity is an implementation-private
+safety boundary, not a wire artifact. It consists of:
+
+1. the literal authority hostname, compared case-insensitively after
+ ASCII lowercasing and independently of the Host class or `aliases:`
+ equivalence used for credential scope;
+2. an explicit non-default port, where `:443` for HTTPS, `:22` for
+ SSH, `:80` for HTTP, and `:9418` for git transport are equivalent
+ to an absent port; and
+3. the complete repository path after first removing all trailing
+ U+002F (`/`) characters and then removing at most one trailing
+ literal `.git` suffix. Path comparison MUST be case-sensitive by
+ default. A dependency with `source: registry`, including one resolved
+ through a registry prefix, has case-insensitive
+ repository-coordinate segments regardless of host documentation;
+ the source rule takes precedence over the host rule. A consumer MUST
+ apply that registry rule at every cache layer. For every other source,
+ a consumer MAY case-fold paths for a host it documents as
+ case-insensitive in its conformance statement (see
+ [Section 11.2](#112-how-to-claim-conformance)) only when every cache
+ layer applies the same rule. The same source and host rules govern
+ policy operands under [req-pl-018](#req-pl-018); repository identity
+ and policy matching MUST NOT diverge. Before comparison, a consumer
+ MUST NOT percent-decode the path, collapse `.` or `..` segments, or
+ coalesce repeated internal slashes; traversal-bearing dependency
+ paths remain subject to parse-time rejection.
+
+Credential material in URL userinfo, query strings, and fragments MUST
+NOT contribute to repository identity; credential handling remains
+subject to [req-sc-007](#req-sc-007). An implementation MAY
+over-partition its private cache by non-credential transport context
+(for example scheme or SSH username), but MUST NOT omit any minimum
+identity component above. This cache identity is distinct from the
+manifest canonicalisation in [req-mf-009](#req-mf-009).
+
+Two dependency declarations whose minimum identities differ MUST NOT
+share cached source material solely because they use the same ref or
+have a common repository-path prefix. A consumer MAY reuse cached
+source material only when minimum identity and resolved commit are
+equal, or, before a commit is known within one resolution operation,
+when the literal ref tokens are character-equal. Identity and ref
+equality MUST NOT override a failed integrity check; the consumer MUST
+discard or re-fetch material that fails the applicable integrity
+obligations in [req-lk-013](#req-lk-013) and
+[req-lk-015](#req-lk-015).
+
+
+**[req-rs-006]** A conforming **consumer** implementation MUST stop
+transitive resolution at a configurable depth cap whose default value
+is **50**. The Governance class MAY tighten this cap via
+`policy.dependencies.max_depth` (see [Section 6.3.1](#631-dependencies)).
+Exceeding the cap MUST cause the install to fail with a diagnostic
+naming the chain at which the cap was reached.
+
+### 7.3 git-semver resolution
+
+
+**[req-rs-003]** A conforming **consumer** implementation MUST
+classify the `ref:` of any git dependency into one of three kinds:
+(a) `semver` -- the ref value parses as a semver range per
+[Section 7.3.1](#731-semver-dialect-normative); (b)
+`literal` -- the ref value is a commit SHA, a literal tag (matching
+`v?\d+\.\d+\.\d+` or a non-semver tag), or a branch name; (c)
+`none` -- the entry has no `ref:` at all.
+
+
+**[req-rs-002]** A conforming **consumer** implementation MUST,
+when resolving a git-semver dependency, list the remote git tags of
+the repository, dereference annotated tags to their peeled commit
+object (lightweight and annotated tags are treated equivalently
+thereafter), discard any tag whose name fails to parse under the
+semver dialect of [Section 7.3.1](#731-semver-dialect-normative)
+without diagnostic, filter the remainder to those matching the
+manifest's semver range under the same dialect, and pin the
+**highest** matching tag in the lockfile. Pre-release tags MUST be
+excluded from selection unless explicit opt-in is signalled per
+[Section 7.3.1](#731-semver-dialect-normative). The selected tag,
+the original constraint, and the resolution timestamp MUST be
+written per [req-lk-008](#req-lk-008).
+
+
+**[req-rs-007]** A conforming **consumer** implementation MUST
+evaluate every semver range expression in a manifest or lockfile
+under the **node-semver** dialect as pinned in
+[Section 7.3.1](#731-semver-dialect-normative). No
+implementation-defined hedging is permitted.
+
+#### 7.3.1 Semver dialect (normative)
+
+This revision pins the semver-range dialect to **node-semver**
+([https://github.com/npm/node-semver](https://github.com/npm/node-semver))
+as its normative reference, with version precedence and pre-release
+ordering inherited from **Semantic Versioning 2.0.0** Section 11
+([https://semver.org/spec/v2.0.0.html](https://semver.org/spec/v2.0.0.html)).
+The conformance oracle for this section is
+`tests/fixtures/spec-conformance/resolution/semver-dialect.json`
+(see [Section 12.4](#124-fixture-layout-informative)).
+
+**Range operators (normative).**
+
+| Operator | Semantics |
+|------------|--------------------------------------------------------------------------------------------------------|
+| `^x.y.z` | Compatible-with-X: matches `>= x.y.z, < (x+1).0.0` when `x > 0`; `>= 0.y.z, < 0.(y+1).0` when `x == 0` and `y > 0`; `>= 0.0.z, < 0.0.(z+1)` when `x == 0` and `y == 0`. |
+| `~x.y.z` | Approximately equivalent: `>= x.y.z, < x.(y+1).0`. `~x.y` (no patch) is equivalent to `>= x.y.0, < x.(y+1).0`. |
+| `>=`, `>`, `<=`, `<`, `=` | Comparator-form: standard inequality on semver precedence. |
+| `x.y.z`, `*` | Wildcard: any version (subject to pre-release exclusion). |
+| Range list (comma or whitespace) | Logical AND: `>=1.0.0, <2.0.0` matches versions satisfying both comparators. |
+| `\|\|` | Logical OR: `^1 \|\| ^2` matches versions satisfying either range list. |
+| `x.y.z - a.b.c` | Hyphen range: equivalent to `>= x.y.z, <= a.b.c`. |
+
+**Build metadata.** Build metadata (anything after `+` in a
+version) MUST be ignored for precedence comparisons per semver
+2.0.0 Section 10. Two versions differing only in build metadata
+compare as equal.
+
+
+**[req-rs-014]** When two candidate tags have equal precedence
+under semver 2.0.0 Section 11 (i.e. they differ only in
+build-metadata identifier), a conforming **consumer** MUST select
+the tag whose name compares highest under bytewise ASCII ordering
+of the full tag string. This rule eliminates non-determinism in
+build-metadata ties.
+
+**Pre-release ordering.** Pre-release ordering follows semver
+2.0.0 Section 11: numeric identifiers compare numerically, ASCII
+alphanumeric identifiers compare lexicographically in ASCII order,
+numeric identifiers always have lower precedence than alphanumeric
+identifiers, and a larger set of pre-release fields has higher
+precedence than a smaller set when all preceding fields are equal.
+
+**Pre-release opt-in (normative).** A pre-release tag (per semver
+2.0.0 Section 9) MAY be selected only when **at least one** of the
+following is true:
+
+1. The manifest range expression itself contains a pre-release
+ identifier on the same `[major, minor, patch]` tuple as the
+ candidate tag (node-semver "include-prerelease in same range"
+ semantics). For example, `>=1.2.0-alpha <1.3.0` permits
+ `1.2.0-beta` and `1.2.0`, but does NOT permit `1.3.0-alpha`.
+2. The manifest dependency entry declares `prerelease: true`
+ (explicit opt-in across the whole range).
+
+When neither (1) nor (2) holds, every candidate tag with a
+non-empty pre-release identifier MUST be discarded from the
+candidate set before highest-match selection.
+
+**`0.x` quirk.** Per semver 2.0.0 Section 4, anything `0.x.y` is
+considered unstable; the caret operator narrows accordingly as
+defined above (`^0.2.3` matches `>= 0.2.3, < 0.3.0`, NOT
+`>= 0.2.3, < 1.0.0`). This is the node-semver convention and is
+adopted normatively.
+
+**Determinism.** The selection function is a deterministic
+function of (range expression, candidate-tag set, opt-in
+signal). Two conforming consumers presented with the same inputs
+MUST select the same tag.
+
+### 7.4 Transitive resolution and conflict policy
+
+Conflicts at the **primitive** level (multiple sources providing a
+primitive with the same name) are governed by
+[Section 8.3](#83-priority-and-conflict-resolution). Conflicts at
+the **package** level (multiple resolution paths reaching the same
+package identity at different constraints) are governed by
+[req-rs-001](#req-rs-001)'s tri-modal policy.
+
+> **Design rationale (non-normative).** The tri-modal policy
+> replaces v0's silent first-wins behaviour. Empty-intersection
+> fail-closed is the correctness default: a consumer that
+> downgrades a dep silently to satisfy a transitive constraint
+> produces audit drift the workspace owner did not author. The
+> intersection-pick default is conservative; nest-mode remains reserved,
+> not an available escape hatch in this revision. A future revision may introduce
+> `policy.dependencies.resolver:` to let Governance pick the mode
+> centrally.
+
+A worked example showing primitive- and package-level conflicts
+composing:
+
+```yaml
+# Manifest:
+dependencies:
+ apm:
+ - acme/foo#^1.2.0 # direct: depth 1, constraint ^1.2.0
+ - acme/bar#^2.0.0 # direct: depth 1, constraint ^2.0.0
+# acme/bar transitively pulls acme/foo#^1.5.0 (depth 2).
+# Intersection of ^1.2.0 and ^1.5.0 is [>=1.5.0, <2.0.0]: pick highest
+# tag in [1.5.0, 2.0.0) per req-rs-001 clause (1).
+# If acme/bar instead pulled acme/foo#^2.0.0, intersection is empty
+# and install fails closed per req-rs-001 clause (2).
+```
+
+Lockfile fragment for the 3-chain intersection case
+(per [req-rs-010](#req-rs-010), `resolved_by` records the chain
+contributing the binding tightest constraint):
+
+```yaml
+# Three chains reach acme/foo:
+# root -> acme/foo#^1.2.0 (depth 1, lo=1.2.0)
+# root -> acme/bar#^2.0.0 -> acme/foo#^1.5.0 (depth 2, lo=1.5.0)
+# root -> acme/baz#^3.0.0 -> acme/qux#^1.0.0 -> acme/foo#~1.7.0
+# (depth 3, lo=1.7.0)
+# Intersection: [>=1.7.0, <2.0.0]. Pick highest tag in that range.
+# The tightest lower bound is contributed by acme/baz -> acme/qux,
+# so `resolved_by` records that chain.
+dependencies:
+ - repo_url: github.com/acme/foo
+ resolved_tag: v1.7.4
+ constraint: "~1.7.0"
+ resolved_by: "acme/baz#^3.0.0 -> acme/qux#^1.0.0 -> acme/foo#~1.7.0"
+ depth: 3
+```
+
+### 7.5 Lockfile replay semantics
+
+
+**[req-rs-004]** A conforming **consumer** implementation MUST treat
+a manifest entry whose `ref:` is a semver range as equivalent to its
+locked counterpart (no drift) when, and only when, the locked
+`constraint` value is character-equal to the manifest's current
+range. Any difference, including whitespace, MUST trigger
+re-resolution.
+
+
+**[req-rs-015]** A conforming **consumer** implementation performing a
+non-update install (that is, not an `apm update` and not an explicit
+`--refresh`/re-resolution invocation) MUST replay a lockfile entry
+that records a `resolved_commit` for a git-literal or untagged-branch
+entry per [req-rs-003](#req-rs-003) by reusing that recorded commit as the
+resolution result WITHOUT issuing a network ref-resolution -- no
+commits-API query, no `git ls-remote`, and no clone for ref discovery
+(illustrative, not exhaustive) -- for that entry, provided drift
+detection against the manifest reference does not require
+re-resolution. An advisory `resolved_tag` recorded under
+[req-rs-017](#req-rs-017) does not change this requirement's
+applicability. Object fetch to materialise content at the
+already-resolved commit is not constrained by this requirement. When
+the manifest reference for the entry has changed so that the recorded
+pin no longer matches (drift -- defined for entries scoped by this
+requirement as: the manifest `ref` value is not character-equal to the
+lockfile `resolved_ref` for that entry, or the entry has been removed
+from the manifest; semver-range drift is governed separately by
+[req-rs-004](#req-rs-004)), or under an explicit `apm update` /
+`--refresh` invocation ([req-rs-011](#req-rs-011),
+[req-rs-012](#req-rs-012)), the consumer MUST re-resolve the reference
+over the network as usual. The recorded `resolved_commit` is the
+lockfile's resolution anchor ([req-lk-003](#req-lk-003)); content
+integrity remains subject to `tree_sha256` ([req-lk-015](#req-lk-015))
+and `resolved_hash` ([req-lk-013](#req-lk-013)).
+
+> **NOTE (non-normative).** This requirement makes a warm install of an
+> already-locked reference network-free at the resolution step, which
+> is what permits reproducible and offline-capable resolution for
+> commit-pinned and branch-tracking entries not covered by the
+> semver-range equivalence of [req-rs-004](#req-rs-004).
+
+#### 7.5.1 Mirror resolution
+
+This revision anchors trust on the recorded `resolved_hash`, not on
+the recorded `resolved_url`. This permits enterprise mirrors,
+content-addressable proxies, and offline caches to substitute for
+the origin URL without lockfile churn.
+
+Registry-class implementations participating in mirror resolution
+MUST satisfy [req-rg-001](#req-rg-001) (Registry-class trust
+anchor).
+
+
+**[req-rs-009]** A conforming **consumer** implementation MUST
+permit the fetch of a registry-sourced dependency to be satisfied
+by **any** registry declared in the project's `apm.yml`
+`registries:` block, or by any policy-declared mirror, **provided
+that** the bytes returned by the mirror hash to the lockfile's
+recorded `resolved_hash`. The `resolved_url` field is advisory in
+this revision: a mismatch between the mirror URL and `resolved_url` MUST
+NOT fail the install when the hash matches. A hash mismatch MUST
+fail closed per [req-lk-013](#req-lk-013), regardless of which
+registry served the bytes.
+
+> **Editorial note (non-normative).** The mirror-tolerance
+> property of [req-rs-009](#req-rs-009) holds against the recorded
+> `resolved_hash`, not against bytes reconstructed from the
+> upstream source. Mirror operators MUST replicate the original
+> archive bytes verbatim; rebuilding the archive on the mirror
+> (even from the same source revision) will produce a different
+> `resolved_hash` and break the mirror-tolerance guarantee.
+> Reproducible-build determinism remains reserved for a future revision (see
+> [Section 1.1](#11-goals-and-non-goals) non-goals).
+
+### 7.6 Diagnostic surface (`deps why`)
+
+
+**[req-rs-005]** A conforming **consumer** implementation that
+exposes a "why is this dependency present" diagnostic command MUST
+compute the answer by walking the lockfile **bottom-up** from the
+target entry to the root, returning the set of root-to-target chains
+that include the target. Chains MUST be returned in lexicographic
+order of the root-to-target path tuple. The walker MUST operate
+offline against the lockfile alone, MUST be safe against cycles (no
+infinite recursion), and MUST produce deterministic output for a
+given lockfile.
+
+### 7.7 Update operation
+
+This revision defines the semantics of an explicit "update"
+operation so that two conforming consumers produce the same lockfile
+delta from the same inputs.
+
+
+**[req-rs-011]** A conforming **consumer** implementation that
+exposes an `apm update` (or equivalent) command MUST, when invoked
+without a package argument, re-resolve every direct dependency.
+Dependencies other than full-SHA git-literal entries MUST resolve
+against their **current** manifest constraint while leaving that
+constraint unchanged, and the consumer MUST rewrite their lockfile
+pins to the new highest matching version. A full-SHA git-literal entry
+follows [req-rs-017](#req-rs-017) when the consumer offers that update
+extension. The consumer MUST re-resolve all transitive dependencies as
+a side-effect and MUST honour the active Governance policy's
+`require_pinned_constraint` rule ([req-pl-007](#req-pl-007)).
+
+
+**[req-rs-012]** A conforming **consumer** implementation that
+exposes `apm update ` MUST scope re-resolution to the named
+package and its subtree only, MUST hold every other resolved entry
+at its prior pin, and MUST refuse to operate on a frozen install
+(see [req-lk-006](#req-lk-006)) without an explicit override
+flag. When the named package is a direct full-SHA git-literal entry
+and the consumer offers the [req-rs-017](#req-rs-017) extension, that
+entry follows req-rs-017; the consumer MUST NOT rewrite a transitive
+package manifest.
+
+
+**[req-rs-017]** A conforming **consumer** implementation that offers
+an update extension for a git-literal dependency pinned to a full
+hexadecimal commit ID MUST form one candidate set from annotated tags
+whose peeled object has been verified as a commit and whose parsed
+semantic version has an empty pre-release identifier under
+[Section 7.3.1](#731-semver-dialect-normative). A `0.x.y` version with
+an empty pre-release identifier remains eligible. The consumer MUST NOT
+select a branch or lightweight tag.
+
+A candidate tag name MUST use one of `v{version}`,
+`{name}--v{version}`, `{name}-v{version}`, or the bare `{version}`.
+For a repository dependency, `name` is the final repository path
+component after removing at most one trailing `.git`; for a selected
+virtual subdirectory, it is the final non-empty virtual-path component.
+The consumer MUST select the candidate with the highest version under
+Section 7.3.1 precedence. When candidates have equal precedence, it
+MUST apply the bytewise ASCII full-tag-string tie break in
+[req-rs-014](#req-rs-014), independently of remote record order. A
+winning candidate whose peeled commit equals the current pin is a
+no-op.
+
+When the authoritative upstream contains no eligible tag, the consumer
+MUST retain the current commit ID, emit a default-visible diagnostic,
+and continue resolving other dependencies in the requested update
+scope. A transport failure, an invalid or all-zero object ID, an invalid
+tag refname, a duplicate tag ref record, or a peeled tag record without
+its base record MUST fail the update before any manifest or lockfile
+write. An otherwise eligible annotated tag that peels to a tree, blob,
+or other non-commit object is also a fatal outcome, not an ignored
+candidate. These failures MUST NOT be converted into the retained-pin
+outcome.
+
+For a successful replacement, the consumer MUST rewrite only the
+direct root-manifest entry's `ref` to the selected peeled commit. The
+matching lockfile entry MUST record that same commit in `resolved_ref`
+and `resolved_commit`, and MUST record the selected tag in
+`resolved_tag` as advisory provenance. The tag does not change the
+entry's git-literal reference kind and does not become a trust anchor.
+A later non-update install MUST replay the full commit without remote
+tag enumeration under [req-rs-015](#req-rs-015). A retained-pin outcome
+MUST leave the direct manifest entry and its resolved lock values
+unchanged.
+
+A no-argument update applies this rule to every direct full-SHA
+git-literal entry. A package-scoped update applies it only to the named
+direct entry; ordinary subtree resolution continues, but transitive
+package manifests MUST NOT be rewritten. The consumer MUST finish
+candidate validation for every scoped direct entry before writing
+either the manifest or lockfile. A retained-pin outcome is entry-local
+and does not stop unrelated scoped updates. Any fatal outcome named
+above aborts the requested update scope and leaves both files
+unchanged.
+
+Range-widening update modes (for example `apm update --aggressive`,
+which would mutate the manifest's range upper bounds) are
+**reserved for a future revision; not activated by v0.2.0**.
+
+### 7.8 Producer release contract
+
+This revision defines a minimal producer release contract so that
+git-semver resolvers ([req-rs-002](#req-rs-002)) bind to the same
+artifact every consumer sees.
+
+
+**[req-pr-004]** A conforming **producer** publishing a git tag
+intended for consumption via git-semver MUST ensure that the tag
+points at a commit whose `apm.yml` `version` field is equal to the
+tag (modulo an OPTIONAL leading `v` prefix). For example, the tag
+`v2.3.1` MUST point at a commit whose `apm.yml` contains
+`version: "2.3.1"`. The tag name MUST match
+`^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-((0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(\.(0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(\+([0-9a-zA-Z-]+(\.[0-9a-zA-Z-]+)*))?$`
+(this matches the semver.org 2.0.0 regex modulo the optional
+leading `v`). A conforming **consumer** SHOULD verify this
+alignment after `git checkout` of the resolved tag and SHOULD emit
+a non-blocking diagnostic on mismatch.
+
+
+**[req-pr-005]** A conforming **producer** publishing release tags
+SHOULD sign tags via a publicly verifiable mechanism (for example
+sigstore, GPG, or SSH-signed git tags). Signature verification is
+not enforced by this revision; the SHOULD is advisory and feeds
+future provenance work (see
+[Section 10.12](#1012-publisher-provenance-and-attestations-reserved)).
+
+Release publication itself is **out of scope** for this revision's CLI
+surface. The canonical publication flow is whichever tag-and-release
+mechanism the producer's git host provides; on GitHub, that is
+`gh release create` or the `microsoft/apm-action mode: release`
+workflow. No `apm pack --create-tag` or `apm pack --push` surface
+is defined by this revision; producers MUST NOT depend on such a surface.
+
+### 7.9 Version withdrawal (reserved)
+
+Version withdrawal (yank, deprecate, supersede) for published
+versions is **out of scope and reserved for a future revision**.
+An informative future sketch includes `yanked: true` (not selected for
+fresh resolution, possibly retained for existing locks with a warning),
+`superseded_by: `, and Governance `refuse_yanked: block | warn | off`.
+None is activated here. Producers needing withdrawal semantics in
+this revision MUST rely on out-of-band advisories.
+
+### 7.10 Worked example (informative)
+
+A consumer with the manifest:
+
+```yaml
+name: web-app
+version: "1.0.0"
+default_host: github.com
+dependencies:
+ apm:
+ - contoso/security-baseline#^2.0
+ - git: https://gitlab.example.com/acme/coding-standards.git
+ ref: main
+```
+
+resolves to the lockfile:
+
+```yaml
+lockfile_version: "2"
+apm_version: "0.7.0"
+dependencies:
+ - repo_url: github.com/contoso/security-baseline
+ resolved_commit: "a1b2c3d4e5f6789012345678901234567890abcd"
+ resolved_ref: "^2.0"
+ constraint: "^2.0"
+ resolved_tag: v2.3.1
+ resolved_at: "2026-05-10T20:14:00+00:00"
+ tree_sha256: "sha256:0102030405060708091011121314151617181920212223242526272829303132"
+ depth: 1
+ - repo_url: gitlab.example.com/acme/coding-standards
+ resolved_commit: "f6e5d4c3b2a1098765432109876543210fedcba9"
+ resolved_ref: main
+ tree_sha256: "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd"
+ depth: 1
+```
+
+The example uses `lockfile_version: "2"` even though no
+registry-sourced entry is present; per [req-lk-002](#req-lk-002),
+this is permitted. Once written as `"2"`, this lockfile MUST NOT
+be demoted to `"1"` on subsequent rewrites.
+
+### 7.11 Conformance requirements (resolution)
+
+This section's normative statements are:
+
+- Consumer: [req-rs-001](#req-rs-001), [req-rs-002](#req-rs-002),
+ [req-rs-003](#req-rs-003), [req-rs-004](#req-rs-004),
+ [req-rs-005](#req-rs-005), [req-rs-006](#req-rs-006),
+ [req-rs-007](#req-rs-007), [req-rs-008](#req-rs-008),
+ [req-rs-009](#req-rs-009), [req-rs-010](#req-rs-010),
+ [req-rs-011](#req-rs-011), [req-rs-012](#req-rs-012),
+ [req-rs-013](#req-rs-013), [req-rs-014](#req-rs-014),
+ [req-rs-015](#req-rs-015), [req-rs-016](#req-rs-016),
+ [req-rs-017](#req-rs-017).
+- Producer: [req-pr-004](#req-pr-004).
+- Producer (SHOULD): [req-pr-005](#req-pr-005).
+
+---
+
+## 8. Primitive type system and target matrix
+
+### 8.1 Primitive types
+
+This specification recognises **seven** primitive types: `instructions`,
+`prompts`, `agents`, `skills`, `commands`, `hooks`, and `mcp`. A
+primitive is a typed unit of agent configuration sourced from one of
+the recognised package layouts:
+
+- **APM package** (`.apm/` directory). Primitives live under typed
+ subdirectories (`.apm/skills/`, `.apm/agents/`, ...) and are
+ hoisted individually into deploy directories.
+- **Skill bundle** (`SKILL.md` at root, optionally with `apm.yml` =
+ hybrid). The whole directory is copied to
+ `/skills//`.
+- **Skill collection** (`skills//SKILL.md` nested). Each
+ nested skill is promoted to `/skills//`.
+- **Plugin collection** (`plugin.json` / `.claude-plugin/`).
+ Artifacts are mapped into deploy directories per the plugin manifest.
+
+
+**[req-pr-006]** For a Plugin collection, a conforming **consumer**
+MUST resolve `plugin.json` `skills` as follows. Only an omitted
+`skills` key permits conventional `skills//SKILL.md` discovery.
+A present key MUST be a string or a list of strings and replaces that
+discovery: an explicit empty list contributes no skills. The plugin root
+is the collection root containing `.claude-plugin/`, not the manifest
+directory. Each declared path MUST be relative to that root; every
+traversed path component, derived child, and `SKILL.md` MUST remain
+within that root and MUST NOT be a symlink. A declared directory
+containing a regular `SKILL.md` is one skill named by its final path
+component; a declared directory without `SKILL.md` contributes only
+immediate child directories containing a regular `SKILL.md`, each named
+by its final path component. A missing, unreadable, malformed, escaping,
+or symlinked entry MUST contribute no skill, and a consumer MUST NOT
+fall back to undeclared root discovery. Consumers MUST canonicalize
+accepted paths relative to the plugin root: repeated spellings of one
+canonical source deduplicate to one leaf, while every contribution whose
+derived leaf name maps to more than one canonical source contributes no
+skill. Only the resulting names are eligible for enumeration, selection,
+or deployment.
+
+
+**[req-pr-007]** A conforming **consumer** implementation MUST, when
+copying a declared Plugin collection component source, canonicalize the
+non-symlink source root selected for that component. A consumer-generated
+staging subtree is the materialization root created by the current operation
+and every descendant of that root. The consumer MUST prune every such staging
+subtree before traversing the source.
+
+A package **exposes selectable skills** when layout resolution identifies a
+container for individually addressable named skill entries, whether that
+container currently yields zero or more entries. This includes an APM package
+with a `.apm/skills/` container, a skill collection, and a plugin collection
+with a named-skills container. A skill bundle with `SKILL.md` at its root
+deploys as a unit and does not expose selectable skills.
+
+### 8.2 Discovery and source tracking
+
+
+**[req-pr-001]** A conforming **consumer** implementation MUST
+attach a source attribution to every discovered primitive. The
+attribution MUST be `local` for primitives sourced from the
+project's own `.apm/` directory and MUST be of the form
+`dependency:` for primitives sourced from a resolved
+dependency.
+
+### 8.3 Priority and conflict resolution
+
+
+**[req-pr-002]** A conforming **consumer** implementation MUST cause
+local primitives to override dependency primitives of the same name
+and same primitive type. The conflict MUST be recorded in the
+consumer's diagnostic surface and MUST be inspectable by the user.
+
+
+**[req-pr-003]** A conforming **consumer** implementation MUST
+process dependencies in the order they are declared in the manifest
+(direct deps first, transitive deps appended in lockfile order).
+When two dependencies provide primitives with the same name and
+same type, the **first declared** dependency wins; later
+dependencies' versions MUST NOT replace the resolved primitive.
+
+### 8.4 Target detection signals (normative)
+
+Audit replay selects current intent under [req-lk-023](#req-lk-023)
+before using this section's detection fallback. The saved-configuration
+branch is selection, not auto-detection.
+
+When the user has not specified a target via `--target` or in the
+manifest's `target:` field, the consumer auto-detects from
+filesystem signals. The concrete table of per-target detection
+signals and deploy roots is published in the non-normative
+**"OpenAPM Target Registry v0.1"** companion document (see
+[targets-matrix.md](../../reference/targets-matrix/)). Vendors MAY
+register new targets without a spec amendment via the
+`x--` extension namespace (see
+[req-tg-004](#req-tg-004)).
+
+
+**[req-tg-001]** A conforming **consumer** implementation MUST
+honour the per-target detection predicate published in the
+registered OpenAPM Target Registry for every spec-registered target
+identifier and for every vendor-registered identifier
+([req-tg-004](#req-tg-004)). Auto-detection MUST activate a target
+**only** when its registered predicate fires; no other filesystem
+signal MAY substitute for, or augment, the registered predicate.
+A target registered without a detection predicate
+MUST NOT be auto-detected and MUST be excluded from the expansion of
+`all`; such an **explicit-only** target MUST be selected explicitly
+via `--target `, via the manifest's `target:` field, or, for audit replay,
+via a valid saved user target configuration selected under
+[req-lk-023](#req-lk-023). That saved audit selection constitutes explicit
+selection, not auto-detection. In this revision
+the explicit-only targets are `agent-skills` and `antigravity`. When
+no detection signal fires, the consumer MAY fall back to a `minimal`
+profile that emits `AGENTS.md` only.
+
+### 8.5 Deploy directory contract (normative)
+
+This revision establishes `.agents/` as an **ecosystem convention**:
+the cross-tool deploy root for primitives shared between targets
+that opt into convergence. Per-target deploy roots are published in
+the non-normative OpenAPM Target Registry v0.1 companion.
+
+
+**[req-tg-002]** A conforming **consumer** implementation MUST
+deploy primitives only under the deploy root(s) registered for the
+active target in the OpenAPM Target Registry. No target's
+installer MAY write files outside its registered root(s); writing
+outside the registered root MUST be treated as an implementation
+defect, not a runtime warning. When two targets register the same
+deploy root (for example two targets that both share `.agents/`),
+each target OWNS only the file-name patterns documented for that
+target in the Registry; `.agents/` is partitioned by subdirectory
+(`.agents/skills/`, `.agents/commands/`, `.agents/prompts/`,
+`.agents/rules/`, ...)
+so that distinct targets do not contend for the same on-disk
+patterns.
+
+
+**[req-tg-003]** A conforming **consumer** implementation MUST
+deploy skills to `.agents/skills//SKILL.md` for every target
+that supports the `skills` primitive type, unless the user has
+explicitly opted out of skill-convergence via the documented
+opt-out switch. This cross-tool convergence ensures a single skill
+bundle serves every harness without per-target duplication.
+
+
+**[req-tg-005]** A conforming **consumer** implementation that deploys
+target-native per-file instruction rules MUST honour the registered
+rule filename pattern and frontmatter mapping for the active target.
+For the `antigravity` target, instruction rules MUST be written under
+`.agents/rules/.md`; when the source instruction declares
+`applyTo`, the emitted frontmatter MUST represent it as
+`trigger: glob` plus a `globs` field. To reduce sources of divergence
+in deployed-file content hashes across conforming implementations
+(contributing to the canonical-content equivalence check of
+[req-lk-012](#req-lk-012)), `globs` MUST be
+emitted as a YAML scalar when `applyTo` resolves to exactly one glob
+pattern and as a YAML block sequence when it resolves to two or more
+patterns; when `applyTo` is absent or empty the emitted rule file MUST
+NOT carry a frontmatter block. Compile-time deduplication MUST treat
+only those files whose names derive from the currently-resolved
+instruction primitives (specifically, for each resolved instruction
+primitive of name N the derived filename is `.agents/rules/N.md`; the
+authoritative set is the lockfile `deployed_files` list when a
+lockfile is present, falling back to manifest instruction entries on
+first install) as deployed rules; any other `.md` file under
+`.agents/rules/` MUST NOT be treated as a deployed rule and MUST NOT
+suppress instruction content in `AGENTS.md`.
+
+> **Editorial note.** [req-tg-005](#req-tg-005) names the `antigravity` deploy path
+> and frontmatter keys in the normative text rather than delegating
+> them to the non-normative Target Registry companion (contrast
+> [req-tg-002](#req-tg-002)). This is a deliberate, scoped exception:
+> the `antigravity` rules directory shares the `.agents/` root that
+> [req-tg-002](#req-tg-002) partitions, so the deduplication and
+> non-suppression guarantees above require normative precision about
+> the filename derivation. A future revision MAY relocate the concrete
+> `antigravity` filename pattern and frontmatter mapping to the
+> Registry companion once a cross-target instruction-rule schema is
+> registered, retaining only the target-agnostic honour and dedup
+> MUSTs here.
+
+#### 8.5.1 Lossy agent conversion
+
+
+**[req-tg-006]** A conforming **consumer** implementation that converts
+an agent primitive into a target-native format MUST either preserve each
+[source-declared capability restriction](#3-terminology) semantically or
+emit an actionable diagnostic. Semantic preservation requires the same
+effective capability ceiling (the maximal set of tools, actions, or resources
+the restriction permits); a translation that widens, narrows, or cannot
+represent the restriction exactly is non-preserving. The diagnostic MUST
+identify the source agent and each discarded field and MUST state that exact
+preservation failed. For a widening or an unrepresentable restriction, it
+MUST state that the generated agent may have broader capability access; for a
+verified narrowing, it MUST state that access is narrower than declared. When
+several fields are discarded, one diagnostic enumerating all of them or
+separate diagnostics for each field MAY be used. The diagnostic MUST appear
+in the consumer's default (non-verbose) output and MUST be rendered before
+the overall operation returns; this requirement does not mandate a nonzero
+exit status. If source agent frontmatter cannot be parsed as a mapping, the
+consumer MUST instead emit a default-visible diagnostic stating that
+capability restrictions could not be verified before the overall operation
+returns.
+
+> **Editorial note.** Concrete target-native encodings for capability
+> restrictions are intentionally unspecified in this revision. A future revision
+> may register them through the Target Registry companion or the amendment
+> process in [Section 9.3](#93-amendment-process) without weakening the
+> preservation-or-diagnostic contract above.
+
+
+**[req-tg-009]** A conforming **consumer** implementation that deploys an
+agent primitive into a target-native format with a fixed, enumerable
+capability vocabulary MUST fail closed: if any source-declared tool falls
+outside the target's approved capability set, the implementation MUST NOT
+write the agent's target artifact (zero bytes, no partial file) and MUST
+emit an actionable diagnostic identifying the unsupported tool value(s) and
+the approved set. This fail-closed evaluation MUST be performed prior to any
+content-identity adoption fast-path; an existing on-disk artifact whose bytes
+match the source MUST NOT cause an agent with unrepresentable capabilities to
+be adopted or retained in the deployed-files record. This gate is evaluated
+per agent primitive independently; failure for one agent MUST NOT prevent
+deployment of other, vocabulary-conformant agent primitives from the same
+dependency or install operation. This evaluation applies only to agents whose
+target is included in the effective intersection computed under
+[req-tg-008](#req-tg-008); agents whose target is already excluded by that
+intersection are not subject to this gate.
+
+> **Editorial note.** The approved capability set for each target is the
+> vocabulary enumerated in the OpenAPM Target Registry companion entry for
+> that target at the spec version the consumer declares conformance to. A
+> conformance test suite MUST pin the exact companion version it validates
+> against. A future revision may promote this pinning to a standalone
+> normative requirement and define a machine-readable vocabulary schema;
+> until then, conformance testing is scoped to the sets published in the
+> companion.
+
+#### 8.5.2 Post-install compilation guidance
+
+
+**[req-tg-007]** A conforming **consumer** implementation that completes a
+non-dry-run, project-scope install MUST emit a default-visible, actionable
+diagnostic before returning when all of the following are true: (a) at least
+one package was installed during this operation; (b) the full installed
+dependency tree, including packages installed during earlier operations,
+contains an instruction primitive; and (c) at least one active target is
+classified as requiring post-install root-context compilation in the companion
+[target support matrix](../../reference/targets-matrix/#post-install-instruction-compilation).
+The diagnostic MUST name the follow-up compilation operation (for example,
+`apm compile` or an equivalent) and only the applicable root context output
+classes (for example, `AGENTS.md`, `CLAUDE.md`, or `GEMINI.md`) for the active
+targets. The consumer MUST NOT emit this diagnostic for a dry run, an
+install that installed no package, an installed dependency tree without
+instruction primitives, or a target set with no active target classified as
+requiring post-install root-context compilation. An unclassified target MUST
+NOT trigger the diagnostic by itself.
+
+> **Editorial note.** The presence check covers the consumer's complete
+> installed dependency store because a later install can make instructions
+> from an earlier dependency newly relevant to an active target. The
+> requirement does not prescribe a lockfile field for this check:
+> compile-only instruction sources are not necessarily deployed outputs.
+
+#### 8.5.3 Package-declared target restrictions
+
+
+**[req-tg-008]** For each dependency, a conforming **consumer**
+implementation MUST integrate target-scoped primitives only into the
+intersection of (a) the project's currently active targets, (b) the
+target subset authorized by the consumer for that dependency, and (c)
+the dependency package's declared `target:` or `targets:` set when that
+set is restrictive. This computed set is the dependency's **effective target
+intersection**. The mechanism for (b) is implementation-defined;
+when the consumer has no explicit per-dependency authorization
+mechanism or subset, (b) adds no restriction.
+
+The package set is restriction-only: it MUST NOT activate a target or
+expand either (a) or (b). Omitting both package fields, or including the
+universal `all` value, adds no package-side restriction. Scalar and list
+spellings under `target:` accept the aliases defined in
+[Section 4.2.1](#421-target); `targets:` accepts lowercase identifiers
+from the canonical set in Section 4.2.1 and the literal `all` sentinel.
+The `all` token remains a literal no-restriction sentinel and MUST NOT
+be expanded to the auto-detectable target set during this intersection.
+A null value under singular `target:` is treated as field omission for
+legacy compatibility; a consumer MUST reject an empty string or empty
+list. A declaration with both fields (even when either value is null),
+a null or empty `targets:` value, a `targets:` token that is neither
+canonical nor `all`, or a `target:` token that does not satisfy
+[req-mf-005](#req-mf-005) MUST be rejected before target-scoped
+deployment with a diagnostic naming the invalid declaration or token.
+
+When no explicit package field exists, a consumer MAY infer an
+additional legacy hook-only restriction from the final path component.
+The consumer strips the final extension, lowercases the ASCII stem, and
+matches either `hooks-` or a contiguous rightmost sequence of
+hyphen-delimited tokens immediately before `-hooks`. It resolves that
+sequence from right to left against its registered filename-target token
+table; unmatched segments terminate the sequence. This filename filter
+is applied after the effective intersection and MUST only narrow it. A
+consumer MUST NOT infer a package restriction from a generic or otherwise
+unmatched filename. Producers SHOULD migrate to explicit `target:` or
+`targets:` declarations.
+When an update narrows the intersection, consumer-owned merge-based
+hook entries and their ownership record MUST be reconciled under
+[req-lk-021](#req-lk-021), while entries without the consumer's own
+ownership attribution remain preserved.
+
+#### 8.5.4 Project-scoped native hook execution
+
+
+**[req-tg-010]** A conforming **consumer** implementation that deploys a
+project-scoped hook into a target-native configuration whose hook command may
+be launched with a working directory outside the consumer project MUST anchor
+the generated command to the consumer project through that target's portable
+project-directory environment variable. The command MUST execute successfully
+when that variable identifies the consumer project, MUST preserve the hook's
+relative path beneath that project, and MUST NOT embed an absolute consumer
+checkout path. For Claude project hooks, such a consumer MUST reject a
+hook path containing a dollar sign or backtick, because either character can
+cause the target shell to reinterpret a path component.
+
+> **Editorial note.** For Claude project hooks, the portable variable is
+> `CLAUDE_PROJECT_DIR` in POSIX commands and `$env:CLAUDE_PROJECT_DIR` in
+> PowerShell commands. This requirement permits target-specific command syntax;
+> it does not prescribe a shell for other targets.
+
+#### 8.5.5 Agent Plugins v1 native-lifecycle deployment boundary
+
+
+**[req-tg-011]** A conforming **consumer** implementation MUST treat a
+schema-bearing Agent Plugins v1 dependency as opaque at the deployment
+boundary unless an applicable target-native lifecycle admits it. Dependency
+acquisition, materialization beneath the resolved dependency root, and lock
+identity recording MAY occur before that boundary. When the effective target
+intersection computed under [req-tg-008](#req-tg-008) does not select an
+applicable native lifecycle, the consumer MUST skip deployment of that
+dependency and emit one actionable diagnostic, MUST NOT run a legacy primitive
+integrator for it, and MUST NOT create target-native registration, settings,
+catalog, or ownership state for it. The materialized package and lock state MAY
+remain. This skip is
+dependency-scoped: ordinary dependencies in the same install MUST remain
+eligible for their normal deployment, and a `--dry-run` invocation MUST report
+the same per-dependency deployment decision without mutating state.
+If an earlier operation registered the dependency but the current effective
+target intersection no longer selects that native lifecycle, the consumer MUST
+retire the dependency's consumer-owned catalog, activation, and ownership
+entries through the rollback unit in [req-tg-013](#req-tg-013).
+
+> **Editorial note.** This requirement governs the deployment boundary
+> only. A machine-verifiable consumer lifecycle now exists:
+> [req-tg-013](#req-tg-013) defines native registration for a consumer
+> that exposes one. "Applicable" is determined from successful Agent Plugins
+> schema parsing, the effective target intersection in
+> [req-tg-008](#req-tg-008), and the admission gates referenced by
+> [req-tg-013](#req-tg-013), not from whether a host binary happens to be
+> installed. This requirement still does not itself define that lifecycle, and
+> it does not mandate any preferred-default change.
+
+#### 8.5.6 Plugin-root hook command resolution
+
+
+**[req-tg-012]** When a conforming **consumer** implementation resolves an
+implementation-defined plugin-root placeholder in a hook command, it MUST treat
+a placeholder enclosed in matching quotation marks and followed by a forward
+slash (`/`) or backslash (`\`) path separator outside the closing quote as
+equivalent to the placeholder and path enclosed together in one double-quoted
+span, regardless of the source quote character. The generated command MUST
+preserve balanced quoting, and any environment-variable expression retained in
+that command MUST remain live for target expansion. If any
+implementation-defined plugin-root placeholder remains unresolved, the consumer
+MUST emit a default-visible diagnostic before the operation returns; it MUST
+NOT silently deploy the unresolved command.
+
+> **Editorial note.** A plugin-root placeholder has the form `${NAME}`; each
+> consumer documents the fixed set of names it recognizes for its targets.
+> `${PLUGIN_ROOT}` is an illustrative spelling. For example,
+> `"${PLUGIN_ROOT}"/hooks/probe.py` normalizes to
+> `"${PLUGIN_ROOT}/hooks/probe.py"`.
+
+#### 8.5.7 Agent Plugins v1 native-lifecycle registration
+
+
+**[req-tg-013]** A conforming **consumer** implementation that exposes a
+machine-verifiable native lifecycle for a schema-bearing Agent Plugins v1
+dependency MAY register that dependency with a target-native plugin host when
+the acquired and materialized dependency has passed Agent Plugins schema
+parsing, the effective target intersection computed under
+[req-tg-008](#req-tg-008) selects that host, and the applicable lock-integrity
+obligations ([req-lk-013](#req-lk-013), [req-lk-015](#req-lk-015)),
+authorized source-plan scan ([req-sc-015](#req-sc-015)), and executable
+authorization ([req-sc-009](#req-sc-009), [req-sc-010](#req-sc-010),
+[req-sc-011](#req-sc-011), and [req-sc-014](#req-sc-014)) admit the
+package. Admission during install,
+update, restore, uninstall, and prune MUST NOT locate, invoke, or version-check
+the target-native host binary. When the dependency is registered, the consumer
+MUST NOT additionally project that package's primitives, so a single dependency
+is deployed exactly once. The consumer MUST NOT copy the package into the
+target's private plugin state; the registration MUST reference the materialized
+package in place beneath the resolved dependency root. A single aggregate
+registration per install scope MUST cover both directly declared and
+transitively resolved Agent Plugin dependencies that passed the admission
+conditions above, without changing their
+`depth` and `resolved_by` lockfile fields defined in
+[Section 5.2](#52-per-entry-fields).
+
+A consumer MUST exclude from plugin-name claimant selection every Agent Plugin
+dependency that did not pass the admission conditions above. It MUST NOT
+register such a dependency. Among admitted dependencies that declare the same
+plugin name, a directly declared dependency MUST win over a transitive
+dependency. Two admitted claimants at the same precedence MUST cause the
+aggregate registration to fail with an actionable diagnostic naming both
+claimants. A consumer MUST NOT silently repoint a ledger-recorded owner to a
+different admitted transitive claimant. An admitted directly declared claimant
+MAY replace a recorded transitive owner under the direct-precedence rule, but
+the ownership record and registration MUST update in the same rollback unit.
+A post-lifecycle reconciliation after uninstall, prune, or restore MAY
+downgrade a residual same-precedence or transitive-repoint collision to an
+actionable diagnostic so cleanup can continue, but it MUST omit every ambiguous
+or changed-owner entry and MUST NOT commit a new owner for that plugin name.
+
+The consumer MUST record registration ownership in consumer-owned state. A
+consumer that reserves a marketplace identifier and activation-key suffix for
+its generated registration MAY treat that namespace as consumer-owned only
+while its exact expected directory-marketplace entry is present or being
+created. It MUST refuse an existing conflicting entry that uses the reserved
+marketplace identifier before treating the activation-key suffix as owned. Its
+ownership record is the primary evidence for later reconciliation and removal.
+If that record is missing, the consumer MAY re-adopt an existing entry only
+when it is exactly the directory-marketplace entry the consumer would generate.
+It MAY then reconcile the reserved activation-key suffix from the aggregate
+registration. Its conformance statement MUST identify any reserved marketplace
+identifier and activation-key suffix. Registration and removal MUST preserve
+unrelated host JSON keys and values semantically; stable JSON serialization MAY
+reformat the document. Invalid JSON, including JSONC comments, MUST fail closed
+before overwrite. Catalog, ownership-record, and settings writes MUST commit as
+one rollback unit so a failed write leaves no partial new registration.
+Removal MUST retire only entries in that consumer-owned rollback unit and MUST
+preserve settings outside the reserved marketplace identifier and activation
+suffix.
+
+> **Editorial note.** "Machine-verifiable" qualifies the consumer lifecycle,
+> not the runtime present on an operator's machine. A consumer can qualify
+> compatibility at release or build time with a pinned real-host lifecycle
+> suite; this implementation uses
+> `tests/integration/test_copilot_native_plugin_binary_lifecycle.py`. Operators
+> remain responsible for supplying a compatible runtime. In this implementation
+> the host is GitHub Copilot CLI, the reserved marketplace identifier is `apm`,
+> activation keys use the `@apm` suffix, and the ownership ledger lives under
+> `apm_modules`. Manual `*@apm` activation keys are unsupported while the exact
+> APM directory-marketplace entry is present because reconciliation owns that
+> reserved namespace. This requirement does not prescribe that host, namespace,
+> or compatibility version for other consumers.
+> A directory-marketplace entry points the host at a generated catalog rooted in
+> the materialized dependency directory; it does not copy package content into
+> the host's private plugin state.
+
+#### 8.5.8 User-scoped MCP target selection
+
+
+**[req-tg-014]** A conforming **consumer** implementation that supports a user
+installation scope MUST disclose in its conformance statement the user-scope
+manifest and lockfile locations and the versioned target-capability declaration
+it uses to determine user-scope MCP support. The consumer MUST treat a target
+without that declared capability as unsupported.
+
+When the consumer installs an MCP server into a user scope, it MUST resolve the
+effective target selection from the first applicable source in this order: an
+explicit target selection; a non-empty user-scope manifest restriction that
+does not contain the literal no-restriction sentinel `all`; a configured user
+default; then user-scope runtime discovery. Once a source selects one or more
+targets, the consumer MUST NOT consult a lower-precedence source. Project-scoped
+target-detection signals outside the user scope MUST NOT constrain the discovery
+step. A manifest `all` token is treated as no restriction and therefore does
+not suppress lower-precedence user-scope defaults or discovery.
+
+Before creating or modifying the user-scope manifest, lockfile, or target
+configuration for the attempted MCP entry, the consumer MUST partition the
+selected targets by the declared user-scope MCP capability. If no supported
+target remains, it MUST emit an actionable diagnostic and MUST NOT make a
+persistent mutation or fall back to discovery. For a mixed set, the supported
+subset MUST become the effective target set; the consumer MUST diagnose every
+unsupported target, MUST NOT write its target configuration, and MUST NOT fall
+back to discovery. If it persists an explicit mixed selection as a user-scope
+manifest restriction, it MUST serialize only the supported subset using target
+identifiers whose replay selects the same runtimes; it MUST NOT persist an
+unsupported member or remap one to a different supported runtime.
+
+### 8.6 Per-target primitive support (informational)
+
+The matrix of which primitive types each target supports is
+informational and additive: new harness adapters MAY add support
+without a spec revision. The current matrix is in the companion
+[targets-matrix.md](../../reference/targets-matrix/).
+
+### 8.7 Conformance requirements (primitives and targets)
+
+- Consumer: [req-pr-001](#req-pr-001), [req-pr-002](#req-pr-002),
+ [req-pr-003](#req-pr-003), [req-tg-001](#req-tg-001),
+ [req-tg-002](#req-tg-002), [req-tg-003](#req-tg-003),
+ [req-tg-004](#req-tg-004), [req-tg-005](#req-tg-005),
+ [req-tg-006](#req-tg-006), [req-tg-007](#req-tg-007),
+ [req-tg-008](#req-tg-008), [req-tg-009](#req-tg-009),
+ [req-tg-010](#req-tg-010), [req-tg-011](#req-tg-011),
+ [req-tg-012](#req-tg-012), [req-tg-013](#req-tg-013),
+ [req-tg-014](#req-tg-014), [req-pr-006](#req-pr-006),
+ [req-pr-007](#req-pr-007).
+
+---
+
+## 9. Versioning and amendment process
+
+### 9.1 Spec versioning
+
+OpenAPM follows the semver discipline at the document level:
+
+- **0.x** -- editor's drafts. Each minor MAY introduce breaking
+ changes with the migration window in [Section 9.5](#95-migration-windows-for-consumers).
+- **1.0** -- first stable cut. Strictly additive within the 1.x
+ major.
+- **2.x and beyond** -- breaking changes require a major bump.
+
+### 9.2 Breaking vs. non-breaking change definition
+
+**Non-breaking** (allowed within a minor):
+
+- Adding a new OPTIONAL field to manifest, lockfile, or policy.
+- Adding a new enum value to a non-conformance-critical enum (for
+ example a new target name registered in the Target Registry
+ companion).
+- Adding a new conformance test for behaviour already required.
+- Making an evaluation deterministic when no normative statement
+ previously defined it, the specification already named the field
+ and its purpose, no field is removed, renamed, or retyped, and no
+ existing fail-closed obligation is relaxed. The Appendix D row for
+ the amendment MUST name every verdict class that can change. This
+ does not license changing an evaluation already defined by a
+ normative statement.
+
+**Breaking** (requires a minor bump with migration window):
+
+- Removing or renaming a field.
+- Changing the type or required-ness of a field.
+- Tightening a SHOULD to a MUST. (Tightening introduces a new
+ hard-conformance bar; it requires migration even when
+ most implementations already meet the bar.)
+- Loosening a MUST to a SHOULD.
+- Promoting a parse-time warning to a parse-time error.
+- Bumping `lockfile_version` for non-additive changes.
+
+### 9.3 Amendment process
+
+1. An issue is filed in `microsoft/apm` with a `spec/openapm-vN.x`
+ label.
+2. An editor's-draft PR amends
+ `docs/src/content/docs/specs/openapm-vN.x.md`.
+3. A reviewer panel of at minimum two non-author reviewers (one
+ with implementation experience, one with
+ consumer/integrator experience) approves.
+4. A 14-day public comment period follows panel approval.
+5. Merged amendments append to [Appendix D](#appendix-d-revision-history)
+ with a `req-xxx`-level diff.
+
+### 9.4 Errata vs. new revision
+
+**Errata** are clarifications that do not change implementation
+behaviour. They are merged inline with an `[Errata YYYY-MM-DD]`
+footnote and summarised at the top of [Appendix D](#appendix-d-revision-history).
+A new revision (minor bump) is required for any change that
+satisfies the breaking-change definition in [Section 9.2](#92-breaking-vs-non-breaking-change-definition).
+
+### 9.5 Migration windows for consumers
+
+Breaking changes MUST be announced in the **previous** minor's
+revision history (so integrators see the announcement while building
+against the still-supported minor). A minimum of **90 days** MUST
+elapse between announcement and removal. Consumers SHOULD emit
+deprecation warnings during the migration window.
+
+The marketplace input block (manifest [Section 4.7](#47-marketplace-authoring-block-normative-input))
+is part of the manifest format. The shape of the emitted
+`marketplace.json` artifact is governed externally; this
+specification tracks upstream changes additively and does not bind
+the emitted artifact.
+
+---
+
+## 10. Security considerations
+
+This section enumerates the attack surfaces this specification
+addresses, and maps each to the normative requirement(s) that
+mitigate it. Normative registry HTTP wire-level treatment remains
+reserved for a future revision, not activated by v0.2.0.
+
+### 10.1 Dependency confusion
+
+**Threat.** An adversary publishes a package with the same name as
+an internal package on a public registry; the consumer's resolver
+fetches the public copy instead of the internal one.
+
+**Current posture.** Absent an active Governance policy, this revision
+has **NO consumer-class mitigation** for dependency confusion: the
+unprotected consumer install MUST be assumed vulnerable, and this
+specification does not claim otherwise. Mitigations below are
+Governance-class controls that an organisation MUST opt into.
+
+**Mitigations (Governance-class only).** The Governance class's
+allow/deny tri-state ([req-pl-005](#req-pl-005),
+[req-pl-006](#req-pl-006)) lets an organisation pin acceptable
+sources. The `require_pinned_constraint` rule
+([req-pl-007](#req-pl-007)) forces the consumer to declare intent
+explicitly, surfacing the dependency for review. A possible
+`registry_source.allow_non_registry: false` toggle remains reserved
+for a future revision; this revision relies on policy review.
+
+**Consumer-default cache isolation.** Cross-repository cache
+substitution is distinct from registry name confusion: a consumer
+that keys cached source material by a path prefix or ref alone can
+serve bytes from one repository for a different declared repository.
+[req-rs-016](#req-rs-016) requires complete minimum repository
+identity at every cache layer and forbids that reuse.
+
+### 10.2 Typosquatting
+
+**Threat.** A lookalike package name (`acm/security-baseline` instead
+of `acme/security-baseline`) lures the consumer into installing a
+hostile package.
+
+**Current posture.** Absent an active Governance policy, this revision
+has **NO consumer-class mitigation** for typosquatting. Lookalike
+detection, vendor-distance scoring, and registry-side
+disambiguation remain reserved for a future revision and are explicitly out of
+scope for the consumer in this revision.
+
+**Mitigations (Governance-class only).** Canonical normalisation
+([req-mf-009](#req-mf-009)) collapses cosmetic differences and
+makes name comparisons stable for policy authors.
+`require_pinned_constraint` ([req-pl-007](#req-pl-007)) forces
+explicit refs, raising review value. Allow/deny lists
+([req-pl-005](#req-pl-005), [req-pl-006](#req-pl-006)) gate the
+acceptable name space.
+
+### 10.3 Token leakage across hosts
+
+**Threat.** A credential issued for `github.com` is forwarded to
+`evil.example.com` during a cross-host clone, leaking the token.
+
+**Mitigations.**
+
+
+**[req-sc-003]** A conforming **consumer** implementation MUST
+resolve credentials per host class (as defined in
+[Section 3](#3-terminology) and as alias-extended via
+[req-sc-006](#req-sc-006)), and MUST NOT forward a credential
+resolved for one host class to a request targeting another host
+class. Credential scope MUST be observable in the consumer's
+diagnostic surface. When a fetch follows an HTTP redirect (3xx)
+whose target hostname classifies into a different host class than
+the originating request per [req-sc-005](#req-sc-005), the consumer
+MUST drop the originating Authorization header (and any other
+credential material attached for the originating host class) before
+issuing the redirected request. Credentials for the destination
+host class MAY be re-resolved per this requirement.
+
+
+**[req-sc-013]** A conforming **consumer** implementation that permits
+operator configuration to assign a literal authority hostname to a host
+class: (a) it MUST select exactly one effective host class before credential
+resolution. For this requirement, a **configuration signal** is any
+manifest declaration or implementation-specific operator setting that
+binds a hostname to a host class. (b) If two or more configuration signals
+claim the same literal authority hostname, the precedence MUST be
+deterministic and documented in the consumer's
+[conformance statement](#112-how-to-claim-conformance).
+
+For each request and transport child process spawned to fetch or validate
+the dependency (for example a git client or credential helper), the
+consumer: (c) it MUST resolve, attach, and expose only credential material
+belonging to the selected host class; and (d) credential material belonging
+to an unselected class MUST NOT be resolved, attached, or inherited by that
+child process. The consumer MUST actively suppress ambient credential
+material (for example environment variables) that the child process would
+otherwise inherit from a parent scope. Literal credential values remain
+subject to the redaction obligation of [req-sc-007](#req-sc-007); source
+descriptors MAY appear in the diagnostic surface required by
+[req-sc-003](#req-sc-003).
+
+(e) An explicit non-default port (using the protocol-default equivalences
+in [req-rs-016](#req-rs-016) item (2)) in the dependency reference MUST
+remain part of both the transport endpoint and credential scope. The port
+narrows credential lookup within the already-selected host class; it does
+not create a distinct host class.
+
+
+**[req-sc-005]** A conforming **consumer** implementation that
+classifies two distinct hostnames as the same host class for the
+purposes of credential reuse MUST do so on the basis of (a)
+identical eTLD+1 per the Public Suffix List
+([https://publicsuffix.org](https://publicsuffix.org)), or (b) an
+explicit `aliases:` entry in the project's `apm.yml`
+`registries:` block (see [Section 4.2.3](#423-registries) and
+[req-sc-006](#req-sc-006)). Implementations MUST NOT collapse two
+hostnames onto the same host class on any other basis (such as
+DNS CNAME chains, TLS SAN entries, or shared HTTP redirects).
+A host-class assignment produced by a configuration signal exercised
+under [req-sc-013](#req-sc-013) is not subject to this prohibition.
+
+
+**[req-sc-007]** A conforming **consumer** implementation MUST
+redact credential material (tokens, basic-auth passwords, bearer
+strings) so that such material MUST NOT appear in any user-facing
+diagnostic, log, error message, packed bundle, lockfile, or
+persisted audit record. The diagnostic that reveals "credential X
+was used for host Y" MUST identify the credential by source
+descriptor (for example `GITHUB_APM_PAT environment variable`),
+not by literal value. The Producer toolchain MUST refuse to pack
+any file whose path matches the configurable secret-pattern set
+(default patterns: `.env`, `.env.*`, `*.pem`, `*.key`, `id_rsa`,
+`id_ed25519`); the pattern set MAY be extended via policy. See
+also the broader token-leakage mitigation row in
+[Section 10.11](#1011-summary-table).
+
+
+**[req-sc-008]** A conforming **consumer** implementation SHOULD
+refuse to attach a credential to a git-over-HTTP fetch whose URL
+scheme is not `https://`, unless the target host is the loopback
+address (`127.0.0.0/8`, `::1`) or the target registry is declared
+with `insecure: true` per [req-sc-006](#req-sc-006).
+
+### 10.4 Lockfile tampering
+
+**Threat.** An adversary edits `apm.lock.yaml` to swap a commit SHA
+or a deployed-file hash and ship a malicious payload that still
+"passes" a naive integrity check.
+
+**Mitigations.**
+
+
+**[req-sc-001]** A conforming **consumer** implementation MUST
+compute and record a SHA-256 content hash for every deployed file
+(per [req-lk-012](#req-lk-012)) and MUST re-verify those hashes on
+audit. Files present in `deployed_files` whose on-disk hash does
+not match the recorded hash MUST be reported as a content-integrity
+violation.
+
+In addition, [req-lk-013](#req-lk-013) verifies registry archive
+bytes before extraction, and [req-lk-017](#req-lk-017) re-verifies
+deployed-file hashes on every frozen install. The combination
+prevents an attacker from tampering with installed files without
+detection.
+
+### 10.5 Registry impersonation
+
+**Threat.** DNS or MITM redirection points a registry URL at an
+attacker-controlled host serving a manipulated archive.
+
+**Mitigation.** [req-lk-013](#req-lk-013) anchors trust in the
+archive's SHA-256 (per the hash envelope at
+[req-lk-016](#req-lk-016)), not the URL: a tampered archive fails
+closed before extraction. The mirror-tolerance rule
+([req-rs-009](#req-rs-009)) preserves this property: a mirror MAY
+serve the bytes, but the bytes MUST still hash to the lockfile's
+recorded `resolved_hash`. Registry-class implementations
+participating in this trust chain MUST satisfy
+[req-rg-001](#req-rg-001). A normative TLS-only requirement
+on the registry HTTP wire remains reserved for a future revision in
+[Appendix B](#appendix-b-registry-http-api-reserved); it is not activated here.
+
+In addition:
+
+
+**[req-sc-004]** A conforming **consumer** implementation MUST
+constrain registry archive extraction so that (a) the archive
+content-type is `application/gzip` over a tar payload (`tar.gz`);
+implementations MUST reject `application/zip` and any other
+archive container in this revision; (b) the uncompressed archive size MUST
+NOT exceed a configurable cap whose default value is **100 MB**;
+and (c) the number of entries in the archive MUST NOT exceed a
+configurable cap whose default value is **10,000**. Violations MUST
+fail closed before extraction proceeds.
+
+### 10.6 Malicious package execution at install time
+
+**Threat.** A hostile package's `scripts:` block executes during
+install.
+
+**Mitigation.** This specification does **not** authorise
+`apm install` to execute any `scripts:` entry. The producer-side
+`scripts:` block is a named-entry registry consumed by an explicit
+user invocation (such as `apm run `). Governance MAY further
+forbid `scripts:` declarations via `manifest.scripts: deny`. The
+absence of an install-time execution path is the load-bearing
+mitigation; the policy block is defence in depth.
+
+### 10.7 Unverified content cleanup (file integrators)
+
+**Threat.** A hostile transitive dependency claims authority over
+files outside its real deployment footprint, causing the cleanup
+logic to remove files belonging to another dependency or to the
+project itself.
+
+**Mitigations.** [req-tg-002](#req-tg-002) constrains each target
+to its registered deploy root(s). The self-entry isolation in
+[Section 5.3](#53-self-entry-semantics) prevents the cleanup logic
+of one dependency from claiming the project's own files. Orphan
+detection MUST scope per-dependency, not globally.
+
+### 10.8 Policy bypass via crafted manifest
+
+**Threat.** A consumer manifest exploits parser ambiguity (an
+unknown key, a malformed `extends:` chain) to silently skip the
+governance gate.
+
+**Mitigations.** [req-pl-009](#req-pl-009) makes unknown policy
+keys a warning, not a silent acceptance, and preserves them as
+`x-*` extensions. [req-pl-010](#req-pl-010) fails closed on fetch
+failure when configured. [req-pl-002](#req-pl-002) blocks before
+disk write. [req-pl-003](#req-pl-003) caps `extends:` depth to
+thwart amplification attacks. [req-pl-018](#req-pl-018) prevents a
+case-variant repository spelling from bypassing an allow-list or
+deny-list when resolution treats both spellings as one package
+identity. On a case-sensitive source, differently cased repository
+paths remain distinct and policy authors must enumerate the spellings
+they intend to deny. The same normalization widens `dependencies.allow`
+matching on a case-insensitive source, so an upgrade can admit a
+case-variant spelling that previously missed. Clause (d) of
+[req-pl-018](#req-pl-018) leaves segments at and after recursive-glob
+ambiguity byte-exact, including on a case-insensitive source. The
+match subject is host-blind, so one policy entry governs the same
+repository path on every reachable host; governance authors relying
+on host separation need an independent host-level control.
+
+### 10.9 Archive path-traversal (zip-slip / symlink escape)
+
+**Threat.** A crafted tarball or zip with `..` segments, absolute
+paths, or symlinks writes files outside the extraction root.
+
+**Mitigation.**
+
+
+**[req-sc-002]** A conforming **consumer** implementation MUST
+reject any archive entry whose extracted path would contain `..`
+segments, would be absolute, or would be a symbolic or hard link.
+Extraction MUST fail closed on the first such entry; partial
+extractions MUST be cleaned up. The archive container, size, and
+entry-count limits of [req-sc-004](#req-sc-004) MUST be enforced
+in addition.
+
+### 10.10 Hash-algorithm downgrade
+
+**Threat.** A consumer's `policy.hash_algorithm` accepts a weak
+digest (MD5, SHA-1) and an attacker exploits collision weakness to
+serve a manipulated policy that matches the recorded digest.
+
+**Mitigation.** [req-mf-018](#req-mf-018) restricts the allowed
+algorithms to `sha256`, `sha384`, and `sha512`, rejecting weaker
+choices at parse time. The lockfile hash envelope
+([req-lk-016](#req-lk-016)) makes the digest algorithm explicit on
+every stored hash, foreclosing algorithm-ambiguity attacks.
+
+### 10.11 Summary table
+
+| # | Attack surface | Mitigation requirement(s) | Posture |
+|---|---------------------------------------------|--------------------------------------------------------------------|-------------------|
+| 1 | Dependency confusion | [req-pl-005](#req-pl-005), [req-pl-006](#req-pl-006), [req-pl-007](#req-pl-007) | Governance-only |
+| 2 | Typosquatting | [req-mf-009](#req-mf-009), [req-pl-005](#req-pl-005), [req-pl-007](#req-pl-007) | Governance-only |
+| 3 | Token leakage across hosts | [req-sc-003](#req-sc-003), [req-sc-005](#req-sc-005), [req-sc-007](#req-sc-007), [req-sc-008](#req-sc-008), [req-sc-013](#req-sc-013) | Consumer-default |
+| 4 | Lockfile tampering | [req-lk-012](#req-lk-012), [req-lk-013](#req-lk-013), [req-lk-016](#req-lk-016), [req-lk-017](#req-lk-017), [req-sc-001](#req-sc-001) | Consumer-default |
+| 5 | Registry impersonation | [req-lk-013](#req-lk-013), [req-rs-009](#req-rs-009), [req-sc-004](#req-sc-004); TLS-only wire rule remains deferred | Consumer-default |
+| 6 | Malicious package execution at install time | No install-time execution path; [req-pl-006](#req-pl-006) defence | Consumer-default |
+| 7 | Unverified content cleanup | [req-tg-002](#req-tg-002), [req-lk-020](#req-lk-020), [req-lk-021](#req-lk-021); self-entry isolation | Consumer-default |
+| 8 | Policy bypass via crafted manifest | [req-pl-002](#req-pl-002), [req-pl-009](#req-pl-009), [req-pl-010](#req-pl-010), [req-pl-018](#req-pl-018) | Governance-only |
+| 9 | Archive path-traversal | [req-sc-002](#req-sc-002), [req-sc-004](#req-sc-004) | Consumer-default |
+| 10| Hash-algorithm downgrade | [req-mf-018](#req-mf-018), [req-lk-016](#req-lk-016) | Consumer-default |
+| 11| Unauthorised executable primitive deployment | [req-sc-009](#req-sc-009) | Consumer-default |
+| 12| Approval grant propagation via VCS | [req-sc-010](#req-sc-010) | Consumer-default |
+| 13| Org executable denial bypassed by project/user grant | [req-sc-011](#req-sc-011) | Consumer-default |
+| 14| Required-package audit false-positive on withheld executable | [req-sc-012](#req-sc-012) | Consumer-default |
+| 15| Cross-repository cache substitution | [req-rs-016](#req-rs-016) | Consumer-default |
+| 16| Silent capability-scope widening via lossy target conversion | [req-tg-006](#req-tg-006); default-visible conversion diagnostic | Consumer-default |
+| 17| Cross-target primitive deployment | [req-tg-008](#req-tg-008), [req-lk-021](#req-lk-021) | Consumer-default |
+| 18| Case-collision materialization confusion | [req-lk-022](#req-lk-022), [req-rs-016](#req-rs-016) | Consumer-default |
+| 19| Executable deployment in non-interactive contexts | [req-sc-014](#req-sc-014) | Consumer-default |
+| 20| Source-only or symlinked package content materialization | [req-sc-015](#req-sc-015) | Consumer-default |
+| 21| Native plugin namespace collision or ownership-ledger loss | [req-tg-013](#req-tg-013) | Consumer-default |
+| 22| Remote-to-local source substitution or internal local-symlink escape | [req-mf-016](#req-mf-016); source admission and acquisition, distinct from target source-plan controls | Consumer-default |
+
+Source admission in [req-mf-016](#req-mf-016) determines whether and
+where dependency content may be acquired. It is distinct from the
+post-authorization target source-file plan in [req-sc-015](#req-sc-015).
+
+### 10.12 Publisher provenance and attestations (reserved)
+
+Publisher provenance (cryptographic attestations binding a
+specific package version to a specific publisher identity) is
+**out of scope and reserved for a future revision**. The
+lockfile's `attestations:` field (per
+[req-lk-001](#req-lk-001)) and the producer-side tag-signing
+SHOULD ([req-pr-005](#req-pr-005)) are reserved hooks for this
+future surface. A future surface will define: in-toto / SLSA
+provenance binding format; sigstore verification semantics;
+Governance `policy.dependencies.require_attestation` enforcement
+modes; and the registry HTTP wire envelope (alongside
+[Appendix B](#appendix-b-registry-http-api-reserved)).
+
+The declaring-source context in [req-mf-016](#req-mf-016) is not
+cryptographic publisher provenance and does not activate this reservation.
+
+### 10.13 Executable primitive approval gate
+
+**Threat.** A dependency package deploys executable code --
+hooks, bin executables, MCP server configurations, or canvas
+extensions -- that runs on the developer's machine without
+explicit consent.
+
+**Mitigations.**
+
+
+**[req-sc-009]** A conforming **consumer** implementation MUST,
+when the consuming project's `apm.yml` contains an `allowExecutables`
+block, deny deployment of any executable primitive (hooks, bin
+executables, MCP server configurations, and canvas extensions)
+from a dependency package unless that package is explicitly listed
+in the effective approval set for the corresponding executable type.
+A consumer MUST fail closed when the `allowExecutables` block is
+present but the package is absent from the approval set: the
+primitive MUST NOT be deployed.
+
+
+**[req-sc-010]** A conforming **consumer** implementation that
+provides an interactive approval mechanism for executable primitives
+MUST persist per-user approval decisions in a location that is
+isolated from the project manifest (`apm.yml`) and is not tracked
+by version control alongside project files by default. The consumer
+MUST NOT write interactive approval decisions into the project
+`apm.yml`, so that one developer's approval cannot propagate
+implicitly to other developers who clone or share the project.
+
+### 10.14 Executable trust precedence and audit fidelity
+
+**Threat.** Two failure modes defeat centralized executable
+governance. First, a project-level or user-level approval re-enables
+an executable primitive that an organization policy has denied, or
+the install-time gate and the post-install audit reach different
+trust conclusions for the same primitive (a split-brain that lets a
+denied primitive read as trusted, or the reverse). Second, a
+governance requirement mandating that a package be present is
+reported as unmet merely because that package's executable primitives
+were withheld from deployment, making it impossible to both mandate a
+package and let a consumer withhold its executables.
+
+**Mitigations.**
+
+
+**[req-sc-011]** A conforming **consumer** implementation MUST resolve
+every executable-primitive trust decision through a single deny-wins
+precedence relation in which an organization policy denial (an
+`executables.deny` entry or `executables.deny_all` in an applicable
+`apm-policy.yml` per [Section 6](#6-policy-format-apm-policyyml))
+overrides any project-level or user-level grant for the same package
+and executable type. A consumer MUST reach the identical allow-or-deny
+outcome for identical inputs whether the decision gates deployment at
+install time or classifies an already-installed primitive at audit
+time. A consumer MUST NOT allow a project `apm.yml` grant or a
+user-local approval to re-enable an executable primitive that an
+applicable organization policy denies.
+
+
+**[req-sc-012]** A conforming **consumer** implementation that
+evaluates a governance requirement mandating the presence of a package
+(per [Section 6](#6-policy-format-apm-policyyml)) MUST determine
+satisfaction of that requirement from the presence of the package in
+the resolved lockfile, and MUST NOT condition it on whether that
+package's executable primitives were deployed. When a required package
+is present but one or more of its executable primitives are withheld
+from deployment by the trust resolution of
+[req-sc-011](#req-sc-011), a consumer MUST treat the presence
+requirement as satisfied and MUST surface each withheld executable as
+a diagnostic signal distinct from any missing-package violation.
+
+### 10.15 Per-invocation executable consent
+
+**Threat.** An operator runs `apm install` in a non-interactive context
+(piped output, CI pipeline, `--frozen` mode) and a marketplace plugin
+deploys `bin/` executables to the developer tool's PATH without any
+visible consent signal, because the per-invocation warning is swallowed
+by log redirection.
+
+**Mitigation.**
+
+
+**[req-sc-014]** A conforming **consumer** implementation that supports
+a per-invocation consent flag for `bin/` executable deployment MUST deny
+that deployment by default when its standard output is not connected to a
+terminal (i.e., the output stream is not a TTY), unless the operator has
+explicitly opted in for that invocation. An explicit per-invocation opt-in
+(for example `--trust-bin`) overrides the non-interactive default and
+permits deployment. An explicit per-invocation opt-out (for example
+`--no-trust-bin`) overrides the non-interactive default and denies
+deployment even when the output IS a terminal. The `allowExecutables`
+policy gate [req-sc-009](#req-sc-009) is evaluated before per-invocation
+consent and always takes precedence: a policy-level denial cannot be
+overridden by a per-invocation opt-in.
+
+### 10.16 Authorized source-plan materialization
+
+**Threat.** A package can hide hostile content in a source-only file or a
+symlink entry, then reach a primitive target through a lifecycle path that
+does not reuse the authorization and scan decision made for ordinary install.
+
+**Mitigation.**
+
+
+**[req-sc-015]** A conforming **consumer** implementation MUST derive exactly
+one authorized source-file set after target, package-subset, and executable
+authorization for every primitive-materialization lifecycle, including
+install and re-integration after uninstall. The set MUST exclude symlink
+files and MUST NOT traverse symlinked directories. The consumer MUST apply
+any pre-deployment content security scan to the full set before any
+source-derived target write and MUST materialize primitive files only from that
+same set; each primitive-integrator materialization path MUST consume the
+canonical set rather than derive a second classifier. If a selected file
+violates a blocking scan policy, the consumer MUST reject that materialization
+before writing a source-derived target file unless the operator explicitly
+forces the install. The consumer MUST NOT scan or materialize a
+source-only file merely because it is present in the package tree. The
+executable authorization used to derive the set remains governed by
+[req-sc-009](#req-sc-009).
+
+### 10.17 Native plugin namespace and ownership recovery
+
+**Threat.** A foreign marketplace entry captures a consumer's reserved
+identifier, or loss of the consumer-owned ledger causes registration cleanup
+to overwrite unrelated host settings or retain stale activation keys.
+
+**Mitigation.** [req-tg-013](#req-tg-013) permits namespace recovery only from
+the exact directory-marketplace entry the consumer would generate, refuses a
+foreign collision, reconciles only the declared reserved activation-key
+suffix, preserves unrelated JSON values semantically, and commits catalog,
+ledger, and settings writes as one rollback unit.
+
+---
+
+## 11. Conformance
+
+### 11.1 Conformance classes (normative)
+
+This specification defines four conformance classes; this section
+is the **sole normative home** for them. The forward pointer in
+[Section 2](#2-conventions) is editorial.
+
+| Class | Role |
+|--------------|---------------------------------------------------------------------------------------|
+| Producer | Emits a conforming `apm.yml`; optionally emits a conforming `apm.lock.yaml`. Conformance hooks: tag-release contract ([req-pr-004](#req-pr-004), [req-pr-005](#req-pr-005)). |
+| Consumer | Parses `apm.yml`, resolves dependencies per [Section 7](#7-dependency-resolution), writes `apm.lock.yaml`, deploys primitives per [Section 8](#8-primitive-type-system-and-target-matrix). |
+| Registry | One operative trust-anchor obligation: [req-rg-001](#req-rg-001). Broader HTTP wire conformance remains reserved for a future revision. |
+| Governance | Parses `apm-policy.yml`, evaluates per [Section 6](#6-policy-format-apm-policyyml), gates a Consumer install. |
+
+An implementation MAY claim more than one class. A toolchain
+component that packs a project (the "producer toolchain") typically
+acts as both Producer (it writes the manifest) and single-tenant
+Consumer (it validates the manifest and writes a lockfile for its
+own pack).
+
+Section-level conformance summaries
+([Section 4.9](#49-conformance-requirements-manifest),
+[Section 5.7](#57-conformance-requirements-lockfile),
+[Section 6.9](#69-conformance-requirements-governance),
+[Section 7.11](#711-conformance-requirements-resolution),
+[Section 8.7](#87-conformance-requirements-primitives-and-targets))
+are reader-aids that restate the Appendix C rows for the section's
+class. Appendix C is the canonical source of truth; on any
+conflict between a section summary and Appendix C, Appendix C
+wins.
+
+### 11.2 How to claim conformance
+
+An implementation claiming OpenAPM v0.2.0 conformance MUST publish a
+conformance statement identifying:
+
+1. Which conformance class(es) it claims.
+2. The exact revision of the specification it conforms to (`v0.2.0`).
+3. The list of OPTIONAL features it implements.
+4. Any limitations or non-conformance points, with rationale.
+5. Any additional conformance-statement content required by a specific
+ requirement, including reserved namespace disclosure under
+ [req-tg-013](#req-tg-013) and fixture citations under
+ [req-cf-002](#req-cf-002).
+6. If it claims the Governance class, or documents a case-insensitive
+ host under [req-rs-016](#req-rs-016), the repository-coordinate
+ case rule for every such host. Registry sources are
+ case-insensitive under [req-rs-016](#req-rs-016) and
+ [req-pl-018](#req-pl-018); the conformance statement records that
+ fixed rule rather than choosing it. The declared host rule MUST
+ agree across repository identity and policy matching. In this revision this
+ information is a named prose section; a machine-readable carrier is
+ reserved in [Section 12.6](#126-machine-readable-conformance-manifest-reserved).
+
+**Foundation assessment status (informative).** This draft and its
+informative requirement inventory do not assert that the reference CLI
+satisfies the requirements above. The combined local-source and audit
+successor is responsible for complete executable bindings, fresh assessment,
+and exact artifact/manifest fingerprints before any implementation claim.
+Static references and passing schema checks are not runtime conformance.
+
+The prior assessment disclosed an inherited gap: the reference CLI's bare
+content audit uses source-derived drift replay rather than the stored-hash
+baseline required by [req-lk-017](#req-lk-017)'s unqualified audit obligation;
+stored-hash and full-SHA consistency baselines are exercised in CI/conformance
+audit. Full Consumer conformance in bare audit mode is not claimed here.
+Prior source-only evidence also reports that local acquisition correctly
+dereferences an admitted internal resource symlink, but inherited replay
+from its original source representation can falsely report the deployed
+regular file as orphaned during unchanged CI audit. That is a replay
+limitation, not evidence of an escape, external-file read, or security
+bypass. This foundation does not rerun or repair those behaviors.
+
+The retained schema and Git-tree evidence limits are described in
+[Appendix A](#appendix-a-normative-json-schemas-inline) and
+[Section 5.6.4](#564-git-source-tree-integrity-hash). Controlled native
+snapshots do not establish a successful native install/audit round trip;
+no new native scratch backend or hosted-runtime evidence is supplied here.
+These disclosures are not waivers of any normative obligation.
+
+### 11.3 Enumerated requirements by class
+
+#### 11.3.1 Producer
+
+[req-mf-001](#req-mf-001), [req-mf-002](#req-mf-002),
+[req-mf-003](#req-mf-003), [req-mf-004](#req-mf-004),
+[req-mf-005](#req-mf-005), [req-mf-014](#req-mf-014),
+[req-mf-015](#req-mf-015), [req-mf-017](#req-mf-017),
+[req-mf-021](#req-mf-021), [req-ext-002](#req-ext-002),
+[req-pr-004](#req-pr-004), [req-pr-005](#req-pr-005) (SHOULD).
+
+#### 11.3.2 Consumer
+
+[req-mf-006](#req-mf-006), [req-mf-007](#req-mf-007),
+[req-mf-008](#req-mf-008), [req-mf-009](#req-mf-009),
+[req-mf-010](#req-mf-010), [req-mf-011](#req-mf-011),
+[req-mf-012](#req-mf-012), [req-mf-013](#req-mf-013),
+[req-mf-016](#req-mf-016), [req-mf-018](#req-mf-018),
+[req-mf-019](#req-mf-019), [req-mf-020](#req-mf-020),
+[req-mf-021](#req-mf-021), [req-mf-022](#req-mf-022),
+[req-mf-023](#req-mf-023), [req-mf-024](#req-mf-024),
+[req-ext-001](#req-ext-001),
+[req-lk-001](#req-lk-001), [req-lk-002](#req-lk-002),
+[req-lk-003](#req-lk-003), [req-lk-004](#req-lk-004),
+[req-lk-005](#req-lk-005), [req-lk-006](#req-lk-006),
+[req-lk-007](#req-lk-007) (SHOULD), [req-lk-008](#req-lk-008),
+[req-lk-009](#req-lk-009), [req-lk-010](#req-lk-010),
+[req-lk-011](#req-lk-011), [req-lk-012](#req-lk-012),
+[req-lk-013](#req-lk-013), [req-lk-014](#req-lk-014),
+[req-lk-015](#req-lk-015), [req-lk-016](#req-lk-016),
+[req-lk-017](#req-lk-017), [req-lk-018](#req-lk-018) (SHOULD),
+[req-lk-019](#req-lk-019), [req-lk-020](#req-lk-020),
+[req-lk-021](#req-lk-021), [req-lk-022](#req-lk-022),
+[req-lk-023](#req-lk-023),
+[req-rs-001](#req-rs-001), [req-rs-002](#req-rs-002),
+[req-rs-003](#req-rs-003), [req-rs-004](#req-rs-004),
+[req-rs-005](#req-rs-005), [req-rs-006](#req-rs-006),
+[req-rs-007](#req-rs-007), [req-rs-008](#req-rs-008),
+[req-rs-009](#req-rs-009), [req-rs-010](#req-rs-010),
+[req-rs-011](#req-rs-011), [req-rs-012](#req-rs-012),
+[req-rs-013](#req-rs-013), [req-rs-014](#req-rs-014),
+[req-rs-015](#req-rs-015), [req-rs-016](#req-rs-016),
+[req-rs-017](#req-rs-017),
+[req-pr-001](#req-pr-001), [req-pr-002](#req-pr-002),
+[req-pr-003](#req-pr-003), [req-tg-001](#req-tg-001),
+[req-pr-006](#req-pr-006), [req-pr-007](#req-pr-007),
+[req-tg-002](#req-tg-002), [req-tg-003](#req-tg-003),
+[req-tg-004](#req-tg-004), [req-tg-005](#req-tg-005),
+[req-tg-006](#req-tg-006), [req-tg-007](#req-tg-007),
+[req-tg-008](#req-tg-008), [req-tg-009](#req-tg-009),
+[req-tg-010](#req-tg-010), [req-tg-011](#req-tg-011),
+[req-tg-012](#req-tg-012), [req-tg-013](#req-tg-013),
+[req-tg-014](#req-tg-014),
+[req-sc-001](#req-sc-001),
+[req-sc-002](#req-sc-002), [req-sc-003](#req-sc-003),
+[req-sc-004](#req-sc-004), [req-sc-005](#req-sc-005),
+[req-sc-006](#req-sc-006), [req-sc-007](#req-sc-007),
+[req-sc-008](#req-sc-008) (SHOULD), [req-sc-009](#req-sc-009),
+[req-sc-010](#req-sc-010), [req-sc-011](#req-sc-011),
+[req-sc-012](#req-sc-012), [req-sc-013](#req-sc-013),
+[req-sc-014](#req-sc-014), [req-sc-015](#req-sc-015),
+[req-cf-001](#req-cf-001),
+[req-cf-002](#req-cf-002).
+
+#### 11.3.3 Registry
+
+
+**[req-rg-001]** A conforming **Registry** implementation
+(whose broader wire contract remains reserved) MUST serve archive bytes
+such that the SHA-256 of those bytes equals the digest the
+Registry advertises for the version, and MUST NOT mutate previously
+published `(name, version)` bytes. When a Registry receives a
+publish request for an `(name, version)` it has previously served,
+the Registry MUST either (a) reject the publish request with a
+diagnostic identifying the existing version, or (b) accept it
+ONLY if the submitted archive bytes are byte-identical to the
+previously-served bytes (idempotent republish). A Registry MUST
+NOT replace the bytes of a previously-served `(name, version)`
+under any circumstance. This is the trust anchor on which
+[req-lk-013](#req-lk-013) and [req-rs-009](#req-rs-009) depend;
+the surrounding HTTP wire envelope remains reserved for a future revision.
+
+#### 11.3.4 Governance
+
+[req-pl-001](#req-pl-001), [req-pl-002](#req-pl-002),
+[req-pl-003](#req-pl-003), [req-pl-004](#req-pl-004),
+[req-pl-005](#req-pl-005), [req-pl-006](#req-pl-006),
+[req-pl-007](#req-pl-007), [req-pl-008](#req-pl-008),
+[req-pl-009](#req-pl-009), [req-pl-010](#req-pl-010),
+[req-pl-011](#req-pl-011), [req-pl-012](#req-pl-012),
+[req-pl-013](#req-pl-013), [req-pl-014](#req-pl-014),
+[req-pl-015](#req-pl-015), [req-pl-016](#req-pl-016),
+[req-pl-017](#req-pl-017), [req-pl-018](#req-pl-018).
+
+### 11.4 Worked conformance examples (informative)
+
+#### 11.4.1 Producer example
+
+A minimal artifact a Producer emits:
+
+```yaml
+# apm.yml
+name: contoso/security-baseline
+version: "2.3.1"
+description: Security baseline skills and instructions for contoso projects.
+license: MIT
+target: [copilot, claude]
+
+dependencies:
+ apm:
+ - contoso/common-prompts#^1.0.0
+```
+
+This artifact satisfies [req-mf-001](#req-mf-001),
+[req-mf-002](#req-mf-002), [req-mf-003](#req-mf-003),
+[req-mf-004](#req-mf-004), [req-mf-005](#req-mf-005).
+
+#### 11.4.2 Consumer example
+
+A Consumer reading the manifest above produces the lockfile:
+
+```yaml
+lockfile_version: "2"
+apm_version: "0.7.0"
+dependencies:
+ - repo_url: github.com/contoso/common-prompts
+ resolved_commit: "a1b2c3d4e5f6789012345678901234567890abcd"
+ resolved_ref: "^1.0.0"
+ constraint: "^1.0.0"
+ resolved_tag: v1.4.2
+ resolved_at: "2026-05-10T20:14:00+00:00"
+ tree_sha256: "sha256:0102030405060708091011121314151617181920212223242526272829303132"
+ depth: 1
+ deployed_files:
+ - .github/prompts/review.prompt.md
+ deployed_file_hashes:
+ .github/prompts/review.prompt.md: "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
+```
+
+This artifact satisfies [req-lk-001](#req-lk-001),
+[req-lk-002](#req-lk-002), [req-lk-003](#req-lk-003),
+[req-lk-008](#req-lk-008), [req-lk-011](#req-lk-011),
+[req-lk-012](#req-lk-012), [req-lk-015](#req-lk-015),
+[req-lk-016](#req-lk-016), and (via the resolution that produced
+it) [req-rs-001](#req-rs-001), [req-rs-002](#req-rs-002).
+
+#### 11.4.3 Governance example
+
+The following policy chain is evaluated by a Governance
+implementation; an install that resolves to the lockfile in
+[Section 11.4.2](#1142-consumer-example) is accepted (no
+unbounded direct deps, dependency in `contoso/*` allow-list) and is
+written to disk:
+
+```yaml
+name: contoso-baseline
+enforcement: block
+fetch_failure: block
+dependencies:
+ allow:
+ - contoso/*
+ require_pinned_constraint: true
+ max_depth: 25
+```
+
+This evaluation exercises [req-pl-002](#req-pl-002),
+[req-pl-005](#req-pl-005), [req-pl-007](#req-pl-007),
+[req-pl-008](#req-pl-008).
+
+#### 11.4.4 Registry example
+
+Wire-format normativity remains reserved for a future revision. A Registry claiming
+conformance against this revision's trust-anchor requirement
+([req-rg-001](#req-rg-001)) MUST publish an addendum statement
+enumerating the immutability guarantees and the digest algorithm
+served.
+
+---
+
+## 12. Conformance test methodology
+
+### 12.1 Statement IDs
+
+Every normative MUST, SHOULD, or MAY statement in this document
+carries a stable `id="req-xxx"` anchor immediately preceding the
+statement. These IDs are the contract between the specification and
+its conformance suite: the test suite cites IDs, the specification
+publishes them.
+
+### 12.2 Hybrid binding (statement-anchored + fixture-anchored)
+
+This specification adopts a **hybrid** conformance binding:
+
+- Statement-anchored: every MUST/SHOULD/MAY carries a `req-xxx` ID.
+ Conformance-suite tests reference the ID either in a docstring or
+ via a pytest marker (`@pytest.mark.req("req-mf-005")`).
+- Fixture-anchored: a subset of requirements that lend themselves to
+ black-box file-in / file-out testing (round-trip, malicious
+ archive, merge-table, semver dialect) MUST additionally carry a
+ fixture directory under `tests/fixtures/spec-conformance/`. The
+ seed fixture tree shipped at spec publication is enumerated in
+ [Section 12.4](#124-fixture-layout-informative).
+
+### 12.3 CI binding
+
+A single CI job named `Spec conformance` is RECOMMENDED. The job:
+
+1. Treats the HTML requirement anchors (``) in the
+ spec body as the canonical statement list, and treats both the
+ informative machine-readable manifest at
+ [`docs/public/specs/manifests/openapm-v0.2.requirements.yml`](/apm/specs/manifests/openapm-v0.2.requirements.yml)
+ (see [Section 12.6](#126-machine-readable-conformance-manifest-reserved))
+ and the [Appendix C](#appendix-c-index-of-normative-statements)
+ table as derived projections that MUST agree with the canonical
+ anchors. The manifest remains informative in this revision.
+2. Walks the conformance suite for ID references (docstrings,
+ markers, or fixture directory names) and builds the set of
+ referenced IDs.
+3. Fails if any declared ID has zero references (orphan in the
+ spec) or any referenced ID is not declared (orphan in the
+ tests).
+4. As a guard against drift in the non-normative companions, the
+ same job MAY fail when any `MUST`, `SHOULD`, or `MAY` token
+ appears in `docs/src/content/docs/reference/**` (the companion
+ pages SHOULD NOT carry normative keywords).
+
+
+**[req-cf-002]** A **Consumer** or **Producer** claiming OpenAPM
+v0.2.0 conformance MUST publish a conformance statement (see
+[Section 11.2](#112-how-to-claim-conformance)) that cites the test
+invocation exercising every `req-XXX` statement in its declared
+class against the seed fixture tree under
+`tests/fixtures/spec-conformance/`. The conformance statement MUST
+list, for each `req-XXX` in scope, the fixture path and the
+assertion that exercises it.
+
+### 12.4 Fixture layout (informative)
+
+The conformance seed fixture tree shipped with this specification
+lives at `tests/fixtures/spec-conformance/`. Concrete on-disk
+files:
+
+```
+tests/fixtures/spec-conformance/
+ README.md
+ manifest/
+ valid-minimal.yml
+ invalid-missing-name.yml
+ invalid-no-source-key.yml
+ x-extension-roundtrip.yml
+ lockfile/
+ materialization-sort-exclusion.yml
+ v1-git-only.yml
+ v2-with-registry.yml
+ round-trip-unknown-fields.yml
+ policy/
+ valid-extends.yml
+ invalid-extends-cycle.yml
+ resolution/
+ semver-dialect.json
+ source-plan/
+ req-sc-015.json
+```
+
+Conformance-suite expansion (additional fixtures for archive
+path-traversal, merge-table cases, etc.) tracks here in subsequent
+revisions; the seed set above is the minimum that
+implementations can run against immediately.
+
+The lockfile fixture `materialization-sort-exclusion.yml` exercises
+`materialization_repo_url` sort-exclusion while
+`v1-git-only.yml` exercises transactional spelling migration and
+collision refusal through the [req-lk-022](#req-lk-022) conformance
+oracles.
+
+The `source-plan/req-sc-015.json` oracle describes selected materialized
+content, source-only content, and excluded symlink files and directories
+for [req-sc-015](#req-sc-015). A consumer uses the oracle to demonstrate
+that scanning and materialization share the same authorized source set
+through both install and re-integration; each lifecycle result matches
+the oracle's authorized paths.
+
+### 12.5 Round-trip conformance (normative)
+
+
+**[req-cf-001]** A conforming **Consumer** implementation MUST
+satisfy an **idempotent round-trip** on any conforming manifest
+and lockfile: re-parsing and re-serialising either artifact MUST
+produce a byte-equivalent file (modulo trailing newline and YAML
+flow-style cosmetics that the implementation is permitted to
+canonicalise per [Section 4.3.4](#434-canonical-normalisation-writer-requirements) and
+[Section 5.2](#52-per-entry-fields)). Unknown top-level keys, `x-*`
+extension entries (per [req-ext-001](#req-ext-001),
+[req-lk-014](#req-lk-014)), and fields the implementation does
+not understand MUST be preserved verbatim across round-trip.
+
+### 12.6 Machine-readable conformance manifest (reserved)
+
+A machine-readable manifest enumerating every normative
+requirement, its keyword (MUST/SHOULD/MAY), its class, and its
+associated fixture path as a normative wire contract is **reserved for a
+future revision**. Such a contract would
+permit a conformance-suite runner to enumerate requirements
+without parsing the prose, and will permit cross-implementation
+result aggregation. Implementations satisfy the
+"enumerable requirements" property via the prose anchors and the
+[Appendix C](#appendix-c-index-of-normative-statements) index
+table.
+
+This revision's **informative** companion manifest is
+[`docs/public/specs/manifests/openapm-v0.2.requirements.yml`](/apm/specs/manifests/openapm-v0.2.requirements.yml)
+with the shape sketched above (id, keyword, section,
+conformance_class, plus optional fixture/oracle paths and
+round-trip carve-outs). The companion is informative and exists
+as a derived inventory; it is also the trip wire the
+spec-conformance CI job can use, when this draft is explicitly selected
+for assessment, to detect silent drift between the
+canonical spec anchors, the Appendix C reader-aid table, and the
+test marker coverage. The inactive foundation does not switch that
+selection from v0.1; the combined successor owns complete executable
+bindings and assessment. Implementations MAY consume it in this revision but
+MUST NOT depend on its presence for normative conformance until
+a future revision lifts the reservation. Its `spec_version` is exactly
+`v0.2.0`; `requirements_format_version: "1"` and the unchanged
+`requirements-v0.1.schema.json` identify the informative format, not
+the assessed specification revision.
+
+---
+
+## Citing this specification
+
+External documents, tooling, and conformance statements MUST cite
+this specification using a stable URL. The following shortlinks are
+prepared under the docs site; draft availability does not imply ratification:
+
+| URL | Resolves to | Use when |
+|------------------------------------------------------|------------------------------|----------|
+| `https://microsoft.github.io/apm/spec/v0.2.0` | This exact corrective revision | Toolchain or test fixture pin after publication. Its route identifies only v0.2.0, never a later patch. |
+| `https://microsoft.github.io/apm/spec/v0.2` | This minor's initial corrective revision | Minor-line entry point; use the exact revision for evidence. |
+| `https://microsoft.github.io/apm/spec/v0.1` | Preserved previous minor | Existing citations retain their previous interpretation and availability. |
+| `https://microsoft.github.io/apm/spec/latest` | Newest ratified version | Human citation in prose. Toolchains MUST NOT pin to `latest`; pin to a versioned URL. |
+| `https://microsoft.github.io/apm/spec` | Alias of `latest` | Short prose citation. Same restriction as `latest` -- do not pin tooling. |
+
+The JSON Schemas published alongside this specification (Appendix
+A) are themselves identified by the `$id` URL embedded in each
+schema. Toolchains MUST pin to the `$id` URL verbatim; the schema
+files are byte-immortal at those URLs for the lifetime of this
+version. The exact v0.2.0 content route is `/specs/openapm-v020/`.
+Any future patch needs a distinct artifact and exact route; it MUST NOT
+replace this artifact at that route. The existing `latest` and `/spec`
+aliases are unchanged during preparation and may advance only at actual
+ratification.
+
+## Appendix A. Normative JSON Schemas (inline)
+
+The machine-readable schemas backing this specification are
+reused unchanged from the previous minor and are normative. This corrective
+revision introduces no wire-schema change; schema identities do not follow
+the specification's version number.
+
+The unchanged wire-schema `$id` values are:
+
+- `https://microsoft.github.io/apm/specs/schemas/manifest-v0.1.schema.json`
+- `https://microsoft.github.io/apm/specs/schemas/lockfile-v0.1.schema.json`
+- `https://microsoft.github.io/apm/specs/schemas/policy-v0.1.schema.json`
+
+The informative requirements format separately reuses
+`https://microsoft.github.io/apm/specs/schemas/requirements-v0.1.schema.json`.
+
+| Schema | Authoritative source (in-tree) |
+|-----------------------|------------------------------------------------------------------------------------------------------|
+| Manifest (`apm.yml`) | [`schemas/manifest-v0.1.schema.json`](/apm/specs/schemas/manifest-v0.1.schema.json) (JSON Schema 2020-12). |
+| Lockfile (`apm.lock.yaml`) | [`schemas/lockfile-v0.1.schema.json`](/apm/specs/schemas/lockfile-v0.1.schema.json) (JSON Schema 2020-12). |
+| Policy (`apm-policy.yml`) | [`schemas/policy-v0.1.schema.json`](/apm/specs/schemas/policy-v0.1.schema.json) (JSON Schema 2020-12). |
+| Claude-Code marketplace (informational, emitted output) | `tests/fixtures/schemas/claude-code-marketplace.schema.json` |
+| Claude-Code plugin (informational, emitted output) | `tests/fixtures/schemas/claude-code-plugin.schema.json` |
+
+The reference Python validator `src/apm_cli/policy/schema.py`
+remains in-tree as a **non-normative cross-reference** for
+implementers; the JSON Schema is authoritative. Schemas for
+manifest and lockfile validation are JSON-Schema-only in this revision; a
+reference Python validator MAY be added in a future minor revision
+without normative effect.
+
+Where a JSON Schema and the prose of this specification disagree,
+the **prose** is authoritative and the schema is treated as an
+errata candidate.
+
+**Retained schema limitations (informative).** The manifest schema has a
+known source-key discriminator limitation: it rejects `git` entries with a
+`path` modifier and `id` entries with an explicit `registry` modifier.
+Those forms remain governed by [Section 4.3.2](#432-object-form). Separately,
+the schema types `policy.hash` as a string without validating its digest
+envelope; the applicable requirements in [req-mf-018](#req-mf-018) and
+[req-lk-016](#req-lk-016) remain in force. Schema validation
+alone is not a complete acceptance or conformance oracle for these cases.
+This note does not waive semantic validation or change any preserved schema
+file or identity; schema repairs require separately versioned artifacts.
+
+---
+
+## Appendix B. Registry HTTP API (reserved)
+
+The registry HTTP API is **non-normative** in this revision.
+
+The companion page
+[`registry-http-api.md`](../../reference/registry-http-api/) is
+informational. A future revision may define the wire
+contract normatively once independent server implementations exist.
+Until then, conforming Consumers MAY implement the wire contract
+described in the companion page but MUST NOT claim normative
+Registry wire conformance against this revision, with the single exception of
+the trust-anchor expectation in [req-rg-001](#req-rg-001).
+
+Reserved for a future revision (sketch only, non-normative here;
+uppercase keywords within sketches do not activate obligations):
+
+- Archive container binding (`application/gzip` over `tar`; reject
+ `application/zip`; see also [req-sc-004](#req-sc-004)).
+- Publisher attestation envelope (in-toto / SLSA; binds version to
+ publisher identity; see
+ [Section 10.12](#1012-publisher-provenance-and-attestations-reserved)).
+- Yank / withdrawal semantics (see
+ [Section 7.9](#79-version-withdrawal-reserved)).
+
+The broader wire-contract slot remains reserved without renumbering
+the existing conformance classes or disabling [req-rg-001](#req-rg-001).
+
+---
+
+## Appendix C. Index of normative statements
+
+| ID | Keyword | Section | Class |
+|------------------------------------------|---------|---------|-------------|
+| [req-mf-001](#req-mf-001) | MUST | 4.1 | producer |
+| [req-mf-002](#req-mf-002) | MUST | 4.1 | producer |
+| [req-mf-003](#req-mf-003) | MUST | 4.1 | producer |
+| [req-mf-004](#req-mf-004) | SHOULD | 4.1 | producer |
+| [req-mf-005](#req-mf-005) | MUST | 4.2.1 | producer |
+| [req-mf-006](#req-mf-006) | MUST | 4.1 | consumer |
+| [req-mf-007](#req-mf-007) | MUST | 4.3.1 | consumer |
+| [req-mf-008](#req-mf-008) | MUST | 4.3.3 | consumer |
+| [req-mf-009](#req-mf-009) | MUST | 4.3.4 | consumer |
+| [req-mf-010](#req-mf-010) | MUST | 4.3.2 | consumer |
+| [req-mf-011](#req-mf-011) | MUST | 4.3.2 | consumer |
+| [req-mf-012](#req-mf-012) | MUST | 4.3.6 | consumer |
+| [req-mf-013](#req-mf-013) | MUST | 4.5 | consumer |
+| [req-mf-014](#req-mf-014) | MUST | 4.2.3 | producer |
+| [req-mf-015](#req-mf-015) | MUST | 4.2.3 | producer |
+| [req-mf-016](#req-mf-016) | MUST | 4.3.5 | consumer |
+| [req-mf-017](#req-mf-017) | MUST | 4.7 | producer |
+| [req-mf-018](#req-mf-018) | MUST | 4.6.1 | consumer |
+| [req-mf-019](#req-mf-019) | MUST | 4.2.4 | consumer |
+| [req-mf-020](#req-mf-020) | MUST | 4.1 | consumer |
+| [req-mf-021](#req-mf-021) | MUST | 4.8 | producer |
+| [req-mf-022](#req-mf-022) | MUST | 4.3.2 | consumer |
+| [req-mf-023](#req-mf-023) | MUST | 4.5 | consumer |
+| [req-mf-024](#req-mf-024) | MUST | 4.3.2 | consumer |
+| [req-ext-001](#req-ext-001) | MUST | 4.1 | consumer |
+| [req-ext-002](#req-ext-002) | MUST | 4.1 | producer |
+| [req-lk-001](#req-lk-001) | MUST | 5.1 | consumer |
+| [req-lk-002](#req-lk-002) | MUST | 5.4 | consumer |
+| [req-lk-003](#req-lk-003) | MUST | 5.2 | consumer |
+| [req-lk-004](#req-lk-004) | MUST | 5.4 | consumer |
+| [req-lk-005](#req-lk-005) | MUST | 5.5 | consumer |
+| [req-lk-006](#req-lk-006) | MUST | 5.5 | consumer |
+| [req-lk-007](#req-lk-007) | SHOULD | 5.5 | consumer |
+| [req-lk-008](#req-lk-008) | MUST | 5.6 | consumer |
+| [req-lk-009](#req-lk-009) | MUST | 5.6 | consumer |
+| [req-lk-010](#req-lk-010) | MUST | 5.6 | consumer |
+| [req-lk-011](#req-lk-011) | MUST | 5.2 | consumer |
+| [req-lk-012](#req-lk-012) | MUST | 5.2 | consumer |
+| [req-lk-013](#req-lk-013) | MUST | 5.2 | consumer |
+| [req-lk-014](#req-lk-014) | MUST | 5.2 | consumer |
+| [req-lk-015](#req-lk-015) | MUST | 5.6.4 | consumer |
+| [req-lk-016](#req-lk-016) | MUST | 5.2 | consumer |
+| [req-lk-017](#req-lk-017) | MUST | 5.2 | consumer |
+| [req-lk-018](#req-lk-018) | SHOULD | 5.5 | consumer |
+| [req-lk-019](#req-lk-019) | MUST | 5.2 | consumer |
+| [req-lk-020](#req-lk-020) | MUST | 5.2 | consumer |
+| [req-lk-021](#req-lk-021) | MUST | 5.2 | consumer |
+| [req-lk-022](#req-lk-022) | MUST | 5.2 | consumer |
+| [req-lk-023](#req-lk-023) | MUST | 5.5 | consumer |
+| [req-pl-001](#req-pl-001) | MUST | 6.1 | governance |
+| [req-pl-002](#req-pl-002) | MUST | 6.2 | governance |
+| [req-pl-003](#req-pl-003) | MUST | 6.4 | governance |
+| [req-pl-004](#req-pl-004) | MUST | 6.4 | governance |
+| [req-pl-005](#req-pl-005) | MUST | 6.5 | governance |
+| [req-pl-006](#req-pl-006) | MUST | 6.4 | governance |
+| [req-pl-007](#req-pl-007) | MUST | 6.3.1 | governance |
+| [req-pl-008](#req-pl-008) | MUST | 6.3.1 | governance |
+| [req-pl-009](#req-pl-009) | MUST | 6.6 | governance |
+| [req-pl-010](#req-pl-010) | MUST | 6.2 | governance |
+| [req-pl-011](#req-pl-011) | MUST | 6.1.1 | governance |
+| [req-pl-012](#req-pl-012) | MUST | 6.1.1 | governance |
+| [req-pl-013](#req-pl-013) | MUST | 6.8 | governance |
+| [req-pl-014](#req-pl-014) | MUST | 6.8 | governance |
+| [req-pl-015](#req-pl-015) | MUST | 6.3.5 | governance |
+| [req-pl-016](#req-pl-016) | MUST | 6.8 | governance |
+| [req-pl-017](#req-pl-017) | MUST | 6.8 | governance |
+| [req-pl-018](#req-pl-018) | MUST | 6.3.1 | governance |
+| [req-rs-001](#req-rs-001) | MUST | 7.2 | consumer |
+| [req-rs-002](#req-rs-002) | MUST | 7.3 | consumer |
+| [req-rs-003](#req-rs-003) | MUST | 7.3 | consumer |
+| [req-rs-004](#req-rs-004) | MUST | 7.5 | consumer |
+| [req-rs-005](#req-rs-005) | MUST | 7.6 | consumer |
+| [req-rs-006](#req-rs-006) | MUST | 7.2 | consumer |
+| [req-rs-007](#req-rs-007) | MUST | 7.3 | consumer |
+| [req-rs-008](#req-rs-008) | MUST | 7.1 | consumer |
+| [req-rs-009](#req-rs-009) | MUST | 7.5.1 | consumer |
+| [req-rs-010](#req-rs-010) | MUST | 7.2 | consumer |
+| [req-rs-011](#req-rs-011) | MUST | 7.7 | consumer |
+| [req-rs-012](#req-rs-012) | MUST | 7.7 | consumer |
+| [req-rs-013](#req-rs-013) | MUST | 7.2 | consumer |
+| [req-rs-014](#req-rs-014) | MUST | 7.3.1 | consumer |
+| [req-rs-015](#req-rs-015) | MUST | 7.5 | consumer |
+| [req-rs-016](#req-rs-016) | MUST | 7.2 | consumer |
+| [req-rs-017](#req-rs-017) | MUST | 7.7 | consumer |
+| [req-pr-001](#req-pr-001) | MUST | 8.2 | consumer |
+| [req-pr-002](#req-pr-002) | MUST | 8.3 | consumer |
+| [req-pr-003](#req-pr-003) | MUST | 8.3 | consumer |
+| [req-pr-004](#req-pr-004) | MUST | 7.8 | producer |
+| [req-pr-005](#req-pr-005) | SHOULD | 7.8 | producer |
+| [req-pr-006](#req-pr-006) | MUST | 8.1 | consumer |
+| [req-pr-007](#req-pr-007) | MUST | 8.1 | consumer |
+| [req-tg-001](#req-tg-001) | MUST | 8.4 | consumer |
+| [req-tg-002](#req-tg-002) | MUST | 8.5 | consumer |
+| [req-tg-003](#req-tg-003) | MUST | 8.5 | consumer |
+| [req-tg-004](#req-tg-004) | MUST | 4.2.1 | consumer |
+| [req-tg-005](#req-tg-005) | MUST | 8.5 | consumer |
+| [req-tg-006](#req-tg-006) | MUST | 8.5 | consumer |
+| [req-tg-007](#req-tg-007) | MUST | 8.5 | consumer |
+| [req-tg-008](#req-tg-008) | MUST | 8.5.3 | consumer |
+| [req-tg-009](#req-tg-009) | MUST | 8.5.1 | consumer |
+| [req-tg-010](#req-tg-010) | MUST | 8.5.4 | consumer |
+| [req-tg-011](#req-tg-011) | MUST | 8.5.5 | consumer |
+| [req-tg-012](#req-tg-012) | MUST | 8.5.6 | consumer |
+| [req-tg-013](#req-tg-013) | MUST | 8.5.7 | consumer |
+| [req-tg-014](#req-tg-014) | MUST | 8.5.8 | consumer |
+| [req-sc-001](#req-sc-001) | MUST | 10.4 | consumer |
+| [req-sc-002](#req-sc-002) | MUST | 10.9 | consumer |
+| [req-sc-003](#req-sc-003) | MUST | 10.3 | consumer |
+| [req-sc-004](#req-sc-004) | MUST | 10.5 | consumer |
+| [req-sc-005](#req-sc-005) | MUST | 10.3 | consumer |
+| [req-sc-006](#req-sc-006) | MUST | 4.2.3 | consumer |
+| [req-sc-007](#req-sc-007) | MUST | 10.3 | consumer |
+| [req-sc-008](#req-sc-008) | SHOULD | 10.3 | consumer |
+| [req-sc-009](#req-sc-009) | MUST | 10.13 | consumer |
+| [req-sc-010](#req-sc-010) | MUST | 10.13 | consumer |
+| [req-sc-011](#req-sc-011) | MUST | 10.14 | consumer |
+| [req-sc-012](#req-sc-012) | MUST | 10.14 | consumer |
+| [req-sc-013](#req-sc-013) | MUST | 10.3 | consumer |
+| [req-sc-014](#req-sc-014) | MUST | 10.15 | consumer |
+| [req-sc-015](#req-sc-015) | MUST | 10.16 | consumer |
+| [req-rg-001](#req-rg-001) | MUST | 11.3.3 | registry |
+| [req-cf-001](#req-cf-001) | MUST | 12.5 | consumer |
+| [req-cf-002](#req-cf-002) | MUST | 12.3 | consumer |
+
+**Total normative statements: 123** (118 MUST, 5 SHOULD).
+
+The [req-mf-016](#req-mf-016) consumer entry covers source anchoring,
+user-scope admission, remote-repository containment, and local
+internal-symlink handling. Its identifier, class, and MUST keyword
+are unchanged by the local-path correction; clauses (a)-(d) remain
+one indexed requirement.
+
+---
+
+## Appendix D. Revision history
+
+### v0.2.0 corrective foundation (draft, inactive; not published)
+
+This distinct minor reconciles the local-source correction in
+[microsoft/apm#2818](https://github.com/microsoft/apm/issues/2818)
+and the audit current-intent/read-only correction in
+[microsoft/apm#2816](https://github.com/microsoft/apm/issues/2816).
+Only those two contracts change behavior. The approved local-source
+clauses replace [req-mf-016](#req-mf-016)'s previous blanket project-root
+escape rejection with source-provenance admission, original declaring-source
+anchors, remote-repository containment, and internal local-symlink rules.
+[req-lk-023](#req-lk-023) adds current-intent precedence, source-derived
+expected output, comparison of prior target claims, and read-only replay
+with unsupported native writers refused before invocation. Section 8.4
+cross-references that selection order; [req-pl-014](#req-pl-014)
+distinguishes default-mode advisory drift from CI/conformance failures.
+Statement count from the current-main baseline: **122 -> 123**
+(118 MUST, 5 SHOULD). The complete previous-minor 0.1.40 amendment,
+including [req-pl-018](#req-pl-018), its related identity, policy,
+disclosure, and security text, and Section 9.2's deterministic-evaluation
+allowance, is inherited rather than introduced by this correction.
+No other requirement identifier is added, removed, or renumbered.
+
+**Classification and preservation.** These are substantive changes to
+the conformance contract under Sections 9.1, 9.2, and 9.4, not same-minor
+errata. The previous minor's operative specification, requirements
+manifest, schemas, and conformance interpretation are retained from exact
+current main `f8df1b751efc30b32dc01b125616b51f777b4c81`. Its artifact
+and manifest are retained byte-for-byte, without a new announcement.
+That previous
+minor remains available and supported indefinitely, with no removal date.
+Section 9.5 constrains announcement-to-removal, not parallel availability
+of a new minor. No migration exception is needed or claimed.
+
+An admitted local sibling outside the project root can satisfy this
+revision while violating the old blanket rule. Neither this correction
+nor any future test bindings establishes that the reference CLI ever
+conformed to the previous minor's req-mf-016.
+
+**Authorization and pending record.** The maintainer authorized immediate
+drafting of this bounded normative reconciliation and waived only the
+14-day public-comment period in Section 9.3 step 4 in the
+[one-amendment public decision](https://github.com/microsoft/apm/issues/2818#issuecomment-5558647529).
+This is not a conformance waiver. At least two qualified non-author human
+reviewers remain required under Section 9.3 step 3: one with implementation
+experience and one with consumer/integrator experience. Implementation
+evidence and ratification remain required; the AI advisory panel does not
+supply those human approvals. This foundation record asserts none of them
+complete and records no announcement, publication, or ratification date.
+The publication owner supplies the actual record later. Section 9 is
+retained unchanged from current main, not from the stale original branch.
+
+The original #2820 is retained as the inactive corrective-spec foundation,
+not the implementation-conformance change. Its combined local-source and
+audit successor owns the complete executable assessment and bindings,
+reanchored to the exact draft and current-main preservation baseline.
+Existing v0.1 remains active; this foundation does not activate a new
+assessment selector, runtime behavior, normative publication, or latest alias.
+
+No previously reserved workspace, nesting, attestation, HTTP wire,
+internationalization, range-widening, withdrawal, or default-frozen feature
+is activated. Existing schema identities and independently versioned
+companions are reused without alteration.
+
+### Previous-minor history (informative)
+
+The following entries are retained as history, not renewed feature promises
+or evidence of approval for this revision. The operative reservation text
+above controls this revision.
+
+| Version | Date | Changes |
+|---------|------------|----------------------------------------------------------|
+| 0.1 | 2026-05-10 | Initial editor's Working Draft. |
+| 0.1-r2 | 2026-05-17 | Round-2 adversarial revision. Closed mandatory FOLDs F1-F10: pinned semver dialect (node-semver + semver 2.0.0); tri-modal transitive conflict resolution; vendor-host neutrality (default_host, pluggable policy discovery, host class via PSL+aliases); hash envelope on every stored hash; canonical git tree-hash definition; mirror-tolerant fetch; producer release contract (tag-version alignment, SHOULD-sign); update operation semantics; lockfile_version monotonicity; reserved-slot prose for 11 deferrals (workspaces, x-* extensions, machine-readable conformance, update --aggressive, frozen-default flip, target registry companion, version yank, attestations, registry HTTP, mirror-tolerance, .agents/ partition); inline JSON Schemas in Appendix A; YAML safe subset; archive container binding; credential redaction. Conformance-statement count: 56 -> 83. Companion seed fixture tree shipped under `tests/fixtures/spec-conformance/`. |
+| 0.1.1 | 2026-05-24 | v1.1 editorial+defensive fold. Closed convergent round-2 followups: Section 12.3 CI-binding MUST-for-claim (req-cf-002); req-mf-019 class reclassification (producer -> consumer); three stale heading labels in req-cf-001 and Appendix E.4; depEntry oneOf source-key requirement plus new fixture `manifest/invalid-no-source-key.yml`; normative-count reconciliation across Section 1.3, Appendix C trailer, and this row; bare-hex pattern anchored to exactly 64 hex characters; req-sc-007 redaction scope extended to packed bundles, lockfiles, and audit records, plus producer secret-pattern refuse-to-pack rule; workspaces MUST-NOT-use in v0.1 (req-mf-021); nest-mode reject-in-v0.1 (req-rs-013); tag-name regex tightened to the semver.org 2.0.0 reference grammar; build-metadata tie-break rule (req-rs-014); mirror-tolerance editorial note (replicate-verbatim); req-rg-001 cross-references added in Section 7.5.1 and Section 10.5; bare-hex reader-tolerance deprecation horizon; interoperability informative note Section 6.1.2; conformance-summary precedence rule in Section 11.1; wildcard typo `x.y.x` -> `x.y.z`; resolved_by worked-example fragment in Section 7.4. Statement count: 83 -> 87. Drift-detection scaffolding lands in-spec and in-tree (informative machine-readable manifest at `docs/public/specs/manifests/openapm-v0.1.requirements.yml`; 4-way orphan_check + spec-conformance pytest suite + generated `CONFORMANCE.{md,json}` at repo root); Section 12.3 language updated to identify HTML anchors as the canonical source. No normative count change. |
+| 0.1.2 | 2026-05-28 | Round-3 spec-guardian editorial fold (no new normative statements; statement count remains 87). Section 11.3.2 Consumer enumeration appended `[req-rs-014]` and `[req-cf-002]` (closing drift vs Appendix C). req-lk-005 extended: writers MUST canonicalise the `dependencies` list in ascending lexicographic order of (`repo_url`, `virtual_path`) so frozen-install diffs are stable across implementations. req-sc-003 extended: consumers MUST drop the originating Authorization header before issuing a cross-host-class redirect (closes the mirror-redirect token-leak surface in Section 10.3). req-rg-001 extended with publish-side idempotency clause: a Registry MUST either reject a republish or accept ONLY if bytes are byte-identical to the previously-served bytes. Section 6.2 + Section 6.3.1 defaults pinned: `fetch_failure` defaults to `warn` and `dependencies.require_resolution` defaults to `project-wins` (mirrored as advisory `"default"` annotations in `policy-v0.1.schema.json`). Manifest schema `conflict_resolution` enum aligned to prose: renamed `intersect` -> `intersection-pick`, dropped `nest` from the v0.1 enum (`nest` remains reserved-for-v0.2 per req-rs-013, now noted via schema `$comment`). Mode B silent-extension detector landed in `.github/workflows/spec-conformance.yml` and `tests/spec_conformance/mode_b_detector.sh`; closes the named sole-implementer rot risk by gating PRs that add substantive code under critical paths (`primitives/`, `deps/`, `policy/`, `registry/`, `runtime/`, `install/`, `integration/`) without a spec citation, with auditable `apm-spec-waiver:` opt-out. |
+| 0.1.3 | 2026-06-16 | Spec-citation fold for the declarable integrity policy keys. Added two governance MUSTs under a new Section 6.8 "Integrity controls": [req-pl-013] (`security.integrity.require_hashes` -- fail-closed install when a resolved non-local dependency lacks a recorded hash in `apm.lock.yaml`, or the lockfile is absent/unreadable) and [req-pl-014] (`security.audit.fail_on_drift` -- audit exits non-zero on detected or indeterminate drift). Both keys are default-off and merge by logical OR (tighten-not-relax). Added the non-normative Section 6.3.6 `security` field reference and two merge-table rows; renumbered the governance conformance trailer 6.8 -> 6.9. Statement count: 87 -> 89 (84 MUST, 5 SHOULD). NOTE: a sibling spec-citation amendment also edits the shared count sites (Section 1.3, Appendix C trailer, this revision history); whichever lands second reconciles the cumulative total and takes the union of the added Appendix C rows. |
+| 0.1.4 | 2026-06-16 | Normative addition (semver-zero `0.x` minor): added `[req-pl-015]` (Section 6.3.5, governance MUST) codifying unmanaged-artifact surfacing completeness -- a governance implementation evaluating policy over a populated primitive target tree MUST surface every file under a managed primitive target directory that is neither recorded in `apm.lock.yaml` nor matched by a configured `unmanaged_files.exclude` glob, each with its unmanaged reason and a supplemental dependency/MCP deny-conflict note where applicable; the inferred primitive type is carried where determinable and omitted otherwise; an excluded path MUST NOT be surfaced even when it also matches a deny pattern. The requirement body is structured as sub-clauses (a)/(b)/(c) so each obligation is individually citable. Added the `unmanaged_files.exclude` row to the Section 6.4 merge table (additive union, deduplicated, parent order preserved). The requirement governs reporting COMPLETENESS only; enforcement stays governed by `unmanaged_files.action`. Reconciled with the sibling 0.1.3 amendment (req-pl-013/req-pl-014): cumulative statement count 89 -> 90 (85 MUST, 5 SHOULD); Appendix C carries the union of all three new governance rows. |
+| 0.1.5 | 2026-06-20 | Spec-citation fold for the executable primitive approval gate. Added new Section 10.13 "Executable primitive approval gate" with two consumer MUSTs: [req-sc-009] (deny deployment of any hook, bin, MCP server, or canvas extension from a dependency not listed in the effective `allowExecutables` approval set when the block is present -- fail closed) and [req-sc-010] (persist interactive approval decisions user-locally, not in the project `apm.yml`, so one developer's approval cannot propagate via VCS to teammates). Added rows 11 and 12 to the Section 10.11 summary table. Section 11.3.2 Consumer enumeration and Appendix C updated. Statement count: 90 -> 92 (87 MUST, 5 SHOULD). |
+| 0.1.6 | 2026-06-25 | Spec-citation fold for executable trust precedence and audit fidelity. Added Section 10.14 with two consumer MUSTs: [req-sc-011] (executable trust resolves through one deny-wins precedence; an org executables.deny/deny_all overrides any project or user grant; the install gate and the audit MUST reach the identical outcome via the shared resolver) and [req-sc-012] (a required package's audit asserts lockfile presence, not executable deployment; a present-but-withheld required package satisfies the presence requirement and surfaces a distinct withheld-executable signal). Added rows 13 and 14 to the Section 10.11 summary table. Section 11.3.2 and Appendix C updated. Statement count 92 -> 94 (89 MUST, 5 SHOULD). |
+| 0.1.7 | 2026-06-27 | Spec-citation fold for lockfile inventory metadata (closes the #1888 Mode-B silent-extension gate). Added [req-lk-019] (Section 5.2, consumer MUST): the optional per-entry `name` and `version` fields are self-asserted inventory metadata only -- preserved on round-trip per [req-lk-011], never a trust anchor, and never an identity, deduplication, or frozen-replay key (identity/replay derive solely from `repo_url`, `resolved_commit`, `resolved_tag`/`constraint`, and the recorded hash envelopes); their presence is additive and MUST NOT change `lockfile_version`. Added the `name` row to the Section 5.2 per-entry field table and broadened the `version` row note to non-semver sources; added `name` to the `entry` `$defs` in `lockfile-v0.1.schema.json` (sibling of `declared_license`). Section 11.3.2 Consumer enumeration and Appendix C updated. Statement count: 94 -> 95 (90 MUST, 5 SHOULD). |
+| 0.1.8 | 2026-06-29 | Normative amendment (semver-zero `0.x` minor) to [req-lk-012]: redefined the `deployed_file_hashes` / `local_deployed_file_hashes` domain from "bytes as written to disk" to the *canonical content* -- UTF-8 text (decodable, no NUL byte) is hashed over its `\r\n` -> `\n` normalized form (a lone `\r` is preserved); binary is hashed raw. This makes the per-deployed-file hash platform-invariant so `apm audit --ci` no longer reports a false `content-integrity` drift when a file is checked out with `\r\n` on Windows (`core.autocrlf=true`) and `\n` on POSIX (apm#1952); it harmonizes `content-integrity` with the drift-replay normalizer. Preserving a bare `\r` keeps the carriage-return smuggling vector hash-visible. [req-lk-017] reworded to re-verify against the [req-lk-012] canonical domain rather than raw on-disk bytes (consistency, not a new obligation). Migration: lockfiles whose hashes were recorded on Windows before this amendment carry `\r\n`-domain hashes; one `apm install` re-records them in the canonical domain. No statement-count change (existing MUST modified, none added); 95 (90 MUST, 5 SHOULD). Subject to the Section 9.3 amendment panel + comment window. |
+| 0.1.9 | 2026-07-04 | Spec-citation fold for network-free lockfile replay (closes the srobroek Mode-B silent-extension gate on the lockfile-seeded resolver cache). Added [req-rs-015] (Section 7.5, consumer MUST): a non-update install (not `apm update` and not `--refresh`/re-resolution) MUST replay a lockfile entry that records a `resolved_commit` by reusing that recorded commit as the resolution result without issuing any network ref-resolution -- no commits-API query, no `git ls-remote`, no clone -- for that entry, PROVIDED drift detection against the manifest reference does not require re-resolution; on drift or under an explicit `apm update`/`--refresh` ([req-rs-011], [req-rs-012]) the consumer MUST re-resolve over the network as usual. The recorded `resolved_commit` is the lockfile's resolution anchor ([req-lk-003]); replaying it is scoped to entries recording a `resolved_commit` WITHOUT a `resolved_tag` (git-literal and untagged-branch entries per [req-rs-003]), leaves content integrity subject to `tree_sha256` ([req-lk-015]) and `resolved_hash` ([req-lk-013]), and defines drift locally as the manifest `ref` no longer being character-equal to the lockfile `resolved_ref`; this makes a warm install of an already-locked reference network-free at the resolution step, extending the reproducible-and-offline resolution guarantee to commit-pinned and branch-tracking entries not covered by the semver-range equivalence of [req-rs-004]. Section 7.11 and Section 11.3.2 Consumer enumerations and Appendix C updated. Statement count: 95 -> 96 (91 MUST, 5 SHOULD). Subject to the Section 9.3 amendment panel + comment window. |
+| 0.1.10 | 2026-07-04 | Spec-citation fold for Antigravity native instruction rules (closes the #1984 Mode-B silent-extension gate). Added [req-tg-005] (Section 8.5, consumer MUST): Antigravity instruction rules are deployed under `.agents/rules/.md`, `applyTo` is rendered as `trigger: glob` plus `globs` (scalar or sequence), and compile-time deduplication only treats expected Antigravity rule filenames as deployed rules so unrelated `.md` files cannot suppress `AGENTS.md` content. Added `antigravity` to the Section 4.2.1 canonical target set and clarified that `all` excludes explicit-only targets. Statement count: 96 -> 97 (92 MUST, 5 SHOULD). |
+| 0.1.11 | 2026-07-09 | Spec-guardian editorial+defensive fold on the Antigravity instruction-rule contract (no new normative statements; statement count remains 97 (92 MUST, 5 SHOULD)). Section 4.2.1: defined the **auto-detectable** vs **explicit-only** target taxonomy deterministically (a target is auto-detectable when the OpenAPM Target Registry publishes at least one detection predicate) and rewrote the `all` expansion to key off it, naming `agent-skills` and `antigravity` as the v0.1 explicit-only set (with a Section 8.4 cross-reference). [req-tg-001] extended: a target registered without a detection predicate MUST NOT be auto-detected and MUST be excluded from `all`, generalising the prior `agent-skills`-only clause to cover `antigravity`. [req-tg-005] extended: pinned a canonical `globs` representation (YAML scalar for exactly one glob, YAML block sequence for two or more, no frontmatter block when `applyTo` is absent) so deployed-file content hashes are reproducible across implementations; redefined the deduplication scope from "expected Antigravity rule filenames" to filenames derived from the currently-resolved instruction primitives recorded in `apm.lock.yaml` and the manifest, closing a fail-open interpretation where an unrelated `.agents/rules/*.md` file could suppress `AGENTS.md` content; lowercased the `antigravity` identifier and added an editorial note scoping the normative citation of the concrete deploy path. [req-tg-002] subdirectory-partition list updated to include `.agents/rules/`. No normative count change. |
+| 0.1.12 | 2026-07-10 | Spec-citation fold for inactive-target lockfile reconciliation. Added [req-lk-020] (Section 5.2, consumer MUST): a non-frozen rewrite with a declared target set preserves paths attributable to current, another declared, or implementation-recognized targets that activate outside the manifest; removes prior paths attributable to none of them; applies the same decision to per-entry and top-level deployed-file lists and hash maps; and preserves prior paths when no target set is declared or attribution is indeterminate. Statement count: 97 -> 98 (93 MUST, 5 SHOULD). |
+| 0.1.13 | 2026-07-14 | Defensive clarification of existing lockfile requirements (no new normative statements; statement count remains 98 (93 MUST, 5 SHOULD)). [req-lk-003] now requires a conformance audit to reject disagreement between a full-SHA manifest pin and `resolved_commit`. [req-lk-020] now preserves paths freshly deployed by an active dependency when orphan cleanup encounters the same path under a prior dependency identity. |
+| 0.1.14 | 2026-07-15 | Spec-citation fold for complete repository identity through resolution and materialization (closes #2191). Added [req-rs-016] (Section 7.2, consumer MUST): repository identity includes normalized host, explicit port, and the complete credential-free repository path; distinct identities MUST NOT share cached source material merely because they use the same ref or a common path prefix; identical identity and ref MAY reuse cached source material. Section 7.11 and Section 11.3.2 Consumer enumerations and Appendix C updated. Statement count: 98 -> 99 (94 MUST, 5 SHOULD). |
+| 0.1.15 | 2026-07-15 | Spec-citation fold for lossy agent target conversion (closes the #2181 Mode-B silent-extension gate). Added [req-tg-006] (Section 8.5, consumer MUST): target-native agent conversion either preserves source-declared capability restrictions exactly or emits a default-visible, actionable diagnostic naming the source agent, each discarded field, and the broader-access risk before the overall operation returns; malformed or non-mapping frontmatter receives an unverifiable-restriction diagnostic. The requirement does not define a target-native restriction encoding or mandate a nonzero exit status. Statement count: 99 -> 100 (95 MUST, 5 SHOULD). |
+| 0.1.16 | 2026-07-17 | Spec-citation fold for dropped-target merge-hook reconciliation (closes the #2253 Mode-B silent-extension gate). Added [req-lk-021] (Section 5.2, consumer MUST): extends [req-lk-020]'s target-reconciliation preserve/remove decision to merge-based hook configuration and its ownership record, since that state is deliberately outside `deployed_files`/`local_deployed_files` tracking and so was never reachable by req-lk-020's literal text -- narrowing a project's declared target set now also reconciles the dropped target's consumer-owned merge-hook entries, while preserving entries not carrying consumer ownership and preserving state for targets still attributable per req-lk-020's own (a)-(c) test. Section 11.3.2 Consumer enumeration and Appendix C updated. Statement count: 100 -> 101 (96 MUST, 5 SHOULD). |
+| 0.1.17 | 2026-07-17 | Spec-citation fold for deployment-ledger owner integrity (closes the PR #2292 Mode-B silent-extension gate on the policy engine and audit exit contract). Added [req-pl-016] (Section 6.8, governance MUST): a canonical deployment-ledger owner that does not resolve to a dependency entry in `apm.lock.yaml` is a hard integrity failure, independent of `security.audit.fail_on_drift`; an audit MUST exit non-zero in BOTH default and CI modes when such a stale ownership record is present, MUST NOT mutate deployed bytes (for example under strip) while ownership is invalid, and MUST name each affected locator with its invalid owner(s) plus one reconcile-ownership remediation. Explicitly distinguished from ordinary deployed-file drift, which stays advisory in default mode per [req-pl-014]; a durable ownership record is not a file edit, so its staleness surfaces unconditionally. Reconciled the Section 6.9 and Section 11.3.4 governance enumerations (the latter also gained the previously-missing [req-pl-015] row). Section 1.3 and Appendix C count sites updated. Statement count: 101 -> 102 (97 MUST, 5 SHOULD). |
+| 0.1.18 | 2026-07-17 | Spec-citation fold for project-scope post-install compilation guidance (closes #2057). Added [req-tg-007] (Section 8.5, consumer MUST): after a non-dry-run project install adds a package, a consumer that finds dependency instruction primitives for an active root-context compilation target emits a default-visible diagnostic naming the follow-up compile operation and root context output class. The diagnostic is suppressed for dry runs, no-op installs, trees without dependency instructions, and target sets that deploy instructions as native per-file rules. Section 8.7 and Section 11.3.2 Consumer enumerations and Appendix C updated. Statement count: 102 -> 103 (98 MUST, 5 SHOULD). |
+| 0.1.19 | 2026-07-18 | Spec-citation fold for stale persisted skill subsets (closes #2116). Added [req-mf-022] (Section 4.3.2, consumer MUST): when a non-empty manifest `skills:` subset matches no available skill in a dependency that exposes selectable skills, the consumer emits a default-visible diagnostic naming the dependency plus the requested and available skill names before install returns; the diagnostic does not by itself require a nonzero install status. Section 11.3.2 Consumer enumeration and Appendix C updated. Statement count: 103 -> 104 (99 MUST, 5 SHOULD). |
+| 0.1.20 | 2026-07-30 | Defensive amendment of [req-lk-006] (no new normative statement; count remains 104 (99 MUST, 5 SHOULD)): frozen validation now covers direct MCP server names and configurations as well as package pins, runs before lockfile, target-config, deployment, or cache mutation, and rejects manifest dependency mutation. |
+| 0.1.21 | 2026-07-31 | Spec-citation fold for package-declared target restrictions (closes #2321 Mode-B silent-extension gate). Added [req-tg-008] (Section 8.5.3, consumer MUST): a consumer MUST treat a package's declared `target:`/`targets:` field as a restriction-only filter on all target-scoped primitive integration; if the field resolves to a non-empty set that does not contain `all`, the consumer MUST NOT deliver that package's primitives to any active integration target not in the declared set; the filter composes by intersection with the consumer-side per-dependency `targets:` filter and can only narrow, never expand. Section 8.7, Section 11.3.2 Consumer enumeration, and Appendix C updated. Statement count: 104 -> 105 (100 MUST, 5 SHOULD). |
+| 0.1.22 | 2026-07-31 | Spec-citation fold for deterministic configured-host credential isolation (closes #2338). Added [req-sc-013] (Section 10.3, consumer MUST): a consumer selects one effective host class before credential resolution, applies documented deterministic precedence when configuration signals overlap, exposes only credentials belonging to the selected class to requests and child processes, and preserves an explicit non-default port in both transport and credential scope. Clarified [req-sc-005] so this configured override is not prohibited by its default host-class collapse rule. Section 1.3, Section 10.11, Section 11.3.2 Consumer enumeration, and Appendix C updated. Statement count: 105 -> 106 (101 MUST, 5 SHOULD). |
+| 0.1.23 | 2026-07-31 | Spec-citation fold for case-preserving dependency materialization (closes #2347). Added [req-lk-022] (Section 5.2, consumer MUST): a consumer that case-folds repository identity but retains different source spelling records `materialization_repo_url`, validates it maps to the same canonical identity, excludes it from identity/cache/sort/trust decisions, preserves exact virtual-path casing, and either transactionally migrates one stale case variant or fails closed without deleting colliding paths. Defined rollback semantics for case-only rename and preserved interrupted recovery state. Added the field to the lockfile schema and conformance fixture, plus migration and collision conformance oracles. Hardened lockfile schema: `repo_url` now carries `minLength: 1` to match the prose requirement that git-sourced entries provide a non-empty canonical identifier ([req-lk-003](#req-lk-003)). Section 5.7, Section 10.11, Section 11.3.2, and Appendix C updated. Statement count: 106 -> 107 (102 MUST, 5 SHOULD). |
+| 0.1.24 | 2026-08-03 | Spec-citation fold for fail-closed Kiro agent vocabulary gate (closes #2089 Mode-B silent-extension gate). Added [req-tg-009] (Section 8.5.1, consumer MUST): a consumer deploying an agent primitive into a target with a fixed, enumerable capability vocabulary MUST fail closed -- writing zero bytes and emitting an actionable diagnostic -- if any source-declared tool falls outside the approved set; the gate fires per agent independently and does not block vocabulary-conformant sibling agents; the gate applies only to targets included in the effective intersection under [req-tg-008]; content-identity fast-paths are not exempt. Added editorial note naming the Target Registry companion as the vocabulary authority and mandating version-pinning for conformance testing. Section 8.7, Section 11.3.2 Consumer enumeration, and Appendix C updated. Statement count: 107 -> 108 (103 MUST, 5 SHOULD). |
+| 0.1.25 | 2026-08-03 | Spec-citation fold for portable project-scoped Claude hooks (closes #2408 Mode-B silent-extension gate). Added [req-tg-010] (Section 8.5.4, consumer MUST): a project-scoped native hook that may launch outside the consumer project anchors its generated command through the target portable project-directory environment variable, preserves the relative hook path, executes successfully when the variable identifies the consumer project, and never embeds an absolute checkout path; shell-expansion path syntax is rejected. Claude uses `CLAUDE_PROJECT_DIR` in POSIX and `$env:CLAUDE_PROJECT_DIR` in PowerShell. Section 8.7, Section 11.3.2 Consumer enumeration, and Appendix C updated. Statement count: 108 -> 109 (104 MUST, 5 SHOULD). |
+| 0.1.26 | 2026-08-03 | Spec-citation fold for VS Code OCI/Docker MCP runtime argument resolution (closes #2438). Added [req-mf-023] (Section 4.5, consumer MUST): a non-secret runtime variable resolves every `{name}` occurrence across package runtime and package arguments, an unresolved template is never written literally, and package-scoped secret metadata uses VS Code secret-input references instead of generated config bytes. Section 4.9, Section 11.3.2, and Appendix C updated. Statement count: 109 -> 110 (105 MUST, 5 SHOULD). |
+| 0.1.27 | 2026-08-03 | Spec-citation fold for object-form registry identity preservation on CLI-driven manifest updates (closes the PR #2166 Mode-B silent-extension gate). Added [req-mf-024] (Section 4.3.2, consumer MUST): a consumer MUST NOT silently rewrite an existing `id:`-form (registry-sourced) manifest entry into a `git:`-form entry when persisting a subsequent CLI-driven update (e.g. an additive `--skill` pin) for the same dependency identity; when a CLI-parsed reference is ambiguous about its source but an existing manifest entry for the same identity already resolves to the `registry` source, the existing entry's source MUST be honored, and an update that would otherwise replace a registry-sourced entry with a non-registry-shaped entry MUST be rejected with a diagnostic naming the identity. Section 4.9 and Section 11.3.2 Consumer enumerations and Appendix C updated. Statement count: 110 -> 111 (106 MUST, 5 SHOULD). |
+| 0.1.28 | 2026-08-06 | Spec-citation fold for per-invocation executable consent in non-interactive contexts (closes #1620 Mode-B silent-extension gate). Added [req-sc-014] (Section 10.15, consumer MUST): a consumer that supports a per-invocation consent flag for bin/ executable deployment MUST deny deployment by default when stdout is not a TTY, unless the operator has explicitly opted in for that invocation; an explicit opt-in overrides the non-interactive default and permits deployment; an explicit opt-out overrides the default and denies deployment even in a terminal; the allowExecutables policy gate [req-sc-009] is evaluated first and always takes precedence. Added row 19 to the Section 10.11 summary table. Section 11.3.2 Consumer enumeration and Appendix C updated. Statement count: 111 -> 112 (107 MUST, 5 SHOULD). |
+| 0.1.29 | 2026-08-22 | Spec-citation fold for the Agent Plugins v1 native-lifecycle deployment boundary (closes #2522 Mode-B silent-extension gate). Added [req-tg-011] (Section 8.5.5, consumer MUST): a consumer treats a schema-bearing Agent Plugins v1 dependency as opaque to legacy primitive projection and requires an applicable target-native lifecycle for deployment. The original boundary wording was refined by 0.1.35 to permit acquisition, materialization, and lock recording before a dependency-scoped deployment skip, while ordinary dependencies in the same install remain eligible for deployment and `--dry-run` reports the same decision without mutation. Section 8.7 and Appendix C updated. Statement count: 112 -> 113 (108 MUST, 5 SHOULD). |
+| 0.1.30 | 2026-08-23 | Spec-citation fold for plugin-root hook command resolution (closes #2639 Mode-B silent-extension gate). Added [req-tg-012] (Section 8.5.6, consumer MUST): a consumer that resolves plugin-root placeholders treats a matching quoted placeholder followed by an outside path separator equivalently to the fully quoted path, preserves balanced expandable quoting, and emits a default-visible diagnostic instead of silently deploying any supported placeholder that remains unresolved. Section 8.7, Section 11.3.2, and Appendix C updated. Statement count: 113 -> 114 (109 MUST, 5 SHOULD). |
+| 0.1.31 | 2026-08-23 | Spec-citation fold for Azure DevOps organization-policy discovery. Added [req-pl-017] (Section 6.8, governance MUST): discovery uses `apm/apm-policy` first and can use legacy `_apm/_apm` only after an HTTP 404; all non-404 failures stop without fallback, and a successful legacy fallback emits one actionable migration warning. Section 6.9, Section 11.3.4, and Appendix C updated. Statement count: 114 -> 115 (110 MUST, 5 SHOULD). |
+| 0.1.32 | 2026-08-23 | Spec-citation fold for authoritative legacy plugin skill declarations (closes #2537). Added [req-pr-006] (Section 8.1, consumer MUST): omitted `skills` alone enables conventional discovery; a string or list replaces discovery; explicit empty, invalid, escaping, symlinked, and duplicate-derived entries contribute no skills; declared containers contribute only immediate child skills; and only resulting names are eligible for enumeration, selection, or deployment. Section 8.7, Section 11.3.2, Appendix C, and conformance coverage updated. Statement count: 115 -> 116 (111 MUST, 5 SHOULD). |
+| 0.1.33 | 2026-08-23 | Spec-citation fold for authorized pre-deployment scan scope (closes #2490 Mode-B silent-extension gate). Added [req-sc-015] (Section 10.16, consumer MUST): a consumer derives one post-authorization source-file set for every install and uninstall re-integration materialization lifecycle; excludes symlink files and does not traverse symlinked directories; scans and materializes only that set; rejects a selected blocking finding before a source-derived target write; and does not scan or materialize source-only package files. Added row 20 to the Section 10.11 summary table. Reconciled with concurrent [req-pl-017] and [req-pr-006] and retained all amendments. Section 1.3, Section 11.3.2, and Appendix C updated. Statement count: 116 -> 117 (112 MUST, 5 SHOULD). |
+| 0.1.34 | 2026-08-25 | Spec-citation fold for root-declared Plugin component staging containment (closes #2556). Added [req-pr-007] (Section 8.1, consumer MUST): a consumer canonicalizes the non-symlink component-source root and prunes the current operation's materialization subtree before traversal. Section 8.7, Section 11.3.2, Appendix C, and conformance coverage updated. Statement count: 117 -> 118 (113 MUST, 5 SHOULD). |
+| 0.1.35 | 2026-08-27 | Stale-spec (Mode C) amendment recording a machine-verifiable native Agent Plugins lifecycle. Added [req-tg-013] (Section 8.5.7, consumer MUST): schema, effective-target, integrity, security, and executable admission drives one aggregate direct-plus-transitive registration per scope without locating, invoking, or version-checking a host binary during lifecycle operations; packages remain materialized in place and opaque to legacy projection; direct dependencies win plugin-name collisions over transitive dependencies, same-precedence collisions fail, and recorded ownership does not silently repoint to a transitive claimant; a consumer-owned marketplace identifier and activation suffix are reserved only with the exact generated directory-marketplace entry; the ownership record is primary evidence, while missing-record recovery may re-adopt only that exact entry and reconcile the reserved namespace; foreign collisions and invalid JSON fail closed; unrelated JSON values are preserved semantically though stable serialization may reformat them; and catalog, ownership-record, and settings writes form one rollback unit. Revised [req-tg-011] to clarify that acquisition, materialization, and lock recording may precede target exclusion, which creates no target registration or primitive projection and does not block ordinary dependencies in the same batch. Compatibility is qualified at release or build time by the pinned real-host lifecycle suite; runtime availability is the operator's responsibility. Added the native plugin namespace and ownership-recovery threat to Section 10. Section 8.7, Section 11.3.2 Consumer enumeration, Appendix C, and conformance coverage updated. Statement count: 118 -> 119 (114 MUST, 5 SHOULD). |
+| 0.1.36 | 2026-08-29 | Editorial and defensive alignment for [req-tg-011] and [req-tg-013]. Named the [req-tg-008] result as the effective target intersection; scoped aggregate registration and plugin-name claimant selection to dependencies that passed admission; required target contraction to retire consumer-owned native registration; required advisory uninstall, prune, and restore reconciliation to omit ambiguous or changed-owner plugin entries without blocking cleanup; restored exact removal boundaries; defined directory-marketplace entries; and added reserved namespace disclosure to Section 11.2. Added conformance coverage for direct-owner promotion, advisory collision cleanup, and transitive owner-repoint refusal. Statement count remains 119 (114 MUST, 5 SHOULD). |
+| 0.1.37 | 2026-09-01 | Spec-citation fold for safe full-SHA revision-pin updates (closes #2511 Mode-B silent-extension gate). Added [req-rs-017] (Section 7.7, consumer MUST): a consumer extension may replace a full commit pin only with the peeled commit of the highest eligible non-prerelease annotated tag, including 0.x; no eligible tag retains the current commit and allows unrelated updates to continue; malformed, ambiguous, or failed remote tag resolution stops before manifest or lockfile writes. Revised [req-rs-011], [req-rs-012], and [req-rs-015] for bounded manifest rewrite, scoped operation, advisory tag provenance, and network-free replay. Section 5.2, Section 5.6, Section 7.11, Section 11.3.2, Appendix C, and conformance coverage updated. Statement count: 119 -> 120 (115 MUST, 5 SHOULD). |
+| 0.1.38 | 2026-09-01 | Defensive amendment of [req-lk-005] (no new normative statement; count remains 120 (115 MUST, 5 SHOULD)): `generated_at` is optional advisory metadata, new lockfiles omit it by default, and later writes preserve an existing omission unless explicitly configured otherwise. |
+| 0.1.39 | 2026-09-01 | Spec-citation fold for user-scoped direct MCP target selection (closes #2548 Mode-B silent-extension gate). Added [req-tg-014] (Section 8.5.8, consumer MUST): explicit selection, the user-scope manifest, configured user default, and user-scope runtime discovery form one precedence chain; project-only signals cannot constrain final discovery; and a selected set with no user-capable runtime fails before user manifest, lockfile, or target-config mutation. Section 8.7, Section 11.3.2, and Appendix C updated. Statement count: 120 -> 121 (116 MUST, 5 SHOULD). |
+| 0.1.40 | 2026-09-07 | Spec-citation fold for dependency-policy identity casing in PR #2706. Added [req-pl-018] (Section 6.3.1, governance MUST) and extended [req-rs-016] clause (3): dependency allow, deny, and exact require operands use the documented per-host repository case rule, while registry-sourced repository coordinates are case-insensitive regardless of host; case normalization is ASCII-only, is bounded identically on both operands, stops at recursive-glob ambiguity, and does not cross virtual-path, ref, registry-name, MCP-name, unmanaged-path, or case-sensitive host/source boundaries; deny precedence is unchanged. Defined the policy glob grammar, documented byte-exact Section 6.4 merge behavior, and added the threat mapping. Classified this as a non-breaking correction of previously unspecified evaluation behavior under Section 9.2: existing lowercase workarounds remain matching; on registry sources and hosts documented as case-insensitive, case-variant allow entries can newly match, deny entries can newly enforce, and exact require entries can newly be satisfied, so those policies should be re-audited. Sections 1.3, 6.3.1, 6.3.5, 6.4, 6.5, 6.9, 7.2, 9.2, 10.8, 10.11, 11.2, and 11.3.4, Appendix C, and conformance coverage updated. Statement count: 121 -> 122 (117 MUST, 5 SHOULD). |
+
+Errata (none at publication).
+
+---
+
+## Appendix E. Editorial reconciliation notes
+
+This appendix collects editorial notes that were inlined in the
+v0.1 first draft. They are non-normative; the section bodies they
+reference remain authoritative.
+
+**E.1 Manifest top-level `type` field.** [Section 4.2.2](#422-type-advisory)
+defines `type` (with values `instructions`, `skill`, `hybrid`, or
+`prompts`) as informational in this revision; it exists so future minor
+revisions can attach normative semantics (for example, packaging
+filters per type) without a breaking schema change. Consumers
+MUST ignore the value.
+
+**E.2 Target identifier reservation.** The target identifiers
+enumerated in the OpenAPM Target Registry companion are reserved
+in the current namespace; vendor extensions MUST use the
+`x--` pattern of [req-tg-004](#req-tg-004) to avoid
+collision.
+
+**E.3 Lockfile `lockfile_version: "2"` adoption.** Once a
+conforming consumer has written `"2"`, it MUST NOT demote to
+`"1"` on subsequent rewrites (see
+[req-lk-002](#req-lk-002)). The motivation is auditability: a
+silently-demoted lockfile would lose the registry-binding
+metadata that prompted the v2 upgrade.
+
+**E.4 `resolved_at` non-stability.** The lockfile field
+`resolved_at` is advisory and MAY vary across re-resolutions; it
+is excluded from canonical-emission stability checks per
+[Section 5.6](#56-git-semver-fields-constraint-resolved_tag-resolved_at). Round-
+trip conformance ([req-cf-001](#req-cf-001)) treats this field as
+permitted-to-vary.
diff --git a/packages/apm-guide/.apm/skills/apm-usage/commands.md b/packages/apm-guide/.apm/skills/apm-usage/commands.md
index 279ccb510b..1868934da0 100644
--- a/packages/apm-guide/.apm/skills/apm-usage/commands.md
+++ b/packages/apm-guide/.apm/skills/apm-usage/commands.md
@@ -9,6 +9,10 @@
## Dependency management
+For `apm install -g`, direct local dependencies require absolute paths
+(`~/path` works). Relative children of local packages resolve from the
+declaring package's original source directory; see [local-path anchoring](dependencies.md).
+
| Command | Purpose | Key flags |
|---------|---------|-----------|
| `apm install [PKGS...]` | Install APM, MCP, and LSP dependencies (supports APM packages, Claude skills (SKILL.md), and plugin collections (plugin.json)); one effective target decision drives package, MCP, and LSP phases; plain/frozen installs replay locked refs and cache state, while `--update` and `--refresh` require current upstream mutable refs; a successful non-dry-run install also reconciles deployed artifacts, lockfile ownership, and merge-hook config/sidecar entries for any target dropped from `targets:` | `--update` (deprecated; prefer `apm update`) refresh refs without accepting stale bare-cache answers, `--refresh` re-fetch all deps from upstream and re-resolve all ref pins, `--force` overwrite collisions and permit deployment after critical built-in scan findings (does NOT refresh refs by itself; `apm update --force` still requires upstream truth), `--frozen` CI-safe install that fails before any durable write when `apm.lock.yaml` is missing or out of sync with `apm.yml`, including MCP config state (mutually exclusive with `--update`, package additions, and `--mcp`; use normal install to create or repair lock state, then `apm audit` for SHA integrity), `--dry-run` (no package/deployment writes; positional packages and ref changes are previewed without changing an existing `apm.yml`; a newly bootstrapped manifest and explicit targets are kept), `--verbose`, `--only [apm\|mcp]`, `--target` (comma-separated, e.g. `--target claude,cursor`; resolution chain `--target` > apm.yml `targets:` > `apm config set target ...` > auto-detect; this decision is reused by package, MCP, and LSP phases; unresolved required service work fails non-zero before manifest or package writes, and native MCP/LSP write failures also fail non-zero; `intellij` is MCP-only and writes JetBrains Copilot's user-scope config; explicit lists are exact, so `intellij,claude` writes those two MCP configs and `all,intellij` adds JetBrains to `all`; on auto-bootstrap when no `apm.yml` exists, recognized manifest target(s) are persisted to the new manifest's `targets:` field so a later bare `apm update` reuses them; `--target all` deprecated, see `apm compile --all`; use `kiro` for Kiro IDE; use `grok-build` for stable Grok Build rules, agents, commands, skills, and `AGENTS.md`; use `copilot-cowork` with `--global` after `apm experimental enable copilot-cowork`; use `grok-cloud` after `apm experimental enable grok-cloud` to deploy skills only to `.grok/skills/`; use explicit target `hermes` to deploy skills and home-scoped MCP servers to `$HERMES_HOME/config.yaml` (or `~/.hermes/config.yaml` when unset or blank); run `apm compile` separately for `AGENTS.md`), `--dev`, `-g` global (MCP deploys only to user-scope runtimes: Copilot CLI, Claude Code, Codex CLI, Gemini CLI, Antigravity CLI, Kiro, Windsurf, JetBrains Copilot, and Hermes when selected explicitly), `--trust-transitive-mcp`, `--parallel-downloads N`, `--trust-bin` / `--no-trust-bin` (per-invocation consent for marketplace-plugin bin/ deployment: `--trust-bin` suppresses the trust-posture warning, `--no-trust-bin` skips bin/ even if policy allows; default deploys with a warning), `--allow-insecure`, `--allow-insecure-host HOSTNAME`, `--skill NAME` install named skills from a dependency that exposes selectable skills (repeatable; plugin manifests accept a leaf name or source-relative path under skills/; a CLI name or path that matches no declared skill fails before manifest or lockfile commit with available names; a stale persisted `skills:` pin that no longer matches an available source skill warns with the package, declared request names, and available names, and directs the user to edit `skills:` in apm.yml; persisted in apm.yml only on a successful CLI match; additive across separate installs -- a later `--skill X` adds to the existing pin (union) rather than replacing it, so previously deployed skills are never silently removed; `'*'` resets to the full bundle; drop a single skill by editing the `skills:` list in apm.yml then re-running install), `--legacy-skill-paths` restore per-client skill dirs, `--mcp NAME` add MCP entry using that same effective target decision (the shared decision applies, so `apm install --mcp NAME --target intellij` writes only JetBrains Copilot's MCP config; compilation target policy applies to every explicitly selected target; `apm install -g --mcp NAME` writes user-scope and bypasses the project-scope gate by design), `--transport`, `--url`, `--env KEY=VAL`, `--header KEY=VAL`, `--mcp-version`, `--registry URL` custom MCP registry, `--root DIR` redirect writes (`apm_modules/`, lockfile, `.gitignore`, integrated harness files) under DIR while `apm.yml`/`.apm/`/local deps resolve from `$PWD` (mirrors `pip install --target`; created if missing; not valid with `-g`/`--global`, which exits 2). Explicit plugin component paths must resolve inside the plugin root; missing declarations fail before deployment and lockfile commit. |
@@ -244,16 +248,54 @@ descendants, are skipped.
## Security and audit
+Built-in protection blocks critical findings during `install`, `compile`, and
+`unpack` without configuration. `apm audit` is the explicit tool for reporting
+(SARIF, JSON, markdown), remediation (`--strip`), and standalone scanning (`--file`).
+
+Audit replays current target intent: manifest `target(s)` > saved `apm config
+set target` > existing directory detection. A selected malformed saved target
+fails resolution; a valid manifest target overrides irrelevant invalid saved
+configuration. Audit does not recover unsaved historical `install --target`
+overrides or create missing user configuration.
+Audit skips startup update checks and their cache writes; use
+`apm self-update --check` separately.
+
+Saved target names do not guarantee scratch replay support. Native nonfilesystem
+targets without an isolated filesystem backend (currently `copilot-app`) fail
+replay explicitly without invoking the live workflow writer or changing its
+database or sidecars. Filesystem-backed native layouts are rebased into scratch;
+old native claims remain comparison-only, and unavailable former roots fail
+closed. Missing ownership never removes source-derived expectations. See the
+[audit reference](https://microsoft.github.io/apm/reference/cli/audit/#drift-detection)
+for target prerequisites and replay boundaries.
+
+Known replay limitation: an internal local resource link copied as a regular
+file during install can be falsely reported as `orphaned` by unchanged CI audit.
+Escaping resource links remain rejected; this is not a containment exception.
+
| Command | Purpose | Key flags |
|---------|---------|-----------|
| `apm audit [PKG]` | Scan installed primitives for hidden Unicode, drift, and lockfile/policy violations | `--file PATH`, `--strip`, `--dry-run`, `-v`, `-f [text\|json\|sarif\|md]`, `-o PATH`, `--ci`, `--policy SOURCE`, `--no-cache`, `--no-fail-fast`, `--no-drift`, `--external NAME` (experimental; ingest a third-party SARIF scanner, e.g. `skillspector`), `--external-sarif PATH`, `--external-llm/--no-external-llm`, `--external-args TEXT` |
-`apm audit` runs **drift detection by default** (issue #1071). It replays `apm install` into a temporary scratch tree and diffs the result against your working tree. Catches three failure modes: (1) `.apm/` source added without re-running `apm install`, (2) hand-edits to deployed files that diverge from canonical source, (3) orphan files left after their source was removed. The scan is read-only -- never writes to your project, lockfile, or live `apm_modules/`. Build IDs, CRLF line endings, and BOMs are normalized away so they cannot trigger false positives. Bare `apm audit` still uses the warmed local cache and skips with an informational message when the cache is absent. `apm audit --ci` is stricter: when `apm_modules/` is missing but `apm.lock.yaml` is present, it self-hydrates a lock-pinned scratch install for `config-consistency` and drift without touching the checkout. That closes the setup-only CI gap for repos that commit deployed files. Repos that gitignore deployed outputs still need those files present on disk for `deployed-files-present`, so keep the full-install CI pattern there. Use `--no-drift` to opt out (e.g. fast inner loops); the flag is mutually exclusive with `--strip`/`--file`. Ordinary drift remains advisory in bare audit and fails only in `--ci` mode or when policy promotes it. A stale canonical deployment owner is different: `deployment-ledger-owners` is a hard integrity failure in both modes, exits 1, names the owner and path in text/JSON/SARIF, and blocks `--strip`. Remediate it with `apm prune`, then rerun `apm audit`. Drift output is integrated into JSON (top-level `drift` key) and SARIF (rule IDs `apm/drift/` where kind is `modified`/`unintegrated`/`orphaned`).
-`apm audit` runs **drift detection by default** (issue #1071). It replays `apm install` cache-only into a temporary scratch tree and diffs the result against your working tree. It catches four failure modes: (1) `.apm/` source added without re-running `apm install`, (2) hand-edits to deployed files that diverge from canonical source, (3) orphan files left after their source was removed, and (4) `unrecorded` files that install deploys but no lockfile entry claims. The scan is read-only -- never writes to your project, lockfile, or `apm_modules/`. Build IDs, CRLF line endings, and BOMs are normalized away so they cannot trigger false positives. If the install cache has not been warmed (e.g. a fresh checkout before the first `apm install`), the drift check is skipped with an informational message and can still exit 0; run `apm install` before relying on drift until cold-cache replay lands. Use `--no-drift` to opt out with reduced coverage; the flag is mutually exclusive with `--strip`/`--file`. Ordinary drift remains advisory in bare audit and fails in `--ci` mode. Remediate `unrecorded` with `apm install`, then commit the regenerated `apm.lock.yaml`. A stale canonical deployment owner is different: `deployment-ledger-owners` is a hard integrity failure in both modes, exits 1, names the owner and path in text/JSON/SARIF, and blocks `--strip`. Remediate it with `apm prune`, then rerun `apm audit`. Drift output is integrated into JSON (top-level `drift` key) and SARIF (rule IDs `apm/drift/` where kind is `modified`/`unintegrated`/`orphaned`/`unrecorded`).
-`apm audit` runs **drift detection by default** (issue #1071). It replays `apm install` cache-only into a temporary scratch tree and diffs the result against your working tree. It catches four failure modes: (1) `.apm/` source added without re-running `apm install`, (2) hand-edits to deployed files that diverge from canonical source, (3) orphan files left after their source was removed, and (4) `unrecorded` files that install deploys but no lockfile entry claims. The scan is read-only -- never writes to your project, lockfile, or `apm_modules/`. Build IDs, CRLF line endings, and BOMs are normalized away so they cannot trigger false positives. If the install cache has not been warmed (e.g. a fresh checkout before the first `apm install`), the drift check is skipped with an informational message and can still exit 0; run `apm install` before relying on drift until cold-cache replay lands. Use `--no-drift` to opt out with reduced coverage; the flag is mutually exclusive with `--strip`/`--file`. Drift is advisory in bare audit by default unless policy enables `security.audit.fail_on_drift`; `--ci` always gates on drift. Remediate `unrecorded` with `apm install`, then commit the regenerated `apm.lock.yaml`. A stale canonical deployment owner is different: `deployment-ledger-owners` is a hard integrity failure in both modes, exits 1, names the owner and path in text/JSON/SARIF, and blocks `--strip`. Remediate it with `apm prune`, then rerun `apm audit`. Drift output is integrated into JSON (top-level `drift` key) and SARIF (rule IDs `apm/drift/` where kind is `modified`/`unintegrated`/`orphaned`/`unrecorded`).
-`apm audit` runs **drift detection by default** (issue #1071). It replays `apm install` cache-only into a temporary scratch tree and diffs the result against your working tree. It catches four failure modes: (1) `.apm/` source added without re-running `apm install`, (2) hand-edits to deployed files that diverge from canonical source, (3) orphan files left after their source was removed, and (4) `unrecorded` files that install deploys but no lockfile entry claims. The scan is read-only -- never writes to your project, lockfile, or `apm_modules/`. Build IDs, CRLF line endings, and BOMs are normalized away so they cannot trigger false positives.
-
-If the install cache has not been warmed (e.g. a fresh checkout before the first `apm install`), the drift check is skipped with an informational message and can still exit 0; run `apm install` before relying on drift until cold-cache replay lands. Use `--no-drift` to opt out with reduced coverage; the flag is mutually exclusive with `--strip`/`--file`. Drift is advisory in bare audit by default unless policy enables `security.audit.fail_on_drift`; `--ci` always gates on drift. Remediate `unrecorded` with `apm install`, then commit the regenerated `apm.lock.yaml`. A stale canonical deployment owner is different: `deployment-ledger-owners` is a hard integrity failure in both modes, exits 1, names the owner and path in text/JSON/SARIF, and blocks `--strip`. Remediate it with `apm prune`, then rerun `apm audit`. Drift output is integrated into JSON (top-level `drift` key) and SARIF (rule IDs `apm/drift/` where kind is `modified`/`unintegrated`/`orphaned`/`unrecorded`).
+Drift detection runs by default without modifying the project, lockfile, or live
+`apm_modules/`. It compares replayed output for `modified`, `unintegrated`,
+`orphaned`, and `unrecorded` files, normalizing build IDs, CRLF, and BOMs.
+Bare audit uses the local cache and skips drift on a cache miss. With a lockfile,
+`apm audit --ci` self-hydrates a lock-pinned scratch install for
+`config-consistency` and drift when `apm_modules/` is absent. Gitignored deployed
+outputs must still exist for `deployed-files-present`.
+
+`--no-drift` skips replay with reduced coverage and cannot accompany `--strip`
+or `--file`. Ordinary drift is advisory in bare audit unless policy enables
+`security.audit.fail_on_drift`; `--ci` gates on drift. For `unrecorded` files,
+run `apm install` and commit the regenerated `apm.lock.yaml`. This does not
+provide a missing native replay backend. JSON reports use the top-level `drift`
+key; SARIF uses `apm/drift/` rule IDs.
+
+Invalid canonical deployment owners remain a hard `deployment-ledger-owners`
+failure in both modes: exit 1, owner/path diagnostics, and `--strip` blocked.
+Run `apm prune`, then rerun `apm audit`; invalid owner records never authorize
+deleting files.
**External scanners (experimental, behind `apm experimental enable external-scanners`).** `--external NAME` runs a third-party SARIF scanner (e.g. `skillspector`) and merges its findings. `--external-llm/--no-external-llm` toggles LLM-powered analysis (default off; sends scanned content to a third-party API, so APM prints a `[!]` egress banner and forwards `OPENAI_API_KEY`/`NVIDIA_INFERENCE_KEY` only when on). `--external-args TEXT` is a single shlex-split string of extra scanner flags, validated against a per-adapter allowlist -- non-allowlisted flags, secret-looking flags, and out-of-cwd paths are rejected fail-closed. `--external-llm`/`--external-args` without `--external` is a usage error (exit 2). Scanner configuration or infrastructure errors (feature disabled, scanner not found, malformed SARIF) exit **3**. Persist defaults with `apm config set external..llm true` and `apm config set external..args -- "--model gpt-4o"`. Precedence: CLI > config > policy floor.
diff --git a/packages/apm-guide/.apm/skills/apm-usage/dependencies.md b/packages/apm-guide/.apm/skills/apm-usage/dependencies.md
index 7841cbf178..68bb420e7a 100644
--- a/packages/apm-guide/.apm/skills/apm-usage/dependencies.md
+++ b/packages/apm-guide/.apm/skills/apm-usage/dependencies.md
@@ -52,18 +52,40 @@ install for manual inspection. Repository path casing remains
identity-significant for unknown git hosts because a self-hosted backend may be
case-sensitive.
-**Local-path anchor rule:** a `local_path` declared INSIDE another local
-package is resolved relative to THAT package's own directory (npm/pip/cargo
-parity). Sibling layouts that resolve outside the consuming project root
-(e.g. `../sibling-pkg` from a local dep at the project edge) are
-supported -- the consuming developer authored the manifest chain and
-already trusts the layout.
+**Local-path anchor rule:** a path declared inside another local package
+resolves from that package's original source directory, including at user
+scope (`--global`). Trusted sibling layouts outside the consumer project
+root, such as `../sibling-pkg`, are supported. Direct user-scope local
+dependencies must use absolute paths (`~/path` also works); a relative
+reference without a known local parent's absolute source anchor is rejected.
+Neither CWD nor `~/.apm/` substitutes for that anchor.
+APM does not search another installation scope's installed packages to resolve
+a missing local source. Local source selection is a trust decision, not a
+guarantee that the package content is safe.
Remote-cloned packages may declare a relative `path:` only when it resolves
inside the same authenticated remote repo root. APM expands that path to the
parent's remote host/repo/ref and fetches the sibling from the same origin.
Absolute paths, paths that escape the repo root, and cross-repo local paths
are rejected.
+This remote expansion runs before operator-local user-scope admission: an
+accepted sibling remains a Git dependency, including during a global install.
+APM establishes that origin from acquisition context, not repository spelling;
+a remote repository named `_local/parent` is still remote. Missing provenance
+does not authorize a transitive local filesystem read.
+
+Local lock replay still needs the original declaring-source context. Relative
+spelling alone is not source identity or read authorization, and an absolute
+local path is not automatically portable to another machine. The
+`declaring_parent` and `anchored_local_path` fields are APM-specific metadata,
+not standardized portable local-source coordinates.
+
+After a local source directory is selected and resolved, symlinks inside that
+package must resolve within that same directory. Internal links are copied as
+content; broken, cyclic, or escaping links fail materialization. Selecting a
+source-directory path that itself resolves through a symlink is a separate
+operation. These checks do not make an entire install atomic or protect against
+all concurrent filesystem changes.
**GitLab `path:` fetch transport:** GitLab `path:` files are fetched over git
transport, not the REST API, so self-hosted instances with the API disabled
diff --git a/scripts/architecture_linter/checks/contracts_test_taxonomy.py b/scripts/architecture_linter/checks/contracts_test_taxonomy.py
index 785c8a4064..8cc8d112da 100644
--- a/scripts/architecture_linter/checks/contracts_test_taxonomy.py
+++ b/scripts/architecture_linter/checks/contracts_test_taxonomy.py
@@ -86,6 +86,48 @@
_GUARD_GENERATION_FOOTER = "contracts-tooling-generation-footer"
+_GUARD_SPEC_ASSESSMENT = "contracts-tooling-spec-assessment"
+
+
+def check_spec_assessment_authority(provider: FactsProvider) -> tuple[Violation, ...]:
+ """Keep selected artifact, exact identity, and collection decisions in one owner."""
+ required_calls = {
+ "tests/spec_conformance/_helpers.py": ("selected_assessment(",),
+ "tests/spec_conformance/conftest.py": ("coverage_document(", "coverage_output_path("),
+ "tests/spec_conformance/orphan_check.py": ("collect_coverage(",),
+ "tests/spec_conformance/gen_statement.py": ("collect_coverage(", "selected_assessment("),
+ "tests/spec_conformance/mode_b_detector.sh": ("-m tests.spec_conformance._manifest",),
+ }
+ findings: list[Violation] = []
+ for path, calls in required_calls.items():
+ facts, failures = _facts_for(provider, path, _GUARD_SPEC_ASSESSMENT)
+ findings.extend(failures)
+ if failures:
+ continue
+ if any(not _present(facts, call) for call in calls):
+ findings.append(
+ _summary(
+ _GUARD_SPEC_ASSESSMENT,
+ path,
+ "Spec assessment consumers must route through tests/spec_conformance/_manifest.py",
+ )
+ )
+ if _present_re(
+ facts,
+ re.compile(
+ r"""(?:openapm-v[0-9]+\.[0-9]+|["']v[0-9]+\.[0-9]+\.[0-9]+["']"""
+ r"|^\s*(?:SPEC_PATH|MANIFEST_PATH|SPEC_VERSION|_SELECTED_MINOR)\s*=)"
+ ),
+ ):
+ findings.append(
+ _summary(
+ _GUARD_SPEC_ASSESSMENT,
+ path,
+ "Selected spec paths and exact revision must not be independently hard-coded",
+ )
+ )
+ return tuple(findings)
+
_SRC_PREFIX = "src/apm_cli/"
@@ -1330,6 +1372,11 @@ def _structural_rule(rule_id: str, description: str, check) -> Rule:
RULES: tuple[Rule, ...] = (
+ _owner_rule(
+ _GUARD_SPEC_ASSESSMENT,
+ "Selected specification and fresh binding inventory have one canonical owner.",
+ check_spec_assessment_authority,
+ ),
_owner_rule(
_GUARD_TAXONOMY,
"Behavioral test taxonomy classification stays owned by module-level pytestmark.",
diff --git a/scripts/architecture_linter/checks/install_deployment_analyzers.py b/scripts/architecture_linter/checks/install_deployment_analyzers.py
index d58844e0e7..a08b607da8 100644
--- a/scripts/architecture_linter/checks/install_deployment_analyzers.py
+++ b/scripts/architecture_linter/checks/install_deployment_analyzers.py
@@ -53,11 +53,13 @@
from scripts.architecture_linter.checks.install_policy_intent import EXTRA_RULES
from scripts.architecture_linter.checks.install_request_and_source import (
_GUARD_INSTALL_SCOPE,
+ _GUARD_LOCAL_SCOPE,
_GUARD_OUTCOME,
_GUARD_PRIMITIVE_CLASSIFICATION,
_GUARD_REQUEST_DEFAULTS,
_GUARD_SOURCE_PLAN,
check_install_scope_selection,
+ check_local_scope_admission,
check_outcome,
check_primitive_classification,
check_request_defaults,
@@ -132,6 +134,11 @@
"Direct MCP installs consume the install command's single scope decision.",
check_install_scope_selection,
),
+ _rule(
+ _GUARD_LOCAL_SCOPE,
+ "Local USER-scope admission routes through user_scope_rejection_reason.",
+ check_local_scope_admission,
+ ),
_rule(
_GUARD_BASE_INTEGRATOR,
"File-level deploy/sync/cleanup stays owned by BaseIntegrator.",
diff --git a/scripts/architecture_linter/checks/install_request_and_source.py b/scripts/architecture_linter/checks/install_request_and_source.py
index ffda5c991a..6b9452c6c7 100644
--- a/scripts/architecture_linter/checks/install_request_and_source.py
+++ b/scripts/architecture_linter/checks/install_request_and_source.py
@@ -22,6 +22,7 @@
_duplicate_definition_lines,
_facts_for,
_lines,
+ _name_calls_in,
_present,
_present_re,
_summary,
@@ -45,6 +46,8 @@
_GUARD_INSTALL_SCOPE = "install-deployment-install-scope-selection"
+_GUARD_LOCAL_SCOPE = "install-deployment-local-scope-admission"
+
_REQUEST_OWNER = "src/apm_cli/install/request.py"
_MCP_COMMAND = "src/apm_cli/install/mcp/command.py"
@@ -53,6 +56,85 @@
_ALLOWED_WRAPPER_DEFAULTS = frozenset({"update_refs", "verbose", "only_packages"})
+def check_local_scope_admission(provider: FactsProvider) -> tuple[Violation, ...]:
+ """Local scope admission must delegate to one owner with parent context."""
+ owner_path = "src/apm_cli/install/package_resolution.py"
+ owner_name = "user_scope_rejection_reason"
+ owner, failures = _facts_for(provider, owner_path, _GUARD_LOCAL_SCOPE)
+ findings = list(failures)
+ if not failures and not _present(owner, f"def {owner_name}("):
+ findings.append(_summary(_GUARD_LOCAL_SCOPE, owner_path, "Missing local admission owner"))
+ if not failures and not _present(owner, 'parent_pkg.proven_source_kind == "local"'):
+ findings.append(
+ _summary(_GUARD_LOCAL_SCOPE, owner_path, "Local admission requires positive provenance")
+ )
+ resolver_path = "src/apm_cli/deps/apm_resolver.py"
+ resolver, failures = _facts_for(provider, resolver_path, _GUARD_LOCAL_SCOPE)
+ findings.extend(failures)
+ if not failures:
+ index = provider.tree_index(resolver_path)
+ calls = (
+ _attribute_calls(tuple(index.walk(index.root)), "_activate_validated_package")
+ if index is not None and index.root is not None
+ else []
+ )
+ if (
+ len(calls) != 4
+ or not all(
+ len(call.args) == 4
+ and isinstance(call.args[3], ast.Name)
+ and call.args[3].id == "dep_ref"
+ for call in calls
+ )
+ or not _present(
+ resolver, "proven_source_kind=self._source_kind_for_dependency(dep_ref)"
+ )
+ or not _present(resolver, "kind = self._source_kind_for_dependency(parent_dep)")
+ or not _present(resolver, 'parent_pkg.proven_source_kind != "local"')
+ ):
+ findings.append(
+ _summary(
+ _GUARD_LOCAL_SCOPE,
+ resolver_path,
+ "All package load paths must project actual dependency origin; "
+ "expansion and backstop must consume established provenance",
+ )
+ )
+ consumers = (
+ (_INSTALL_ADAPTER, "_resolve_package_references", False),
+ ("src/apm_cli/install/phases/resolve.py", "download_callback", True),
+ ("src/apm_cli/install/sources.py", "acquire", True),
+ )
+ for path, function_name, needs_parent in consumers:
+ facts, failures = _facts_for(provider, path, _GUARD_LOCAL_SCOPE)
+ findings.extend(failures)
+ if failures:
+ continue
+ delegated = owner_name in _name_calls_in(facts, function_name)
+ if needs_parent:
+ index = provider.tree_index(path)
+ calls = (
+ _named_calls(tuple(index.walk(index.root)), owner_name)
+ if index is not None and index.root is not None
+ else []
+ )
+ delegated = (
+ delegated
+ and any(_has_name_keyword(call, "parent_pkg", "parent_pkg") for call in calls)
+ and not _present(facts, "scope is InstallScope.USER")
+ )
+ if not delegated:
+ findings.append(
+ _summary(
+ _GUARD_LOCAL_SCOPE,
+ path,
+ "Local scope admission must call user_scope_rejection_reason "
+ "and retain declaring-parent context instead of an inline scope predicate",
+ )
+ )
+ return tuple(findings)
+
+
def _wrapper_default_args(index: TreeIndex) -> list[str]:
"""Return trailing defaulted args of top-level ``_install_apm_dependencies``."""
if index.root is None:
diff --git a/scripts/architecture_linter/checks/registry_owner_guards.py b/scripts/architecture_linter/checks/registry_owner_guards.py
index c6e92af045..56db0c63dc 100644
--- a/scripts/architecture_linter/checks/registry_owner_guards.py
+++ b/scripts/architecture_linter/checks/registry_owner_guards.py
@@ -250,7 +250,7 @@ def _check_install_target_selection(provider: FactsProvider) -> Iterable[Violati
if failures:
return failures
- findings: list[Violation] = []
+ findings = list(_check_audit_target_selection(provider, rule_id))
owner = facts_by_path[_EFFECTIVE_TARGET_OWNER]
install = facts_by_path[_INSTALL_CMD]
pipeline = facts_by_path[_INSTALL_PIPELINE]
@@ -332,6 +332,99 @@ def _check_install_target_selection(provider: FactsProvider) -> Iterable[Violati
return findings
+def _check_audit_target_selection(provider: FactsProvider, rule_id: str) -> Iterable[Violation]:
+ """Audit adapts the canonical decision and never redetects scratch targets."""
+ adapter = "src/apm_cli/install/audit_target_roots.py"
+ native_discovery = "src/apm_cli/integration/copilot_cowork_paths.py"
+ required = {
+ adapter: {
+ "resolve_effective_target_decision",
+ "read_declared_target_names",
+ "resolve_targets",
+ },
+ "src/apm_cli/install/drift.py": {"resolve_audit_targets", "replay_target"},
+ "src/apm_cli/install/audit_replay.py": {"resolve_audit_targets"},
+ "src/apm_cli/policy/ci_checks.py": {"resolve_audit_targets", "audit_comparison_targets"},
+ native_discovery: {"get_copilot_cowork_skills_dir"},
+ }
+ for path, expected_calls in required.items():
+ _facts, failures = checked_facts(provider, path, rule_id, require_python=True)
+ if failures:
+ yield from failures
+ continue
+ index = provider.tree_index(path)
+ if index is None:
+ yield violation(rule_id, path, "audit target delegation has no parsed source")
+ continue
+ calls = [
+ node
+ for node in index.walk(index.root)
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
+ ]
+ missing = expected_calls - {node.func.id for node in calls}
+ if missing:
+ yield violation(
+ rule_id,
+ path,
+ f"audit target delegation is missing calls: {', '.join(sorted(missing))}",
+ )
+ for call in calls:
+ if path != adapter and call.func.id in {"resolve_targets", "_read_apm_yml_target"}:
+ yield violation(
+ rule_id,
+ path,
+ "audit consumers must use resolve_audit_targets",
+ line=call.lineno,
+ )
+ if path in {adapter, native_discovery} and call.func.id in {
+ "resolve_effective_target_decision",
+ "resolve_targets",
+ "get_copilot_cowork_skills_dir",
+ }:
+ keywords = {keyword.arg: keyword.value for keyword in call.keywords}
+ value = keywords.get("create_config")
+ if not isinstance(value, ast.Constant) or value.value is not False:
+ yield violation(
+ rule_id,
+ path,
+ "audit target resolution must not create config",
+ line=call.lineno,
+ )
+ if call.func.id == "resolve_effective_target_decision":
+ strict = keywords.get("strict_config")
+ if not isinstance(strict, ast.Constant) or strict.value is not True:
+ yield violation(
+ rule_id,
+ path,
+ "audit target resolution must reject invalid saved configuration",
+ line=call.lineno,
+ )
+ if path == adapter:
+ for definition in direct_definitions(index, "replay_target", kinds=FUNCTION_NODES):
+ if not any(
+ isinstance(node, ast.Call)
+ and isinstance(node.func, ast.Name)
+ and node.func.id == "_require_filesystem_replay"
+ for node in index.own_scope(definition)
+ ):
+ yield violation(
+ rule_id, path, "audit projection must reject unsafe native replay"
+ )
+ if path != adapter:
+ for node in index.walk(index.root):
+ if (
+ isinstance(node, ast.ImportFrom)
+ and (node.module or "").endswith("integration.targets")
+ and any(alias.name == "resolve_targets" for alias in node.names)
+ ):
+ yield violation(
+ rule_id,
+ path,
+ "audit target consumers must not import a parallel resolver",
+ line=node.lineno,
+ )
+
+
def _check_output_diagnostics(provider: FactsProvider) -> Iterable[Violation]:
"""Doctor status symbols must come from ``utils/console.py::STATUS_SYMBOLS``.
diff --git a/scripts/architecture_linter/checks/transport_auth_platform.py b/scripts/architecture_linter/checks/transport_auth_platform.py
index d9f8cb702e..a927a9101c 100644
--- a/scripts/architecture_linter/checks/transport_auth_platform.py
+++ b/scripts/architecture_linter/checks/transport_auth_platform.py
@@ -144,6 +144,32 @@ def _check_host_credential_resolution(provider: FactsProvider) -> tuple[Violatio
inv = frozenset(provider.inventory)
findings: list[Violation] = []
+ # Git credential isolation may write its scratch sentinel, never bootstrap
+ # user configuration. Keep temp precedence with the configuration owner.
+ for path, required in (
+ (
+ "src/apm_cli/deps/git_auth_env.py",
+ ("get_apm_temp_dir(create_config=False)",),
+ ),
+ (
+ "src/apm_cli/config.py",
+ (
+ 'return get_config(create=create_config).get("temp_dir")',
+ "get_temp_dir(create_config=create_config)",
+ ),
+ ),
+ ):
+ findings.extend(
+ _require_subs(
+ provider,
+ inv,
+ _RID_HOST_CRED,
+ path,
+ required,
+ "Git sentinel temp lookup must use noncreating configuration-owner reads",
+ )
+ )
+
# AC5 -- AuthResolver must scrub inherited Git authorization state.
findings.extend(
_require_subs(
diff --git a/src/apm_cli/cli.py b/src/apm_cli/cli.py
index 7f6978aab3..bac205fd9b 100644
--- a/src/apm_cli/cli.py
+++ b/src/apm_cli/cli.py
@@ -170,9 +170,10 @@ def cli(ctx, verbose: bool) -> None:
warnings.filterwarnings("ignore", category=AgentsTargetDeprecationWarning)
# Check for updates only for known commands; skip on invalid input to fail fast.
+ # Read-only audit also excludes the update check's cache/configuration writes.
if (
not ctx.resilient_parsing
- and ctx.invoked_subcommand is not None
+ and ctx.invoked_subcommand not in (None, "audit")
and ctx.command.get_command(ctx, ctx.invoked_subcommand) is not None
):
_check_and_notify_updates()
diff --git a/src/apm_cli/config.py b/src/apm_cli/config.py
index bf62234e17..b84c986d8a 100644
--- a/src/apm_cli/config.py
+++ b/src/apm_cli/config.py
@@ -148,13 +148,16 @@ def set_auto_integrate(enabled: bool) -> None:
update_config({"auto_integrate": enabled})
-def get_temp_dir() -> str | None:
+def get_temp_dir(*, create_config: bool = True) -> str | None:
"""Get the configured temporary directory.
+ Args:
+ create_config: When false, leave missing user configuration absent.
+
Returns:
The stored temp_dir config value, or None if not set.
"""
- return get_config().get("temp_dir")
+ return get_config(create=create_config).get("temp_dir")
def set_temp_dir(path: str) -> None:
@@ -206,21 +209,34 @@ def unset_temp_dir() -> None:
_unset_config_key("temp_dir")
-def get_install_target(*, create_config: bool = True) -> str | list[str] | None:
+def get_install_target(
+ *, create_config: bool = True, strict: bool = False
+) -> str | list[str] | None:
"""Get the configured default target used by ``apm install``.
Args:
create_config: When false, do not create a missing user config file.
+ strict: Reject invalid present values rather than treating them as unset.
Returns:
Parsed target value from config, or ``None`` when unset/invalid.
"""
from apm_cli.core.target_detection import parse_target_field
- value = get_config(create=create_config).get(_INSTALL_TARGET_KEY)
+ settings = get_config(create=create_config)
+ if _INSTALL_TARGET_KEY not in settings:
+ return None
try:
- return parse_target_field(value)
- except ValueError:
+ parsed = parse_target_field(settings[_INSTALL_TARGET_KEY])
+ if strict and parsed is None:
+ raise ValueError("target value must not be null")
+ return parsed
+ except ValueError as exc:
+ if strict:
+ raise ValueError(
+ "Invalid saved target configuration. Correct 'apm config set target' "
+ f"or use 'apm config unset target': {exc}"
+ ) from exc
return None
@@ -489,13 +505,16 @@ def get_apm_protocol_pref(
# ---------------------------------------------------------------------------
-def get_copilot_cowork_skills_dir() -> str | None:
+def get_copilot_cowork_skills_dir(*, create_config: bool = True) -> str | None:
"""Get the configured cowork skills directory.
+ Args:
+ create_config: When false, leave missing configuration absent during discovery.
+
Returns:
The stored ``copilot_cowork_skills_dir`` config value, or ``None`` if not set.
"""
- return get_config().get("copilot_cowork_skills_dir")
+ return get_config(create=create_config).get("copilot_cowork_skills_dir")
def set_copilot_cowork_skills_dir(path: str) -> None:
@@ -644,7 +663,7 @@ def is_registry_default(name: str) -> bool:
return bool(cfg and cfg.get("default") is True)
-def get_apm_temp_dir() -> str | None:
+def get_apm_temp_dir(*, create_config: bool = True) -> str | None:
"""Return the effective temporary directory for APM operations.
Resolution order:
@@ -654,13 +673,16 @@ def get_apm_temp_dir() -> str | None:
Empty or whitespace-only values are treated as unset and skipped.
+ Args:
+ create_config: When false, leave missing user configuration absent.
+
Returns:
Directory path string, or None when the system default should be used.
"""
env_val = os.environ.get("APM_TEMP_DIR", "").strip()
if env_val:
return env_val
- config_val = (get_temp_dir() or "").strip()
+ config_val = (get_temp_dir(create_config=create_config) or "").strip()
if config_val:
return config_val
return None
diff --git a/src/apm_cli/core/target_detection.py b/src/apm_cli/core/target_detection.py
index f33ae3451d..24af4a577e 100644
--- a/src/apm_cli/core/target_detection.py
+++ b/src/apm_cli/core/target_detection.py
@@ -930,6 +930,7 @@ def resolve_effective_target_decision(
user_scope: bool = False,
auto_detect: bool = True,
create_config: bool = True,
+ strict_config: bool = False,
) -> EffectiveTargetDecision:
"""Choose the effective install target once using the public precedence.
@@ -947,7 +948,11 @@ def resolve_effective_target_decision(
from apm_cli.config import get_install_target
- configured_target = get_install_target(create_config=create_config)
+ configured_target = (
+ get_install_target(create_config=create_config, strict=True)
+ if strict_config
+ else get_install_target(create_config=create_config)
+ )
if configured_target is not None:
return EffectiveTargetDecision(configured_target, "apm config target")
diff --git a/src/apm_cli/deps/apm_resolver.py b/src/apm_cli/deps/apm_resolver.py
index ab6a826f75..9fac31a884 100644
--- a/src/apm_cli/deps/apm_resolver.py
+++ b/src/apm_cli/deps/apm_resolver.py
@@ -9,7 +9,7 @@
from concurrent.futures import ThreadPoolExecutor
from dataclasses import fields, is_dataclass, replace
from pathlib import Path, PureWindowsPath
-from typing import TYPE_CHECKING, NoReturn, Optional, Protocol
+from typing import TYPE_CHECKING, Literal, NoReturn, Optional, Protocol
from ..bundle.local_bundle import route_agent_plugin_package
from ..models.apm_package import APMPackage, DependencyReference
@@ -500,8 +500,16 @@ def _expand_or_reject_remote_parent_local_path(
child_dep: DependencyReference,
) -> DependencyReference | None:
"""Expand eligible remote ``path:`` deps, otherwise fail closed."""
- if not (child_dep.is_local and child_dep.local_path and self._is_remote_parent(parent_pkg)):
+ if not (child_dep.is_local and child_dep.local_path):
return child_dep
+ kind = self._source_kind_for_dependency(parent_dep)
+ if kind == "local":
+ return child_dep
+ if kind is None:
+ self._reject_remote_parent_local_path(
+ child_dep, parent_pkg, "declaring dependency has no established source kind."
+ )
+ return None
try:
return self._expand_remote_parent_local_path(parent_dep, parent_pkg, child_dep)
except PathTraversalError as exc:
@@ -641,6 +649,8 @@ def build_dependency_tree(
tree = DependencyTree(root_package=empty_package)
return tree
+ # Root selection is explicit local context, not manifest-provided provenance.
+ root_package = replace(root_package, proven_source_kind="local")
# Initialize the tree
tree = DependencyTree(root_package=root_package)
@@ -1224,6 +1234,7 @@ def _try_load_dependency_package(
validation.package,
downloaded_candidate,
had_existing_install,
+ dep_ref,
)
package_type, _ = detect_package_type(install_path)
@@ -1252,6 +1263,7 @@ def _try_load_dependency_package(
validation.package,
downloaded_candidate,
had_existing_install,
+ dep_ref,
)
# Look for apm.yml in the install path
@@ -1273,6 +1285,7 @@ def _try_load_dependency_package(
package,
downloaded_candidate,
had_existing_install,
+ dep_ref,
)
# No manifest found
self._raise_downloaded_package_error(
@@ -1312,6 +1325,7 @@ def _try_load_dependency_package(
package,
downloaded_candidate,
had_existing_install,
+ dep_ref,
)
def _activate_validated_package(
@@ -1319,8 +1333,11 @@ def _activate_validated_package(
package: APMPackage,
downloaded_candidate: Path | None,
had_existing_install: bool,
+ dep_ref: DependencyReference,
) -> APMPackage:
"""Publish one validated candidate and remap its package paths."""
+ # from_apm_yml caches package objects; provenance belongs to this acquisition.
+ package = replace(package, proven_source_kind=self._source_kind_for_dependency(dep_ref))
if downloaded_candidate is None or self._activation_callback is None:
return package
try:
@@ -1403,36 +1420,23 @@ def _remap_candidate_path(
return path
return live_path / relative
+ @staticmethod
+ def _source_kind_for_dependency(
+ dep_ref: DependencyReference,
+ ) -> Literal["local", "git", "registry"] | None:
+ """Classify the actual acquisition route, never the repository spelling."""
+ if dep_ref.source == "registry":
+ return "registry"
+ if dep_ref.source not in {"git", "local"}:
+ return None
+ if dep_ref.is_local:
+ return "local" if dep_ref.local_path else None
+ return "git" if dep_ref.source == "git" else None
+
@staticmethod
def _is_remote_parent(parent_pkg: APMPackage | None) -> bool:
- """Return True if *parent_pkg* is a REMOTE package (i.e. fetched via
- git URL or pinned by ref/path).
-
- Used to gate ``local_path`` deps: only the root project and other
- local packages may legitimately declare them. Remote packages
- declaring a local_path is a path-confusion vector.
-
- SECURITY NOTE: this is a heuristic on the ``source`` field. A
- sufficiently adversarial remote could spoof a local-looking source.
- The downstream containment check via ``ensure_path_within`` is the
- actual security boundary; this gate just produces the user-facing
- error early.
- """
- if parent_pkg is None or not parent_pkg.source:
- return False
- src = str(parent_pkg.source)
- # Local deps get ``source = "_local/"`` (see DependencyReference
- # construction for is_local=True). Treat that prefix as definitively
- # local even though it contains a slash.
- if src.startswith("_local/"):
- return False
- # Remote sources look like URLs or owner/repo refs. Local sources
- # are filesystem paths the user typed in their apm.yml.
- return (
- src.startswith(("http://", "https://", "git@", "ssh://", "git+"))
- or "://" in src
- or (src.count("/") >= 1 and not src.startswith((".", "/", "~")))
- )
+ """Require the remote-path backstop for non-local or unknown parent origins."""
+ return parent_pkg is not None and parent_pkg.proven_source_kind != "local"
@staticmethod
def _compute_dep_source_path(
diff --git a/src/apm_cli/deps/git_auth_env.py b/src/apm_cli/deps/git_auth_env.py
index 5021fc920e..0365f407eb 100644
--- a/src/apm_cli/deps/git_auth_env.py
+++ b/src/apm_cli/deps/git_auth_env.py
@@ -83,13 +83,13 @@ def setup_environment(self) -> dict[str, Any]:
@staticmethod
def isolated_global_config_path() -> str:
- """Return a cross-platform empty Git config path."""
+ """Return an empty Git config path without bootstrapping user config."""
if sys.platform == "win32":
import tempfile
from ..config import get_apm_temp_dir
- temp_base = get_apm_temp_dir() or tempfile.gettempdir()
+ temp_base = get_apm_temp_dir(create_config=False) or tempfile.gettempdir()
empty_cfg = os.path.join(temp_base, ".apm_empty_gitconfig")
with open(empty_cfg, "w", encoding="ascii"):
pass
diff --git a/src/apm_cli/deps/github_downloader.py b/src/apm_cli/deps/github_downloader.py
index aa355ee454..a34655f1e0 100644
--- a/src/apm_cli/deps/github_downloader.py
+++ b/src/apm_cli/deps/github_downloader.py
@@ -205,6 +205,8 @@ def __init__(
transport_selector: TransportSelector | None = None,
protocol_pref: ProtocolPreference | None = None,
allow_fallback: bool | None = None,
+ *,
+ create_config: bool = True,
):
"""Initialize the GitHub package downloader.
@@ -221,6 +223,8 @@ def __init__(
``APM_ALLOW_PROTOCOL_FALLBACK`` env var, then
``allow-protocol-fallback`` in ``~/.apm/config.json``,
then ``False``.
+ create_config: Whether transport preference reads may initialize
+ missing user configuration. Read-only replay passes False.
"""
self.auth_resolver = auth_resolver or AuthResolver()
self.token_manager = self.auth_resolver._token_manager # Backward compat
@@ -235,7 +239,7 @@ def __init__(
from ..config import get_apm_protocol_pref as _get_pref
from .transport_selection import ProtocolPreference
- _pref_str = _get_pref()
+ _pref_str = _get_pref(create_config=create_config)
self._protocol_pref = ProtocolPreference.from_str(_pref_str)
if allow_fallback is not None:
self._allow_fallback = allow_fallback
@@ -243,7 +247,7 @@ def __init__(
# Config-aware helper (env > apm config > False).
from ..config import get_apm_allow_protocol_fallback as _get_fallback
- self._allow_fallback = _get_fallback()
+ self._allow_fallback = _get_fallback(create_config=create_config)
# Dedup set for the issue #786 cross-protocol port warning: one install
# run calls _clone_with_fallback multiple times per dep (ref-resolution
# clone, then the actual dep clone). We want the warning exactly once
diff --git a/src/apm_cli/install/audit_replay.py b/src/apm_cli/install/audit_replay.py
index d5dc5df647..acefe03f01 100644
--- a/src/apm_cli/install/audit_replay.py
+++ b/src/apm_cli/install/audit_replay.py
@@ -15,15 +15,15 @@
from pathlib import Path
from apm_cli.deps.lockfile import LockFile, get_lockfile_path
+from apm_cli.install.audit_target_roots import AuditTargetError, resolve_audit_targets
from apm_cli.install.drift import (
CheckLogger,
ReplayConfig,
_make_scratch_root,
- _read_apm_yml_target,
run_replay,
)
from apm_cli.install.plan import lockfile_satisfies_manifest
-from apm_cli.integration.targets import TargetProfile, resolve_targets
+from apm_cli.integration.targets import TargetProfile
from apm_cli.models.apm_package import APMPackage
@@ -76,7 +76,7 @@ def prepare_ci_audit_replay(
f"lockfile not found at {lockfile_path}; run 'apm install' to generate it"
)
- manifest = APMPackage.from_apm_yml(project_root / "apm.yml")
+ manifest = APMPackage.from_apm_yml(project_root / "apm.yml", create_config=False)
lockfile = LockFile.read(lockfile_path)
if lockfile is None:
raise CiAuditReplayError(f"lockfile at {lockfile_path} is empty or unreadable")
@@ -87,6 +87,10 @@ def prepare_ci_audit_replay(
if not satisfied:
raise CiAuditReplayError("apm.lock.yaml is out of sync with apm.yml. " + " ".join(reasons))
+ try:
+ targets = resolve_audit_targets(project_root, user_scope=user_scope)
+ except AuditTargetError as exc:
+ raise CiAuditReplayError(str(exc)) from exc
scratch_root = _make_scratch_root(project_root)
modules_root = scratch_root / "apm_modules"
config = ReplayConfig(
@@ -96,6 +100,7 @@ def prepare_ci_audit_replay(
scratch_root=scratch_root,
modules_root=modules_root,
user_scope=user_scope,
+ resolved_targets=targets,
)
stderr_context = (
contextlib.nullcontext() if verbose else contextlib.redirect_stderr(io.StringIO())
@@ -106,17 +111,10 @@ def prepare_ci_audit_replay(
except Exception as exc:
raise CiAuditReplayError(str(exc)) from exc
- explicit_target = _read_apm_yml_target(project_root)
return PreparedCiAuditReplay(
scratch_root=scratch_root,
modules_root=modules_root,
lockfile_path=lockfile_path,
tracked_files=_git_tracked_files(project_root),
- targets=tuple(
- resolve_targets(
- scratch_root,
- user_scope=user_scope,
- explicit_target=explicit_target,
- )
- ),
+ targets=targets,
)
diff --git a/src/apm_cli/install/audit_target_roots.py b/src/apm_cli/install/audit_target_roots.py
index 6e0380895c..173b9700a8 100644
--- a/src/apm_cli/install/audit_target_roots.py
+++ b/src/apm_cli/install/audit_target_roots.py
@@ -1,27 +1,119 @@
-"""Scratch projection helpers for audit target deployment roots."""
+"""Read-only target resolution and scratch deployment roots for audit."""
from __future__ import annotations
from dataclasses import replace
from pathlib import Path
+from typing import TYPE_CHECKING
-from apm_cli.integration.targets import TargetProfile
+import click
+import yaml
+
+from apm_cli.integration.targets import KNOWN_TARGETS, TargetProfile, resolve_targets
from apm_cli.utils.path_security import PathTraversalError, ensure_path_within
_EXTERNAL_REPLAY_ROOT = ".apm-audit-targets"
+if TYPE_CHECKING:
+ from apm_cli.deps.lockfile import LockFile
+
+
+class AuditTargetError(ValueError):
+ """Current audit target intent cannot be resolved safely."""
+
+
+def resolve_audit_targets(
+ project_root: Path, *, user_scope: bool = False
+) -> tuple[TargetProfile, ...]:
+ """Adapt the canonical current-intent decision to read-only audit profiles."""
+ from apm_cli.core.apm_yml import read_declared_target_names
+ from apm_cli.core.target_detection import resolve_effective_target_decision
+
+ try:
+ decision = resolve_effective_target_decision(
+ project_root,
+ explicit_target=None,
+ manifest_target=read_declared_target_names(project_root),
+ user_scope=user_scope,
+ auto_detect=False,
+ create_config=False,
+ strict_config=True,
+ )
+ selected_targets = decision.canonical_targets
+ targets = resolve_targets(
+ project_root,
+ user_scope=user_scope,
+ explicit_target=list(selected_targets) if selected_targets is not None else None,
+ create_config=False,
+ )
+ except (OSError, ValueError, yaml.YAMLError, click.ClickException) as exc:
+ raise AuditTargetError(str(exc)) from exc
+
+ unavailable = set(selected_targets or ()) - {target.name for target in targets}
+ if unavailable:
+ names = ", ".join(sorted(unavailable))
+ raise AuditTargetError(
+ f"Cannot audit selected target(s): {names}. Restore their experimental/runtime "
+ "prerequisites for this scope, or correct apm.yml / 'apm config set target'."
+ )
+ return tuple(targets)
-def replay_target(target: TargetProfile) -> TargetProfile:
+
+def _require_filesystem_replay(target: TargetProfile) -> None:
+ """Native runtime records without a filesystem decoder cannot use scratch."""
+ if target.external_locator_encoder is not None and target.external_locator_decoder is None:
+ raise AuditTargetError(
+ f"Target '{target.name}' has no isolated filesystem scratch replay backend. "
+ "Audit cannot safely replay this native runtime; live state was not modified."
+ )
+
+
+def replay_target(target: TargetProfile, scratch_root: Path) -> TargetProfile:
"""Return a scratch-contained profile for replay-only integration."""
+ _require_filesystem_replay(target)
if target.managed_deploy_root is None:
return target
return replace(
target,
root_dir=f"{_EXTERNAL_REPLAY_ROOT}/{target.name}",
- resolved_deploy_root=None,
+ resolved_deploy_root=(
+ external_replay_root(scratch_root, target)
+ if target.resolved_deploy_root is not None
+ else None
+ ),
)
+def audit_comparison_targets(
+ lockfile: LockFile, selected: tuple[TargetProfile, ...], *, user_scope: bool
+) -> tuple[TargetProfile, ...]:
+ """Retain recorded native roots for comparison without authorizing replay."""
+ from apm_cli.core.deployment_ledger import DeploymentLedgerCodec
+
+ ledger = DeploymentLedgerCodec.from_lockfile(lockfile)
+ names = {record.locator.target for record in ledger.records.values()}
+ claims = DeploymentLedgerCodec.legacy_deployed_file_claims(lockfile)
+ result = {target.name: target for target in selected}
+ for name, profile in KNOWN_TARGETS.items():
+ if name in result or not (
+ name in names or any(path.startswith(profile.lockfile_uri_schemes) for path in claims)
+ ):
+ continue
+ _require_filesystem_replay(profile)
+ try:
+ scoped = profile.for_scope(user_scope=user_scope)
+ except (OSError, ValueError) as exc:
+ raise AuditTargetError(f"Cannot compare recorded target '{name}': {exc}") from exc
+ if scoped is None or (profile.lockfile_uri_schemes and scoped.managed_deploy_root is None):
+ raise AuditTargetError(
+ f"Cannot compare recorded target '{name}': its deployment root is unavailable. "
+ "Restore the runtime root or reconcile the recorded ownership."
+ )
+ if scoped.managed_deploy_root is not None:
+ result[name] = scoped
+ return tuple(result.values())
+
+
def external_replay_root(scratch_root: Path, target: TargetProfile) -> Path:
"""Return the scratch projection root for an external target."""
return scratch_root / _EXTERNAL_REPLAY_ROOT / target.name
@@ -30,6 +122,15 @@ def external_replay_root(scratch_root: Path, target: TargetProfile) -> Path:
def external_target_relative_roots(target: TargetProfile) -> set[str]:
"""Return bounded paths governed below an external target root."""
roots: set[str] = set()
+ if "skills" in target.primitives:
+ from apm_cli.integration.skill_integrator import SkillIntegrator
+
+ deploy_root = target.managed_deploy_root
+ if deploy_root is not None:
+ skills_root = SkillIntegrator._target_skills_root(target, deploy_root)
+ roots.add(
+ ensure_path_within(skills_root, deploy_root).relative_to(deploy_root).as_posix()
+ )
for mapping in target.primitives.values():
if mapping.deploy_root:
deploy_root = Path(mapping.deploy_root)
diff --git a/src/apm_cli/install/drift.py b/src/apm_cli/install/drift.py
index 2ce8b03365..443cfdfef2 100644
--- a/src/apm_cli/install/drift.py
+++ b/src/apm_cli/install/drift.py
@@ -88,6 +88,7 @@ class ReplayConfig:
scratch_root: Path | None = None
modules_root: Path | None = None
user_scope: bool = False
+ resolved_targets: tuple[TargetProfile, ...] | None = None
@dataclass(frozen=True)
@@ -432,7 +433,7 @@ def _build_package_info(
apm_yml = install_path / "apm.yml"
if apm_yml.exists():
try:
- pkg = APMPackage.from_apm_yml(apm_yml, source_path=install_path)
+ pkg = APMPackage.from_apm_yml(apm_yml, source_path=install_path, create_config=False)
except Exception:
pkg = APMPackage(
name=install_path.name,
@@ -506,40 +507,11 @@ def _filter_targets(all_targets, names: frozenset[str] | None):
return [t for t in all_targets if t.name in names]
-def _read_apm_yml_target(project_root: Path):
- """Return the explicit target list from ``apm.yml`` if declared, else ``None``.
+def _read_apm_yml_target(project_root: Path) -> list[str] | None:
+ """Compatibility wrapper for the authoritative manifest target reader."""
+ from apm_cli.core.apm_yml import read_declared_target_names
- Handles both the singular ``target:`` and plural ``targets:`` forms so
- that the replay uses the same target set the install pipeline used.
- Without this, a project with ``targets: [claude, codex]`` (no copilot)
- that also has a ``.github/`` directory for unrelated CI workflows would
- have copilot auto-detected during replay, producing false
- ``unintegrated`` findings for ``.github/instructions/`` (#1924).
- """
- apm_yml = project_root / "apm.yml"
- if not apm_yml.exists():
- return None
- try:
- # Route through the merge/alias-bounded loader (not stock yaml.safe_load)
- # so a hostile apm.yml shipped in a cloned repo cannot wedge the default-on
- # ``apm audit`` drift replay with a billion-laughs merge/alias bomb.
- from apm_cli.utils.yaml_io import load_yaml
-
- data = load_yaml(apm_yml) or {}
- except Exception:
- # Manifest unreadable / corrupt: fall back to auto-detect rather
- # than crashing the replay; the caller still surfaces a useful
- # error elsewhere if the project is truly broken.
- return None
- # parse_targets_field handles both 'target:' (singular) and 'targets:'
- # (plural list) and validates the tokens against the canonical set.
- try:
- from apm_cli.core.apm_yml import parse_targets_field
-
- tokens = parse_targets_field(data)
- return tokens if tokens else None
- except Exception:
- return None
+ return read_declared_target_names(project_root)
def run_replay(config: ReplayConfig, logger: CheckLogger) -> Path:
@@ -554,9 +526,8 @@ def run_replay(config: ReplayConfig, logger: CheckLogger) -> Path:
Surfaced verbatim when a locked dep cannot be materialized.
"""
from apm_cli.deps.lockfile import _SELF_KEY, LockFile
- from apm_cli.install.audit_target_roots import replay_target
+ from apm_cli.install.audit_target_roots import replay_target, resolve_audit_targets
from apm_cli.install.services import IntegratorBundle, integrate_package_primitives
- from apm_cli.integration.targets import resolve_targets
from apm_cli.utils.diagnostics import DiagnosticCollector
if not config.lockfile_path.exists():
@@ -583,18 +554,15 @@ def run_replay(config: ReplayConfig, logger: CheckLogger) -> Path:
)
live_modules_dir = project_root / "apm_modules"
- # Honor apm.yml's ``target:`` field so multi-target projects replay
- # into all governed roots (not just whichever directory happens to
- # already exist via auto-detection). Without this, a project that
- # targets ``copilot,claude,cursor`` would replay only the primary
- # auto-detected target and report the others as ``orphaned``.
- explicit_target = _read_apm_yml_target(project_root)
- live_targets = resolve_targets(
- project_root,
- user_scope=config.user_scope,
- explicit_target=explicit_target,
+ live_targets = (
+ config.resolved_targets
+ if config.resolved_targets is not None
+ else resolve_audit_targets(project_root, user_scope=config.user_scope)
)
- targets = [replay_target(target) for target in _filter_targets(live_targets, config.targets)]
+ targets = [
+ replay_target(target, scratch_root)
+ for target in _filter_targets(live_targets, config.targets)
+ ]
registries: dict[str, str] | None = None
downloader = None
registry_resolver = None
@@ -604,10 +572,10 @@ def run_replay(config: ReplayConfig, logger: CheckLogger) -> Path:
from apm_cli.deps.registry.resolver import RegistryPackageResolver
from apm_cli.models.apm_package import APMPackage
- downloader = GitHubPackageDownloader(auth_resolver=AuthResolver())
+ downloader = GitHubPackageDownloader(auth_resolver=AuthResolver(), create_config=False)
apm_yml = project_root / "apm.yml"
if apm_yml.exists():
- manifest = APMPackage.from_apm_yml(apm_yml)
+ manifest = APMPackage.from_apm_yml(apm_yml, create_config=False)
registries = getattr(manifest, "registries", None) or {}
if registries:
registry_resolver = RegistryPackageResolver(registries)
@@ -754,15 +722,29 @@ def _governed_root_dirs(targets: list[TargetProfile]) -> set[str]:
return roots
-def _walk_managed(root: Path, governed_roots: set[str]) -> dict[str, Path]:
- """Return a mapping of project-relative posix paths to absolute paths."""
+def _walk_managed(
+ root: Path,
+ governed_roots: set[str],
+ *,
+ walked_dirs: set[Path] | None = None,
+) -> dict[str, Path]:
+ """Collect files, visiting each safely covered directory once per comparison."""
+ from apm_cli.utils.path_security import ensure_path_within, has_symlink_component
+
out: dict[str, Path] = {}
if not root.exists():
return out
- for top in governed_roots:
+ if walked_dirs is None:
+ walked_dirs = set()
+ for top in sorted(governed_roots, key=lambda path: path.rstrip("/").count("/")):
base = root / top
+ if has_symlink_component(root, base):
+ continue
+ base = ensure_path_within(base, root)
if not base.exists():
continue
+ if base in walked_dirs or any(parent in walked_dirs for parent in base.parents):
+ continue
if base.is_file() and not base.is_symlink():
out[top] = base
continue
@@ -770,6 +752,9 @@ def _walk_managed(root: Path, governed_roots: set[str]) -> dict[str, Path]:
if p.is_file() and not p.is_symlink():
rel = p.relative_to(root).as_posix()
out[rel] = p
+ # Only validated directory walks can suppress descendant enumeration.
+ if base.is_dir():
+ walked_dirs.add(base)
# AGENTS.md is a flat top-level file in some target layouts.
agents_md = root / "AGENTS.md"
if agents_md.is_file() and not agents_md.is_symlink():
@@ -878,7 +863,8 @@ def diff_scratch_against_project(
project_root = project_root.resolve()
governed = governed_roots if governed_roots is not None else _governed_root_dirs(targets)
scratch_files = _walk_managed(scratch_root, governed)
- project_files = _walk_managed(project_root, governed)
+ walked_dirs: set[Path] = set()
+ project_files = _walk_managed(project_root, governed, walked_dirs=walked_dirs)
from apm_cli.install.audit_target_roots import claims_for_root
tracked = claims_for_root(
@@ -887,18 +873,35 @@ def diff_scratch_against_project(
absolute_only=absolute_claims_only,
targets=tuple(targets),
)
- claimed_prefixes = _claimed_prefixes(
- tracked,
- set(
- claims_for_root(
- {path: "" for path in _collect_hashed_files(lockfile)},
- project_root,
- absolute_only=absolute_claims_only,
- targets=tuple(targets),
- )
- ),
- project_files,
+ hashed_files = set(
+ claims_for_root(
+ {path: "" for path in _collect_hashed_files(lockfile)},
+ project_root,
+ absolute_only=absolute_claims_only,
+ targets=tuple(targets),
+ )
)
+ # A narrowed desired target set must not hide files still claimed under
+ # the old target. Claims widen comparison only, never source replay.
+ from apm_cli.utils.path_security import ensure_path_within, has_symlink_component
+
+ for rel in sorted(tracked, key=lambda path: path.rstrip("/").count("/")):
+ if rel not in project_files:
+ candidate = project_root / rel
+ if has_symlink_component(project_root, candidate):
+ continue
+ path = ensure_path_within(candidate, project_root)
+ if path.is_file():
+ project_files[rel] = path
+ elif path.is_dir() and rel not in hashed_files:
+ project_files.update(_walk_managed(project_root, {rel}, walked_dirs=walked_dirs))
+ claimed_prefixes = _claimed_prefixes(tracked, hashed_files, project_files)
+ prefix_set = set(claimed_prefixes)
+ prefix_owners = {
+ path.rstrip("/") + "/": owner
+ for path, owner in tracked.items()
+ if path.rstrip("/") + "/" in prefix_set
+ }
# Hook merge targets are shared with the user and never claimed in
# deployed_files, so they can never be "unrecorded". Their APM-owned slice
# is compared through hook_ownership; sidecars remain byte-for-byte owned.
@@ -916,18 +919,9 @@ def diff_scratch_against_project(
# orphaned.
from apm_cli.core.deployment_ledger import DeploymentLedgerCodec
- local_bundle_paths = DeploymentLedgerCodec.local_bundle_paths(lockfile)
- if local_bundle_paths:
- scratch_files = {
- relative_path: path
- for relative_path, path in scratch_files.items()
- if relative_path not in local_bundle_paths
- }
- project_files = {
- relative_path: path
- for relative_path, path in project_files.items()
- if relative_path not in local_bundle_paths
- }
+ for relative_path in DeploymentLedgerCodec.local_bundle_paths(lockfile):
+ scratch_files.pop(relative_path, None)
+ project_files.pop(relative_path, None)
# Canvas extensions are executable bundles that the drift replay does
# not re-integrate (their integrator is intentionally omitted from the
@@ -1006,12 +1000,19 @@ def _is_canvas(rel: str) -> bool:
for rel in sorted(project_files.keys()):
if rel in scratch_files:
continue
- if rel in tracked:
+ owner = tracked.get(rel)
+ # Exact claims win; otherwise probe only the deepest-to-shallowest
+ # parents, O(path depth) rather than scanning every directory claim.
+ parent = rel.rpartition("/")[0]
+ while owner is None and parent:
+ owner = prefix_owners.get(parent + "/")
+ parent = parent.rpartition("/")[0]
+ if owner is not None:
findings.append(
DriftFinding(
path=rel,
kind="orphaned",
- package=tracked.get(rel, ""),
+ package=owner,
)
)
# else: untracked governed file -- ignore (user authored).
diff --git a/src/apm_cli/install/package_resolution.py b/src/apm_cli/install/package_resolution.py
index 68ceda9851..043843e1ef 100644
--- a/src/apm_cli/install/package_resolution.py
+++ b/src/apm_cli/install/package_resolution.py
@@ -10,11 +10,14 @@
import builtins
from collections.abc import Callable
-from typing import Any
+from typing import TYPE_CHECKING, Any
from apm_cli.install.gitlab_resolver import _GITLAB_DIRECT_SHORTHAND_UNRESOLVED
from apm_cli.utils.github_host import build_ssh_url
+if TYPE_CHECKING:
+ from apm_cli.models.apm_package import APMPackage
+
GIT_PARENT_USER_SCOPE_ERROR = (
"git: parent dependencies are not supported at user scope. "
"Use project scope or specify explicit git URL."
@@ -110,12 +113,16 @@ def resolve_parsed_dependency_reference(
return dep_ref, False
-def user_scope_rejection_reason(dep_ref: Any, scope: Any) -> str | None:
- """Return a validation-fail reason if *dep_ref* is invalid at user scope.
+def user_scope_rejection_reason(
+ dep_ref: Any, scope: Any, *, parent_pkg: APMPackage | None = None
+) -> str | None:
+ """Return a scope or declaring-source admission failure.
- Per #937, only relative local paths are rejected at user scope -- absolute
- local paths are unambiguous and flow through the same _copy_local_package
- code path as project scope.
+ Absolute local paths are unambiguous (#937). A relative transitive local
+ path also has an anchor when the resolver supplies its declaring local
+ package's original absolute source directory (#2815). Direct references
+ and unknown, remote, or unanchored parents retain the relative-path rejection.
+ A transitive local read requires established local provenance at every scope.
"""
if scope is None:
return None
@@ -129,10 +136,28 @@ def user_scope_rejection_reason(dep_ref: Any, scope: Any) -> str | None:
# which expanduser()s local paths before consuming them: `~/pkg` is
# absolute after expansion and must NOT be rejected here.
if not Path(local_path).expanduser().is_absolute():
+ if (
+ local_path
+ and dep_ref.declaring_parent
+ and parent_pkg is not None
+ and parent_pkg.proven_source_kind == "local"
+ and parent_pkg.source_path is not None
+ and parent_pkg.source_path.is_absolute()
+ ):
+ return None
return (
"relative local paths are not supported at user scope (--global). "
"Use an absolute path or a remote reference (owner/repo) instead"
)
+ if (
+ dep_ref.is_local
+ and (dep_ref.declaring_parent or parent_pkg is not None)
+ and (parent_pkg is None or parent_pkg.proven_source_kind != "local")
+ ):
+ return (
+ "local dependency has no established local declaring source. "
+ "Use a same-repository remote reference, or explicitly select a local source"
+ )
if dep_ref.is_parent_repo_inheritance and scope is InstallScope.USER:
return GIT_PARENT_USER_SCOPE_ERROR
return None
diff --git a/src/apm_cli/install/phases/resolve.py b/src/apm_cli/install/phases/resolve.py
index fdb0735d35..f53717223c 100644
--- a/src/apm_cli/install/phases/resolve.py
+++ b/src/apm_cli/install/phases/resolve.py
@@ -363,7 +363,6 @@ def _resolve_dependencies(
"""Resolve dependencies and populate the resolution fields on ``ctx``."""
import threading as _threading
- from apm_cli.core.scope import InstallScope
from apm_cli.deps.apm_resolver import APMDependencyResolver
from apm_cli.install.insecure_policy import (
_check_insecure_dependencies,
@@ -371,6 +370,7 @@ def _resolve_dependencies(
_guard_transitive_insecure_dependencies,
_warn_insecure_dependencies,
)
+ from apm_cli.install.package_resolution import user_scope_rejection_reason
from apm_cli.install.phases.local_content import _copy_local_package
# 3b. Dedicated registry resolver (design §3.1, §8)
@@ -553,15 +553,7 @@ def download_callback(dep_ref, modules_dir, parent_chain="", parent_pkg=None):
# Handle local packages: copy instead of git clone
if dep_ref.is_local and dep_ref.local_path:
- if (
- scope is InstallScope.USER
- and not Path(dep_ref.local_path).expanduser().is_absolute()
- ):
- # At user scope, relative local paths have no meaningful
- # root (cwd is arbitrary, $HOME is not a project). Only
- # absolute paths are unambiguous; reject relative refs.
- # Note: callback_failures is a set (see line ~105),
- # so use .add() rather than dict-style assignment.
+ if user_scope_rejection_reason(dep_ref, scope, parent_pkg=parent_pkg):
with callback_lock:
callback_failures.add(dep_ref.get_unique_key())
_tui = getattr(ctx, "tui", None)
diff --git a/src/apm_cli/install/sources.py b/src/apm_cli/install/sources.py
index e5743a5e23..50d3777c5c 100644
--- a/src/apm_cli/install/sources.py
+++ b/src/apm_cli/install/sources.py
@@ -118,9 +118,9 @@ def acquire(self) -> Materialization | None:
from apm_cli.agent_plugins.errors import AgentPluginError
from apm_cli.bundle.local_bundle import route_agent_plugin_package
from apm_cli.constants import APM_YML_FILENAME
- from apm_cli.core.scope import InstallScope
from apm_cli.deps._shared import materialize_marketplace_manifest
from apm_cli.deps.installed_package import InstalledPackage
+ from apm_cli.install.package_resolution import user_scope_rejection_reason
from apm_cli.install.phases.local_content import _copy_local_package
from apm_cli.models.apm_package import (
APMPackage,
@@ -139,25 +139,21 @@ def acquire(self) -> Materialization | None:
diagnostics = ctx.diagnostics
logger = ctx.logger
- # User scope: relative paths are project-relative and have no
- # meaningful root outside a project, so reject them. Absolute
- # paths are unambiguous and supported.
- if ctx.scope is InstallScope.USER:
+ parent_pkg = None
+ if dep_ref.declaring_parent:
+ node = ctx.dependency_graph.dependency_tree.get_node(dep_key)
+ if node is not None and node.parent is not None:
+ parent_pkg = node.parent.package
+ scope_reject = user_scope_rejection_reason(dep_ref, ctx.scope, parent_pkg=parent_pkg)
+ if scope_reject:
local_path_str = dep_ref.local_path or ""
- if not local_path_str or not Path(local_path_str).expanduser().is_absolute():
- diagnostics.warn(
- f"Skipped local package '{local_path_str}' "
- "-- relative local paths are not supported at user scope "
- "(--global). Use an absolute path or a remote reference "
- "(owner/repo) instead.",
- package=local_path_str,
- )
- if logger:
- logger.verbose_detail(
- f" Skipping {local_path_str} (relative local paths "
- "are project-relative and have no root at user scope)"
- )
- return None
+ diagnostics.warn(
+ f"Skipped local package '{local_path_str}' -- {scope_reject}.",
+ package=local_path_str,
+ )
+ if logger:
+ logger.verbose_detail(f" Skipping {local_path_str} ({scope_reject})")
+ return None
# Determine the anchor for relative ``local_path`` (#857). For
# direct deps from the root project this is ``ctx.source_root``
@@ -470,6 +466,9 @@ def acquire(self) -> Materialization | None:
package_path=install_path,
source=dep_ref.repo_url,
)
+ from apm_cli.models.apm_package import restore_installed_package_source
+
+ restore_installed_package_source(cached_package, dep_ref)
if not cached_package.source:
cached_package.source = dep_ref.repo_url
diff --git a/src/apm_cli/integration/copilot_cowork_paths.py b/src/apm_cli/integration/copilot_cowork_paths.py
index 1d416058af..f89bf59733 100644
--- a/src/apm_cli/integration/copilot_cowork_paths.py
+++ b/src/apm_cli/integration/copilot_cowork_paths.py
@@ -111,7 +111,7 @@ def resolve_copilot_cowork_skills_dir() -> Path | None:
# --- persisted config value ---
from apm_cli.config import get_copilot_cowork_skills_dir
- config_value = get_copilot_cowork_skills_dir()
+ config_value = get_copilot_cowork_skills_dir(create_config=False)
if config_value:
from apm_cli.utils.path_security import (
PathTraversalError,
diff --git a/src/apm_cli/integration/mcp_config_view.py b/src/apm_cli/integration/mcp_config_view.py
index 2f776a8bfc..7c0d9f8851 100644
--- a/src/apm_cli/integration/mcp_config_view.py
+++ b/src/apm_cli/integration/mcp_config_view.py
@@ -336,6 +336,7 @@ def _collect_locked_dependencies(
package = APMPackage.from_apm_yml(
manifest_path,
source_path=manifest_path.parent,
+ create_config=False,
)
except (OSError, ValueError, UnicodeError) as exc:
problems.append(
diff --git a/src/apm_cli/models/apm_package.py b/src/apm_cli/models/apm_package.py
index b00e786c0e..d5316b673b 100644
--- a/src/apm_cli/models/apm_package.py
+++ b/src/apm_cli/models/apm_package.py
@@ -10,7 +10,7 @@
from collections.abc import Iterator, Mapping
from dataclasses import dataclass
from pathlib import Path
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING, Any, Literal
import yaml
@@ -344,6 +344,8 @@ class APMPackage:
# to boolean (e.g. ``{"owner/repo#v1.0": {"hooks": true}}``).
allow_executables: dict[str, dict[str, bool]] | None = None
agent_plugin: "AgentPlugin | None" = None
+ # Acquisition-only provenance; never parsed from or serialized to a manifest.
+ proven_source_kind: Literal["local", "git", "registry"] | None = None
def __post_init__(self) -> None:
"""Derive the canonical target projection for compatibility callers."""
@@ -843,6 +845,12 @@ def has_primitives(self) -> bool:
return False
+def restore_installed_package_source(package: APMPackage, dep_ref: DependencyReference) -> None:
+ """Restore Git acquisition provenance after loading installed authored metadata."""
+ if dep_ref.source in (None, "git"):
+ package.source = dep_ref.to_github_url()
+
+
def build_installed_package_info(
dep_ref: DependencyReference, apm_modules_dir: Path
) -> PackageInfo | None:
@@ -865,6 +873,8 @@ def build_installed_package_info(
if not package:
return None
+ restore_installed_package_source(package, dep_ref)
+
return PackageInfo(
package=package,
install_path=install_path,
diff --git a/src/apm_cli/policy/_shared.py b/src/apm_cli/policy/_shared.py
index 052875f35c..30853252a1 100644
--- a/src/apm_cli/policy/_shared.py
+++ b/src/apm_cli/policy/_shared.py
@@ -27,7 +27,7 @@ def _parse_apm_yml_safe(apm_yml_path: Path, result) -> object | None:
try:
clear_apm_yml_cache()
- return APMPackage.from_apm_yml(apm_yml_path)
+ return APMPackage.from_apm_yml(apm_yml_path, create_config=False)
except (ValueError, yaml.YAMLError, OSError) as exc:
result.checks.append(
CheckResult(
diff --git a/src/apm_cli/policy/ci_checks.py b/src/apm_cli/policy/ci_checks.py
index 5fa57f89f1..d3df35ef6e 100644
--- a/src/apm_cli/policy/ci_checks.py
+++ b/src/apm_cli/policy/ci_checks.py
@@ -724,31 +724,42 @@ def _check_drift(
from ..core.scope import get_workspace_deploy_root
from ..deps.lockfile import get_lockfile_path
from ..deps.path_anchoring import LocalResolutionError
- from ..install.audit_target_roots import external_replay_root
+ from ..install.audit_target_roots import (
+ AuditTargetError,
+ audit_comparison_targets,
+ external_replay_root,
+ resolve_audit_targets,
+ )
from ..install.drift import (
CacheMissError,
CheckLogger,
ReplayConfig,
- _read_apm_yml_target,
diff_scratch_against_project,
run_replay,
)
- from ..integration.targets import resolve_targets
logger = CheckLogger(verbose=verbose)
deployment_root = get_workspace_deploy_root(project_root)
user_scope = deployment_root != project_root.resolve()
if prepared_replay is None:
- config = ReplayConfig(
- project_root=project_root,
- lockfile_path=get_lockfile_path(project_root),
- targets=frozenset(targets) if targets else None,
- cache_only=cache_only,
- user_scope=user_scope,
- )
-
try:
+ resolved_targets = list(resolve_audit_targets(project_root, user_scope=user_scope))
+ config = ReplayConfig(
+ project_root=project_root,
+ lockfile_path=get_lockfile_path(project_root),
+ targets=frozenset(targets) if targets else None,
+ cache_only=cache_only,
+ user_scope=user_scope,
+ resolved_targets=tuple(resolved_targets),
+ )
scratch = run_replay(config, logger)
+ except AuditTargetError as exc:
+ return (
+ CheckResult(
+ name="drift", passed=False, message=f"drift target resolution failed: {exc}"
+ ),
+ [],
+ )
except AgentPluginDeploymentBoundaryError as exc:
return (
CheckResult(
@@ -792,11 +803,6 @@ def _check_drift(
),
[],
)
- resolved_targets = resolve_targets(
- project_root,
- user_scope=user_scope,
- explicit_target=_read_apm_yml_target(project_root),
- )
tracked_files = None
else:
scratch = prepared_replay.scratch_root
@@ -814,7 +820,16 @@ def _check_drift(
project_targets,
tracked_files=tracked_files,
)
- for target in resolved_targets:
+ try:
+ comparison_targets = audit_comparison_targets(
+ lockfile, tuple(resolved_targets), user_scope=user_scope
+ )
+ except AuditTargetError as exc:
+ return (
+ CheckResult(name="drift", passed=False, message=f"drift comparison failed: {exc}"),
+ findings,
+ )
+ for target in comparison_targets:
live_root = target.managed_deploy_root
if live_root is None:
continue
@@ -887,14 +902,6 @@ def run_baseline_checks(
result = CIAuditResult()
deployment_root = get_workspace_deploy_root(project_root)
user_scope = deployment_root != project_root.resolve()
- from ..install.drift import _read_apm_yml_target
- from ..integration.targets import resolve_targets
-
- resolved_targets = resolve_targets(
- deployment_root,
- user_scope=user_scope,
- explicit_target=_read_apm_yml_target(project_root),
- )
apm_yml_path = project_root / "apm.yml"
# Parse manifest ONCE -- this function owns parse-error handling.
@@ -964,8 +971,24 @@ def _run(check: CheckResult) -> bool:
if _run(_check_deployment_ledger_owners(lock)):
return result
+ from ..install.audit_target_roots import AuditTargetError, resolve_audit_targets
+
+ target_error = None
+ try:
+ resolved_targets = (
+ prepared_replay.targets
+ if prepared_replay is not None
+ else resolve_audit_targets(project_root, user_scope=user_scope)
+ )
+ except AuditTargetError as exc:
+ target_error = CheckResult(name="target-resolution", passed=False, message=str(exc))
+ result.checks.append(target_error)
+ resolved_targets = ()
+
# Check 4: Deployed files present
- if _run(_check_deployed_files_present(deployment_root, lock, resolved_targets)):
+ if target_error is not None or _run(
+ _check_deployed_files_present(deployment_root, lock, resolved_targets)
+ ):
return result
# Check 5: No orphaned packages
diff --git a/src/apm_cli/security/file_scanner.py b/src/apm_cli/security/file_scanner.py
index d97b897227..581a22aa36 100644
--- a/src/apm_cli/security/file_scanner.py
+++ b/src/apm_cli/security/file_scanner.py
@@ -146,7 +146,9 @@ def _scan_deployed_trees(
from ..install.manifest_reconcile import install_governance
from ..integration.targets import resolve_targets
- file_prefixes, _uri_schemes = install_governance(resolve_targets(project_root))
+ file_prefixes, _uri_schemes = install_governance(
+ resolve_targets(project_root, create_config=False)
+ )
result = _empty_scan()
for rel_path in _minimal_governed_prefixes(file_prefixes):
diff --git a/tests/fixtures/spec-conformance/manifest/valid-local-parent.yml b/tests/fixtures/spec-conformance/manifest/valid-local-parent.yml
new file mode 100644
index 0000000000..ee8cca959e
--- /dev/null
+++ b/tests/fixtures/spec-conformance/manifest/valid-local-parent.yml
@@ -0,0 +1,9 @@
+# req-mf-016: this selected local parent and its child are siblings outside
+# the consumer's project/user root; source anchoring is not deployment scope.
+name: parent
+version: 1.0.0
+targets:
+ - cursor
+dependencies:
+ apm:
+ - path: ../child
diff --git a/tests/integration/test_architecture_confirmed_bypass_mutations.py b/tests/integration/test_architecture_confirmed_bypass_mutations.py
index 928927ac17..e4a8ebe397 100644
--- a/tests/integration/test_architecture_confirmed_bypass_mutations.py
+++ b/tests/integration/test_architecture_confirmed_bypass_mutations.py
@@ -31,6 +31,71 @@ class BypassMutation:
BYPASS_MUTATIONS: tuple[BypassMutation, ...] = (
+ BypassMutation(
+ name="audit-canonical-target-owner",
+ rule_id="registry_delegation.install_target_selection",
+ path="src/apm_cli/install/audit_target_roots.py",
+ old="decision = resolve_effective_target_decision(",
+ new="decision = private_target_decision(",
+ ),
+ BypassMutation(
+ name="audit-read-only-config",
+ rule_id="registry_delegation.install_target_selection",
+ path="src/apm_cli/install/audit_target_roots.py",
+ old="create_config=False",
+ new="create_config=True",
+ replace_all=True,
+ ),
+ BypassMutation(
+ name="audit-strict-saved-target",
+ rule_id="registry_delegation.install_target_selection",
+ path="src/apm_cli/install/audit_target_roots.py",
+ old="strict_config=True",
+ new="strict_config=False",
+ ),
+ BypassMutation(
+ name="audit-native-writer-admission",
+ rule_id="registry_delegation.install_target_selection",
+ path="src/apm_cli/install/audit_target_roots.py",
+ old=" _require_filesystem_replay(target)\n",
+ new=" pass\n",
+ ),
+ BypassMutation(
+ name="audit-contracted-native-comparison",
+ rule_id="registry_delegation.install_target_selection",
+ path="src/apm_cli/policy/ci_checks.py",
+ old="comparison_targets = audit_comparison_targets(",
+ new="comparison_targets = private_comparison_targets(",
+ ),
+ BypassMutation(
+ name="audit-native-discovery-read-only",
+ rule_id="registry_delegation.install_target_selection",
+ path="src/apm_cli/integration/copilot_cowork_paths.py",
+ old="get_copilot_cowork_skills_dir(create_config=False)",
+ new="get_copilot_cowork_skills_dir(create_config=True)",
+ ),
+ BypassMutation(
+ name="audit-warm-replay-private-targets",
+ rule_id="registry_delegation.install_target_selection",
+ path="src/apm_cli/install/drift.py",
+ old="else resolve_audit_targets(project_root, user_scope=config.user_scope)",
+ new="else private_target_profiles(project_root, user_scope=config.user_scope)",
+ ),
+ BypassMutation(
+ name="audit-cold-replay-scratch-detection",
+ rule_id="registry_delegation.install_target_selection",
+ path="src/apm_cli/install/audit_replay.py",
+ old=" targets=targets,\n )",
+ new=" targets=resolve_targets(scratch_root),\n )",
+ ),
+ BypassMutation(
+ name="audit-baseline-private-targets",
+ rule_id="registry_delegation.install_target_selection",
+ path="src/apm_cli/policy/ci_checks.py",
+ old="resolve_audit_targets(project_root, user_scope=user_scope)",
+ new="private_target_profiles(project_root, user_scope=user_scope)",
+ replace_all=True,
+ ),
BypassMutation(
name="target-context-exemption",
rule_id="registry_delegation.install_target_selection",
diff --git a/tests/integration/test_architecture_owner_rule_mutations.py b/tests/integration/test_architecture_owner_rule_mutations.py
index 036f70b41d..1cf19b952a 100644
--- a/tests/integration/test_architecture_owner_rule_mutations.py
+++ b/tests/integration/test_architecture_owner_rule_mutations.py
@@ -221,6 +221,14 @@ class MutationCase:
new="def _hand_authored_root_context_blocks_write_disabled(",
intent="Root context writes lose the canonical hand-authored ownership gate.",
),
+ MutationCase(
+ guard_id="contracts-tooling-spec-assessment",
+ rule_id="contracts-tooling-spec-assessment",
+ path="tests/spec_conformance/_helpers.py",
+ old="selected_assessment().spec_path",
+ new="local_assessment().spec_path",
+ intent="Spec-text helper stops using the canonical selected assessment.",
+ ),
MutationCase(
guard_id="hooks-integrations-copilot-cli-mcp-paths",
rule_id="mutation_writes.copilot_cli_mcp_paths",
@@ -349,6 +357,14 @@ class MutationCase:
new="def set(",
intent="Config mutation stops routing through the canonical lifecycle lock.",
),
+ MutationCase(
+ guard_id="install-deployment-local-scope-admission",
+ rule_id="install-deployment-local-scope-admission",
+ path="src/apm_cli/deps/apm_resolver.py",
+ old="proven_source_kind=self._source_kind_for_dependency(dep_ref)",
+ new='proven_source_kind="local"',
+ intent="Resolver assigns trusted-local provenance without acquisition evidence.",
+ ),
MutationCase(
guard_id="install-deployment-lsp-lifecycle",
rule_id="install-deployment-lsp-lifecycle",
@@ -1153,6 +1169,21 @@ def test_owner_rules_report_nothing_before_mutation(
assert baseline_violated_rule_ids == frozenset()
+def test_local_scope_guard_retains_declaring_parent_delegation() -> None:
+ """Retain the earlier admission mutation alongside the stronger provenance case."""
+ case = MutationCase(
+ guard_id="install-deployment-local-scope-admission",
+ rule_id="install-deployment-local-scope-admission",
+ path="src/apm_cli/install/phases/resolve.py",
+ old="user_scope_rejection_reason(dep_ref, scope, parent_pkg=parent_pkg)",
+ new="user_scope_rejection_reason(dep_ref, scope, parent_pkg=None)",
+ intent="Resolution drops the declaring local parent's source context from admission.",
+ )
+ report = run_selected_rules(ROOT, (case.rule_id,), source_overrides={case.path: _mutate(case)})
+ assert not report.failures
+ assert {finding.rule_id for finding in report.violations} == {case.rule_id}
+
+
def test_git_semver_guard_rejects_bypassing_selected_attempt_requested_url() -> None:
"""AC13 must retain the selected transport attempt as the requested-URL owner."""
path = "src/apm_cli/install/helpers/ref_reuse.py"
@@ -1205,3 +1236,44 @@ def test_owner_rule_catches_its_guard_mutation(
f"({case.intent}) -- the guard has no teeth for this owner. "
f"failures={[(f.stage, f.message) for f in report.failures]}"
)
+
+
+@pytest.mark.parametrize(
+ ("path", "old", "new"),
+ [
+ (
+ "src/apm_cli/deps/git_auth_env.py",
+ "get_apm_temp_dir(create_config=False)",
+ "get_apm_temp_dir()",
+ ),
+ (
+ "src/apm_cli/config.py",
+ "get_temp_dir(create_config=create_config)",
+ "get_temp_dir()",
+ ),
+ (
+ "src/apm_cli/config.py",
+ 'get_config(create=create_config).get("temp_dir")',
+ 'get_config().get("temp_dir")',
+ ),
+ ],
+ ids=["sentinel-owner-route", "temp-owner-forwarding", "config-owner-forwarding"],
+)
+def test_auth_guard_requires_noncreating_sentinel_config_reads(
+ path: str, old: str, new: str
+) -> None:
+ """Reject a bootstrap bypass at each edge of the existing temp-config owner."""
+ rule_id = "transport-platform-host-credential-resolution"
+ baseline = run_selected_rules(ROOT, (rule_id,))
+ assert baseline.failures == ()
+ assert baseline.violations == ()
+ source = _source(path)
+ assert source.count(old) == 1
+ mutated = source.replace(old, new, 1)
+ ast.parse(mutated, filename=path)
+
+ report = run_selected_rules(ROOT, (rule_id,), source_overrides={path: mutated})
+
+ assert report.failures == ()
+ assert {finding.rule_id for finding in report.violations} == {rule_id}
+ assert {finding.path for finding in report.violations} == {path}
diff --git a/tests/integration/test_audit_target_intent_e2e.py b/tests/integration/test_audit_target_intent_e2e.py
new file mode 100644
index 0000000000..98f0a1c3eb
--- /dev/null
+++ b/tests/integration/test_audit_target_intent_e2e.py
@@ -0,0 +1,304 @@
+"""Installed-CLI proofs that audit replays current configured target intent."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+import shutil
+from dataclasses import dataclass, replace
+from pathlib import Path
+
+import pytest
+
+from apm_cli.deps.lockfile import LockFile
+from apm_cli.utils.yaml_io import dump_yaml, load_yaml
+from tests.utils.apm_lifecycle_runner import ApmLifecycleRunner, CommandResult
+from tests.utils.artifact_snapshot import ArtifactSnapshot, assert_unchanged
+from tests.utils.isolated_apm_environment import IsolatedApmEnvironment
+from tests.utils.local_package import LocalPackageFactory
+
+pytestmark = [
+ pytest.mark.integration,
+ pytest.mark.e2e,
+ pytest.mark.lifecycle_smoke,
+ pytest.mark.lifecycle_merge_group,
+ pytest.mark.requires_apm_binary,
+ pytest.mark.requires_e2e_mode,
+]
+
+_SKILL_NAME = "audit-target-intent"
+_SKILL_PATH = f".grok/skills/{_SKILL_NAME}/SKILL.md"
+_AUDIT_ARGS = ("audit", "--ci", "--no-policy", "--no-fail-fast", "--format", "json")
+
+
+@dataclass(frozen=True)
+class _Scenario:
+ isolated: IsolatedApmEnvironment
+ project: Path
+ skill: Path
+ runner: ApmLifecycleRunner
+
+ def run(self, *args: str, expected_exit: int = 0) -> CommandResult:
+ """Run the real CLI with the scenario's isolated environment."""
+ result = self.runner.run(
+ args,
+ scenario_id="audit-target-intent",
+ cwd=self.project,
+ env=self.isolated.subprocess_env(),
+ )
+ assert result.returncode == expected_exit, (
+ f"{result.command}\n{result.stdout}\n{result.stderr}"
+ )
+ return result
+
+ def audit(self, *, expected_exit: int = 0) -> dict:
+ """Audit without changing the project or the user's configuration."""
+ before_project = ArtifactSnapshot.capture(self.project)
+ before_config = ArtifactSnapshot.capture(self.isolated.config_root)
+ result = self.run(*_AUDIT_ARGS, expected_exit=expected_exit)
+ assert_unchanged(before_project, ArtifactSnapshot.capture(self.project))
+ assert_unchanged(before_config, ArtifactSnapshot.capture(self.isolated.config_root))
+ return json.loads(result.stdout)
+
+
+def _install_grok(
+ tmp_path: Path,
+ apm_binary_path: Path,
+) -> _Scenario:
+ """Install a local skill without any manifest target declaration."""
+ isolated = IsolatedApmEnvironment.create(tmp_path / "scenario", base_env=dict(os.environ))
+ sources = LocalPackageFactory(isolated.package_root)
+ package = sources.create("audit-source")
+ skill = sources.add_skill(
+ package,
+ _SKILL_NAME,
+ f"---\nname: {_SKILL_NAME}\ndescription: Audit target fixture\n---\n"
+ "# Source-derived clean skill\n",
+ )
+ project = LocalPackageFactory(isolated.work_root).create(
+ "audit-consumer",
+ dependencies=({"path": str(package.root)},),
+ )
+ sentinel = project.root / ".github/workflows/unrelated.yml"
+ sentinel.parent.mkdir(parents=True)
+ sentinel.write_text("name: unrelated\n", encoding="utf-8")
+ scenario = _Scenario(
+ isolated,
+ project.root,
+ skill,
+ ApmLifecycleRunner((str(apm_binary_path),), scenario_timeout_seconds=180),
+ )
+ scenario.run("experimental", "enable", "grok-cloud")
+ scenario.run("config", "set", "target", "grok-cloud")
+ scenario.run("install", "--target", "grok-cloud", "--no-policy", "--parallel-downloads", "0")
+
+ deployed = project.root / _SKILL_PATH
+ assert deployed.read_bytes() == skill.read_bytes()
+ assert not (project.root / ".agents/skills" / _SKILL_NAME).exists()
+ lock = LockFile.read(project.root / "apm.lock.yaml")
+ assert lock is not None
+ assert {record.locator.target for record in lock.deployment_ledger.records.values()} == {
+ "grok-cloud"
+ }
+ records = tuple(
+ record
+ for record in lock.deployment_ledger.records.values()
+ if record.locator.value == _SKILL_PATH
+ )
+ assert len(records) == 1
+ record = records[0]
+ assert record.locator.target == "grok-cloud"
+ assert record.locator.value == _SKILL_PATH
+ assert record.owners == (record.active_owner,)
+ assert record.active_owner in lock.dependencies
+ assert record.content_hash == f"sha256:{hashlib.sha256(skill.read_bytes()).hexdigest()}"
+ return scenario
+
+
+@pytest.mark.parametrize("cold_cache", [False, True], ids=["warm", "cold"])
+def test_clean_configured_grok_cloud_audit(
+ tmp_path: Path,
+ apm_binary_path: Path,
+ cold_cache: bool,
+) -> None:
+ """An unrelated CI directory cannot retarget a clean cloud installation."""
+ scenario = _install_grok(tmp_path, apm_binary_path)
+ if cold_cache:
+ shutil.rmtree(scenario.project / "apm_modules")
+ payload = scenario.audit()
+ assert payload["passed"] is True
+ checks = {check["name"]: check for check in payload["checks"]}
+ assert checks["drift"]["passed"] is True
+ assert checks["deployment-ledger-owners"]["passed"] is True
+ assert checks["content-integrity"]["passed"] is True
+
+
+@pytest.mark.parametrize(
+ ("mutation", "failed_check", "drift_kind"),
+ [
+ ("content", "content-integrity", "modified"),
+ ("forged-hash", "drift", "modified"),
+ ("missing-claim", "drift", "unrecorded"),
+ ("invalid-owner", "deployment-ledger-owners", None),
+ ("missing-file", "deployed-files-present", "unintegrated"),
+ ("removed-source", "drift", "orphaned"),
+ ("disabled", "target-resolution", None),
+ ],
+)
+def test_configured_target_keeps_real_audit_failures(
+ tmp_path: Path,
+ apm_binary_path: Path,
+ mutation: str,
+ failed_check: str,
+ drift_kind: str | None,
+) -> None:
+ """Configured intent must not depend on the integrity of installed claims."""
+ scenario = _install_grok(tmp_path, apm_binary_path)
+ deployed = scenario.project / _SKILL_PATH
+ lock_path = scenario.project / "apm.lock.yaml"
+ if mutation in {"content", "forged-hash"}:
+ deployed.write_bytes(deployed.read_bytes() + b"\nUnexpected deployed edit.\n")
+ if mutation in {"forged-hash", "missing-claim", "invalid-owner"}:
+ document = load_yaml(lock_path)
+ for row in list(document["deployments"]):
+ if mutation == "missing-claim" and (
+ row["value"] == _SKILL_PATH
+ or _SKILL_PATH.startswith(row["value"].rstrip("/") + "/")
+ ):
+ document["deployments"].remove(row)
+ continue
+ if row["value"] != _SKILL_PATH:
+ continue
+ if mutation == "forged-hash":
+ row["content_hash"] = f"sha256:{hashlib.sha256(deployed.read_bytes()).hexdigest()}"
+ else:
+ row["owners"] = ["departed-owner"]
+ row["active_owner"] = "departed-owner"
+ for dependency in document["dependencies"]:
+ if mutation == "forged-hash":
+ dependency["deployed_file_hashes"][_SKILL_PATH] = (
+ f"sha256:{hashlib.sha256(deployed.read_bytes()).hexdigest()}"
+ )
+ elif mutation == "missing-claim":
+ dependency["deployed_files"] = [
+ path
+ for path in dependency["deployed_files"]
+ if path != _SKILL_PATH and not _SKILL_PATH.startswith(path.rstrip("/") + "/")
+ ]
+ dependency["deployed_file_hashes"].pop(_SKILL_PATH)
+ dump_yaml(document, lock_path)
+ elif mutation == "missing-file":
+ deployed.unlink()
+ elif mutation == "removed-source":
+ scenario.skill.unlink()
+ elif mutation == "disabled":
+ scenario.run("experimental", "disable", "grok-cloud")
+
+ payload = scenario.audit(expected_exit=1)
+ checks = {check["name"]: check for check in payload["checks"]}
+ assert checks[failed_check]["passed"] is False
+ if drift_kind is not None:
+ assert f"{drift_kind}: {_SKILL_PATH}" in checks["drift"]["details"]
+
+
+def test_target_contraction_keeps_old_claims_in_comparison(
+ tmp_path: Path, apm_binary_path: Path
+) -> None:
+ """Old targets widen comparison coverage, not desired replay output."""
+ scenario = _install_grok(tmp_path, apm_binary_path)
+ scenario.run(
+ "install", "--target", "grok-cloud,claude", "--no-policy", "--parallel-downloads", "0"
+ )
+ payload = scenario.audit(expected_exit=1)
+ drift = next(check for check in payload["checks"] if check["name"] == "drift")
+ assert f"orphaned: .claude/skills/{_SKILL_NAME}/SKILL.md" in drift["details"]
+ assert not any(detail.startswith("unintegrated:") for detail in drift["details"])
+
+
+def test_manifest_beats_stale_saved_default(tmp_path: Path, apm_binary_path: Path) -> None:
+ """The validated manifest overrides a conflicting user default."""
+ scenario = _install_grok(tmp_path, apm_binary_path)
+ manifest_path = scenario.project / "apm.yml"
+ manifest = load_yaml(manifest_path)
+ manifest["targets"] = ["grok-build"]
+ dump_yaml(manifest, manifest_path)
+ scenario.run("config", "set", "target", "claude")
+ scenario.run("install", "--no-policy", "--parallel-downloads", "0")
+ assert scenario.audit()["passed"] is True
+
+
+def test_changed_default_audits_current_intent(tmp_path: Path, apm_binary_path: Path) -> None:
+ """Changing the default cannot silently bless the prior installed target."""
+ scenario = _install_grok(tmp_path, apm_binary_path)
+ scenario.run("config", "set", "target", "claude")
+ payload = scenario.audit(expected_exit=1)
+ drift = next(check for check in payload["checks"] if check["name"] == "drift")
+ assert f"unintegrated: .claude/skills/{_SKILL_NAME}/SKILL.md" in drift["details"]
+ assert f"orphaned: {_SKILL_PATH}" in drift["details"]
+
+
+def test_configured_grok_cloud_user_scope_audit(tmp_path: Path, apm_binary_path: Path) -> None:
+ """Global replay uses the user deployment root, without writing to HOME."""
+ scenario = _install_grok(tmp_path, apm_binary_path)
+ scenario.run(
+ "install",
+ "--global",
+ str(scenario.skill.parents[2]),
+ "--target",
+ "grok-cloud",
+ "--no-policy",
+ "--parallel-downloads",
+ "0",
+ )
+ home_skill = scenario.isolated.home / _SKILL_PATH
+ assert home_skill.read_bytes() == scenario.skill.read_bytes()
+ user = replace(scenario, project=scenario.isolated.config_root)
+ before_home = ArtifactSnapshot.capture(scenario.isolated.home)
+ assert user.audit()["passed"] is True
+ assert_unchanged(before_home, ArtifactSnapshot.capture(scenario.isolated.home))
+
+
+@pytest.mark.parametrize("failed_replay", [False, True], ids=["clean", "unsupported-native"])
+@pytest.mark.parametrize("cold_cache", [False, True], ids=["warm", "cold"])
+def test_audit_startup_leaves_fresh_home_unchanged_outside_test_mode(
+ tmp_path: Path, apm_binary_path: Path, failed_replay: bool, cold_cache: bool
+) -> None:
+ """The real entry point stays read-only without test-only update suppression."""
+ scenario = _install_grok(tmp_path, apm_binary_path)
+ manifest_path = scenario.project / "apm.yml"
+ manifest = load_yaml(manifest_path)
+ manifest["targets"] = ["grok-build"]
+ dump_yaml(manifest, manifest_path)
+ (scenario.isolated.config_root / "config.json").unlink()
+ if cold_cache:
+ shutil.rmtree(scenario.project / "apm_modules")
+ if failed_replay:
+ lock_path = scenario.project / "apm.lock.yaml"
+ document = load_yaml(lock_path)
+ document["dependencies"][0]["deployed_files"].append(
+ "copilot-app-db://workflows/audit-startup-fixture"
+ )
+ dump_yaml(document, lock_path)
+
+ environment = scenario.isolated.subprocess_env()
+ environment.pop("PYTEST_CURRENT_TEST", None)
+ environment.pop("APM_E2E_TESTS", None)
+ before_project = ArtifactSnapshot.capture(scenario.project)
+ before_home = ArtifactSnapshot.capture(scenario.isolated.home)
+ result = scenario.runner.run(
+ _AUDIT_ARGS,
+ scenario_id="audit-without-test-mode",
+ cwd=scenario.project,
+ env=environment,
+ )
+ assert result.returncode == int(failed_replay), result.stdout + result.stderr
+ checks = {row["name"]: row for row in json.loads(result.stdout)["checks"]}
+ assert checks["content-integrity"]["passed"] is True
+ if failed_replay:
+ assert checks["drift"]["passed"] is False
+ assert "no isolated filesystem scratch replay backend" in checks["drift"]["message"]
+ else:
+ assert all(check["passed"] for check in checks.values())
+ assert_unchanged(before_project, ArtifactSnapshot.capture(scenario.project))
+ assert_unchanged(before_home, ArtifactSnapshot.capture(scenario.isolated.home))
diff --git a/tests/integration/test_audit_unrecorded_unicode_lifecycle.py b/tests/integration/test_audit_unrecorded_unicode_lifecycle.py
index 9ed45c1246..6dbd83d7b8 100644
--- a/tests/integration/test_audit_unrecorded_unicode_lifecycle.py
+++ b/tests/integration/test_audit_unrecorded_unicode_lifecycle.py
@@ -262,12 +262,23 @@ def test_symlinked_deploy_root_never_scans_outside_project(
audit = _run(
lifecycle,
- _CI_AUDIT,
+ (*_CI_AUDIT, "--format", "json"),
expected=1,
scenario_id="symlinked-deploy-root-contained",
)
assert _UNRECORDED not in audit.stdout
- assert audit.stderr == "Error: Refusing deployment through symlinked target root: .claude\n"
+ report = json.loads(audit.stdout)
+ failures = [check for check in report["checks"] if not check["passed"]]
+ assert report["passed"] is False
+ assert failures == [
+ {
+ "name": "target-resolution",
+ "passed": False,
+ "message": "Refusing deployment through symlinked target root: .claude",
+ "details": [],
+ }
+ ]
+ assert audit.stderr == ""
assert payload.read_bytes() == _BIDI_BYTES
diff --git a/tests/integration/test_deps_resolver_resolution.py b/tests/integration/test_deps_resolver_resolution.py
index b8b158c59a..39f39751f9 100644
--- a/tests/integration/test_deps_resolver_resolution.py
+++ b/tests/integration/test_deps_resolver_resolution.py
@@ -549,7 +549,12 @@ def test_local_prefix_is_not_remote(self) -> None:
from apm_cli.deps.apm_resolver import APMDependencyResolver
from apm_cli.models.apm_package import APMPackage
- pkg = APMPackage(name="local-pkg", version="1.0.0", source="_local/local-pkg")
+ pkg = APMPackage(
+ name="local-pkg",
+ version="1.0.0",
+ source="_local/local-pkg",
+ proven_source_kind="local",
+ )
assert APMDependencyResolver._is_remote_parent(pkg) is False
def test_https_url_source_is_remote(self) -> None:
@@ -577,7 +582,9 @@ def test_dot_slash_source_is_not_remote(self) -> None:
from apm_cli.deps.apm_resolver import APMDependencyResolver
from apm_cli.models.apm_package import APMPackage
- pkg = APMPackage(name="local", version="1.0.0", source="./path/to/pkg")
+ pkg = APMPackage(
+ name="local", version="1.0.0", source="./path/to/pkg", proven_source_kind="local"
+ )
assert APMDependencyResolver._is_remote_parent(pkg) is False
diff --git a/tests/integration/test_global_local_transitive_dependency_e2e.py b/tests/integration/test_global_local_transitive_dependency_e2e.py
new file mode 100644
index 0000000000..bfbbe9677a
--- /dev/null
+++ b/tests/integration/test_global_local_transitive_dependency_e2e.py
@@ -0,0 +1,135 @@
+"""Installed-CLI regressions for local declaring-parent anchors at user scope."""
+
+from __future__ import annotations
+
+import os
+import shutil
+from pathlib import Path
+
+import pytest
+
+from apm_cli.utils.yaml_io import dump_yaml, load_yaml
+from tests.utils.apm_lifecycle_runner import ApmLifecycleRunner
+from tests.utils.isolated_apm_environment import IsolatedApmEnvironment
+from tests.utils.local_package import LocalPackageFactory
+
+pytestmark = [
+ pytest.mark.e2e,
+ pytest.mark.requires_apm_binary,
+ pytest.mark.lifecycle_smoke,
+ pytest.mark.lifecycle_merge_group,
+]
+
+
+@pytest.mark.parametrize(
+ ("user_scope", "relative_child"),
+ [(False, True), (True, False), (True, True)],
+ ids=["project-relative-control", "user-absolute-control", "user-relative-regression"],
+)
+def test_local_transitive_scope_parity(
+ tmp_path: Path,
+ apm_binary_path: Path,
+ user_scope: bool,
+ relative_child: bool,
+) -> None:
+ """Success must include both declared packages, regardless of deploy scope."""
+ environment = IsolatedApmEnvironment.create(tmp_path / "scenario", base_env=os.environ)
+ factory = LocalPackageFactory(environment.package_root)
+ child = factory.create("child", targets=["cursor"])
+ child_reference = "../child" if relative_child else child.root.as_posix()
+ parent = factory.create(
+ "parent",
+ dependencies=[{"path": child_reference}],
+ targets=["cursor"],
+ )
+ sources = {
+ package.name: factory.add_command(
+ package,
+ package.name,
+ f"---\ndescription: {package.name} command\n---\n# Command\n"
+ f"Distinct {package.name} command body.\n",
+ )
+ for package in (parent, child)
+ }
+ assert (parent.root / "../child").resolve() == child.root
+ assert child.manifest_path.is_file()
+ consumer = environment.work_root / "consumer"
+ consumer.mkdir()
+ manifest_root = environment.config_root if user_scope else consumer
+ manifest = {
+ "name": "consumer",
+ "version": "0.1.0",
+ "targets": ["cursor"],
+ "dependencies": {"apm": [{"path": parent.root.as_posix()}]},
+ }
+ dump_yaml(manifest, manifest_root / "apm.yml")
+ deploy_root = environment.home if user_scope else consumer
+ runner = ApmLifecycleRunner([str(apm_binary_path)])
+ args = ("install", "--target", "cursor", "--no-policy", "--parallel-downloads", "0")
+ if user_scope:
+ args += ("--global",)
+
+ lock_bytes = None
+ for iteration in range(3):
+ if iteration == 2:
+ # Keep the written lock and source roots; force actual rematerialization.
+ modules = manifest_root / "apm_modules"
+ assert modules.is_dir() and not modules.is_symlink()
+ assert modules.is_relative_to(environment.root)
+ shutil.rmtree(modules)
+ result = runner.run(
+ args,
+ scenario_id=f"local-transitive-scope-parity-{iteration}",
+ cwd=consumer,
+ env=environment.subprocess_env(),
+ )
+ evidence = f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
+ assert result.returncode == 0, evidence
+ lockfile = load_yaml(manifest_root / "apm.lock.yaml")
+ current_lock_bytes = (manifest_root / "apm.lock.yaml").read_bytes()
+ if lock_bytes is not None:
+ assert current_lock_bytes == lock_bytes
+ lock_bytes = current_lock_bytes
+ entries = lockfile["dependencies"]
+ assert len(entries) == 2, evidence
+ by_repo = {entry["repo_url"]: entry for entry in entries}
+ assert set(by_repo) == {"_local/parent", "_local/child"}, evidence
+ locked_child = by_repo["_local/child"]
+ assert locked_child["local_path"] == child_reference
+ assert locked_child["source"] == "local"
+ assert locked_child["depth"] == 2
+ assert locked_child["resolved_by"] == "_local/parent"
+ assert locked_child["declaring_parent"] == parent.root.as_posix()
+ assert locked_child["anchored_local_path"] == child.root.as_posix()
+ assert load_yaml(manifest_root / "apm.yml") == manifest
+
+ commands = list((deploy_root / ".cursor" / "commands").glob("*.md"))
+ assert len(commands) == 2, evidence
+ command_bodies = [path.read_text(encoding="utf-8") for path in commands]
+ for name, source in sources.items():
+ assert sum(f"Distinct {name} command body." in body for body in command_bodies) == 1
+ copies = list((manifest_root / "apm_modules").rglob(f"{name}.prompt.md"))
+ assert len(copies) == 1
+ assert copies[0].read_bytes() == source.read_bytes()
+ if user_scope:
+ assert not (consumer / ".cursor").exists()
+ assert not (consumer / "apm_modules").exists()
+ assert not (consumer / "apm.lock.yaml").exists()
+
+
+def test_direct_user_relative_input_remains_rejected(tmp_path: Path, apm_binary_path: Path) -> None:
+ """A real local directory under CWD does not make a direct global ref valid."""
+ environment = IsolatedApmEnvironment.create(tmp_path / "scenario", base_env=os.environ)
+ factory = LocalPackageFactory(environment.work_root)
+ factory.create("direct", targets=["cursor"])
+ result = ApmLifecycleRunner([str(apm_binary_path)]).run(
+ ("install", "./direct", "--global", "--target", "cursor", "--no-policy"),
+ cwd=environment.work_root,
+ env=environment.subprocess_env(),
+ )
+ assert result.returncode != 0
+ output = result.stdout + result.stderr
+ assert "relative local paths" in output
+ assert "absolute path" in output
+ assert not (environment.config_root / "apm.lock.yaml").exists()
+ assert not (environment.home / ".cursor" / "commands").exists()
diff --git a/tests/integration/test_version_notification.py b/tests/integration/test_version_notification.py
index 81b4dfc2c2..14a365374a 100644
--- a/tests/integration/test_version_notification.py
+++ b/tests/integration/test_version_notification.py
@@ -2,11 +2,14 @@
import os # noqa: F401
import unittest
-from unittest.mock import patch
+from unittest.mock import MagicMock, patch
import click
+import pytest
from click.testing import CliRunner
+pytestmark = pytest.mark.component
+
class TestVersionNotificationIntegration(unittest.TestCase):
"""Test version check notification in CLI commands."""
@@ -116,6 +119,17 @@ def test_callback_skips_update_check_without_subcommand(self, mock_check):
mock_check.assert_not_called()
+ @patch("apm_cli.cli._check_and_notify_updates")
+ def test_audit_callback_skips_startup_update_check(self, mock_check: MagicMock) -> None:
+ """Read-only audit must not invoke update-cache or configuration writes."""
+ from apm_cli.cli import cli
+
+ ctx = click.Context(cli, info_name="apm")
+ ctx.invoked_subcommand = "audit"
+ with ctx:
+ cli.callback(verbose=False)
+ mock_check.assert_not_called()
+
class TestUpdateCommand(unittest.TestCase):
"""Test the update command."""
diff --git a/tests/spec_conformance/__init__.py b/tests/spec_conformance/__init__.py
index 84f2137bfc..72e0089fa7 100644
--- a/tests/spec_conformance/__init__.py
+++ b/tests/spec_conformance/__init__.py
@@ -1,6 +1,6 @@
"""Conformance test package marker.
-Tests under this package bind to OpenAPM v0.1 normative requirements
+Tests under this package bind to the one selected OpenAPM revision's requirements
via `@pytest.mark.req("req-XXX")`. The orphan_check gate and
gen_statement reader live as siblings.
"""
diff --git a/tests/spec_conformance/_helpers.py b/tests/spec_conformance/_helpers.py
index 9dd54b8cb5..01c9762ff0 100644
--- a/tests/spec_conformance/_helpers.py
+++ b/tests/spec_conformance/_helpers.py
@@ -10,7 +10,7 @@
matching test breaks at PR time.
There is also `waive(...)`. Use it ONLY when the requirement is
-genuinely beyond v0.1 active testability and the rationale is
+genuinely beyond the selected revision's active testability and the rationale is
written down. Every waiver appears in CONFORMANCE.md as debt.
"""
@@ -25,7 +25,7 @@
import yaml
from jsonschema import Draft202012Validator
-from tests.spec_conformance._manifest import FIXTURE_ROOT, SPEC_DIR, SPEC_PATH
+from tests.spec_conformance._manifest import FIXTURE_ROOT, SPEC_DIR, selected_assessment
def waive(reason: str) -> None:
@@ -67,7 +67,7 @@ def fixture_path(*parts: str) -> Path:
def spec_text() -> str:
global _SPEC_TEXT_CACHE
if _SPEC_TEXT_CACHE is None:
- _SPEC_TEXT_CACHE = SPEC_PATH.read_text(encoding="utf-8")
+ _SPEC_TEXT_CACHE = selected_assessment().spec_path.read_text(encoding="utf-8")
return _SPEC_TEXT_CACHE
diff --git a/tests/spec_conformance/_manifest.py b/tests/spec_conformance/_manifest.py
index 31eed3310f..ac044d45d2 100644
--- a/tests/spec_conformance/_manifest.py
+++ b/tests/spec_conformance/_manifest.py
@@ -6,9 +6,15 @@
from __future__ import annotations
+import hashlib
import json
+import os
+import re
+import subprocess
+import sys
from dataclasses import dataclass
from pathlib import Path
+from tempfile import TemporaryDirectory
from typing import Any
import yaml
@@ -16,20 +22,49 @@
REPO_ROOT = Path(__file__).resolve().parents[2]
SPEC_DIR = REPO_ROOT / "docs" / "src" / "content" / "docs" / "specs"
-SPEC_PATH = SPEC_DIR / "openapm-v0.1.md"
+_SELECTED_MINOR = "v0.2"
+SPEC_PATH = SPEC_DIR / f"openapm-{_SELECTED_MINOR}.md"
# Schemas and the requirements manifest are served as static assets so
# the schema $id URLs resolve on the published site. They live under
# docs/public/specs/, not under the Starlight content collection.
PUBLIC_SPEC_DIR = REPO_ROOT / "docs" / "public" / "specs"
-MANIFEST_PATH = PUBLIC_SPEC_DIR / "manifests" / "openapm-v0.1.requirements.yml"
+MANIFEST_PATH = PUBLIC_SPEC_DIR / "manifests" / f"openapm-{_SELECTED_MINOR}.requirements.yml"
SCHEMA_PATH = PUBLIC_SPEC_DIR / "schemas" / "requirements-v0.1.schema.json"
FIXTURE_ROOT = REPO_ROOT / "tests" / "fixtures" / "spec-conformance"
-COVERAGE_PATH = REPO_ROOT / "build" / "conformance-coverage.json"
+COVERAGE_ENV = "APM_CONFORMANCE_COVERAGE_PATH"
+Coverage = dict[str, list[dict[str, str]]]
ALLOWED_KEYWORDS = ("MUST", "MUST NOT", "SHOULD", "SHOULD NOT", "MAY")
ALLOWED_CLASSES = ("producer", "consumer", "registry", "governance")
+@dataclass(frozen=True)
+class Assessment:
+ """The one active exact revision, with its validated input fingerprints."""
+
+ version: str
+ spec_path: Path
+ manifest_path: Path
+ spec_sha256: str
+ manifest_sha256: str
+
+ @property
+ def citation(self) -> str:
+ return f"https://microsoft.github.io/apm/spec/{self.version}"
+
+ @property
+ def coverage_path(self) -> Path:
+ return REPO_ROOT / "build" / f"conformance-coverage-{self.version}.json"
+
+ def stamp(self) -> dict[str, str]:
+ return {
+ "spec_version": self.version,
+ "spec_sha256": self.spec_sha256,
+ "manifest_sha256": self.manifest_sha256,
+ "inventory_kind": "static-test-bindings",
+ }
+
+
@dataclass(frozen=True)
class Requirement:
id: str
@@ -54,7 +89,109 @@ def load_manifest_raw() -> dict[str, Any]:
return yaml.safe_load(f)
+def selected_assessment() -> Assessment:
+ """Validate metadata against the artifact, without a second version authority."""
+ raw = load_manifest_raw()
+ Draft202012Validator(load_schema()).validate(raw)
+ version = raw["spec_version"]
+ if not re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+", version):
+ raise ValueError("Selected assessment requires an exact specification revision")
+ if version.rsplit(".", 1)[0] != _SELECTED_MINOR:
+ raise ValueError("Selected manifest revision disagrees with its minor artifact")
+ text = SPEC_PATH.read_text(encoding="utf-8")
+ parts = text.split("---\n", 2)
+ if len(parts) != 3 or parts[0]:
+ raise ValueError("Selected specification has no frontmatter identity")
+ metadata = yaml.safe_load(parts[1])
+ if not isinstance(metadata, dict) or metadata.get("title") != f"OpenAPM {version}":
+ raise ValueError("Selected specification identity disagrees with manifest metadata")
+ if metadata.get("slug") != f"specs/openapm-{version.replace('.', '')}":
+ raise ValueError("Selected specification needs an exact-revision content route")
+ return Assessment(
+ version=version,
+ spec_path=SPEC_PATH,
+ manifest_path=MANIFEST_PATH,
+ spec_sha256=hashlib.sha256(SPEC_PATH.read_bytes()).hexdigest(),
+ manifest_sha256=hashlib.sha256(MANIFEST_PATH.read_bytes()).hexdigest(),
+ )
+
+
+def coverage_document(coverage: Coverage) -> dict[str, Any]:
+ """Stamp static bindings, not runtime outcomes, with the selected inputs."""
+ return {**selected_assessment().stamp(), "requirements": coverage}
+
+
+def coverage_output_path() -> Path:
+ """Honor the fresh collector's isolated output, or use a version-qualified map."""
+ requested = os.environ.get(COVERAGE_ENV)
+ return Path(requested) if requested else selected_assessment().coverage_path
+
+
+def load_coverage(path: Path) -> Coverage:
+ """Reject stale, unversioned, mismatched, or malformed binding inventories."""
+ with path.open(encoding="utf-8") as handle:
+ document = json.load(handle)
+ if not isinstance(document, dict) or any(
+ document.get(key) != value for key, value in selected_assessment().stamp().items()
+ ):
+ raise ValueError("Coverage identity or input fingerprints do not match the assessment")
+ coverage = document.get("requirements")
+ if not isinstance(coverage, dict) or any(
+ not isinstance(req_id, str)
+ or not isinstance(rows, list)
+ or any(
+ not isinstance(row, dict)
+ or not isinstance(row.get("test_nodeid"), str)
+ or row.get("status") not in {"active", "skipped", "xfail"}
+ for row in rows
+ )
+ for req_id, rows in coverage.items()
+ ):
+ raise ValueError("Malformed conformance binding inventory")
+ return coverage
+
+
+def collect_coverage() -> Coverage:
+ """Always collect the full active suite; never reuse a previous map on failure."""
+ assessment = selected_assessment()
+ assessment.coverage_path.parent.mkdir(parents=True, exist_ok=True)
+ with TemporaryDirectory(
+ prefix="conformance-", dir=assessment.coverage_path.parent
+ ) as temporary:
+ output = Path(temporary) / assessment.coverage_path.name
+ env = {**os.environ, COVERAGE_ENV: str(output)}
+ env.pop("PYTEST_ADDOPTS", None)
+ result = subprocess.run(
+ [
+ sys.executable,
+ "-m",
+ "pytest",
+ "tests/spec_conformance",
+ "--collect-only",
+ "-q",
+ "-o",
+ "addopts=",
+ "-p",
+ "no:randomly",
+ "--no-header",
+ ],
+ cwd=REPO_ROOT,
+ env=env,
+ capture_output=True,
+ text=True,
+ )
+ if result.returncode != 0:
+ raise RuntimeError(
+ f"Full spec collection failed ({result.returncode}):\n"
+ f"{result.stdout}\n{result.stderr}"
+ )
+ if not output.is_file():
+ raise RuntimeError("Full spec collection produced no binding inventory")
+ return load_coverage(output)
+
+
def load_requirements() -> list[Requirement]:
+ selected_assessment()
schema = load_schema()
raw = load_manifest_raw()
Draft202012Validator(schema).validate(raw)
@@ -77,3 +214,9 @@ def load_requirements() -> list[Requirement]:
def requirements_by_id() -> dict[str, Requirement]:
return {r.id: r for r in load_requirements()}
+
+
+if __name__ == "__main__":
+ selection = selected_assessment()
+ print(selection.spec_path.relative_to(REPO_ROOT).as_posix())
+ print(selection.manifest_path.relative_to(REPO_ROOT).as_posix())
diff --git a/tests/spec_conformance/conftest.py b/tests/spec_conformance/conftest.py
index 317355e532..406a4f1588 100644
--- a/tests/spec_conformance/conftest.py
+++ b/tests/spec_conformance/conftest.py
@@ -2,8 +2,8 @@
Registers and enforces the `req` marker. Every marker MUST resolve to
an id in the requirements manifest; unknown ids fail collection. The
-marker coverage map is written to build/conformance-coverage.json for
-gen_statement.py consumption.
+static binding map is version-qualified and stamped by the shared
+assessment owner. Statement generation always requests fresh full collection.
"""
from __future__ import annotations
@@ -17,8 +17,9 @@
import pytest
from tests.spec_conformance._manifest import (
- COVERAGE_PATH,
REPO_ROOT,
+ coverage_document,
+ coverage_output_path,
requirements_by_id,
)
@@ -100,12 +101,13 @@ def pytest_collection_modifyitems(config, items: list[pytest.Item]) -> None:
if errors:
joined = "\n".join(f" - {e}" for e in errors)
raise pytest.UsageError("Spec-conformance marker validation failed:\n" + joined)
- COVERAGE_PATH.parent.mkdir(parents=True, exist_ok=True)
+ output = coverage_output_path()
+ output.parent.mkdir(parents=True, exist_ok=True)
canonical = {
rid: sorted(rows, key=lambda r: r["test_nodeid"]) for rid, rows in sorted(coverage.items())
}
- with COVERAGE_PATH.open("w", encoding="ascii", newline="\n") as f:
- json.dump(canonical, f, indent=2, sort_keys=True)
+ with output.open("w", encoding="ascii", newline="\n") as f:
+ json.dump(coverage_document(canonical), f, indent=2, sort_keys=True)
f.write("\n")
diff --git a/tests/spec_conformance/gen_statement.py b/tests/spec_conformance/gen_statement.py
index cdacb34b73..07094646a3 100644
--- a/tests/spec_conformance/gen_statement.py
+++ b/tests/spec_conformance/gen_statement.py
@@ -1,8 +1,8 @@
-"""Generate the OpenAPM v0.1 conformance statement.
+"""Generate the selected OpenAPM revision's static binding inventory.
Reads:
- - build/conformance-coverage.json (written by conftest at collection)
- - docs/.../openapm-v0.1.requirements.yml (manifest)
+ - a fresh, version-stamped full-suite collection
+ - the selected requirements manifest
- test source files (for waiver/assertion extraction via ast)
Writes:
@@ -17,23 +17,23 @@
import ast
import json
-import subprocess
import sys
from collections import defaultdict
from tests.spec_conformance._manifest import (
ALLOWED_CLASSES,
- COVERAGE_PATH,
REPO_ROOT,
- SPEC_PATH,
+ Coverage,
+ collect_coverage,
load_requirements,
+ selected_assessment,
)
+from tests.spec_conformance.orphan_check import check_bindings
CONFORMANCE_JSON = REPO_ROOT / "CONFORMANCE.json"
CONFORMANCE_MD = REPO_ROOT / "CONFORMANCE.md"
-SPEC_VERSION = "v0.1.1"
-GENERATOR = "gen_statement.py v1"
+GENERATOR = "gen_statement.py v2"
USER_SCOPE_DISCLOSURE = {
"manifest_location": "~/.apm/apm.yml",
"lockfile_location": "~/.apm/apm.lock.yaml",
@@ -41,31 +41,40 @@
"MCPClientAdapter.supports_user_scope (OpenAPM Target Registry v0.1 implementation profile)"
),
}
+ASSESSMENT_LIMITATIONS = [
+ "The reference CLI's bare content audit uses source-derived drift replay, not the "
+ "stored-hash baseline required by req-lk-017's unqualified audit obligation. "
+ "The stored-hash and full-SHA consistency baselines are exercised in CI/conformance "
+ "audit. This inventory does not claim full Consumer conformance in bare audit mode.",
+ "The native Cowork audit controls use a controlled pre-existing standalone-skill "
+ "snapshot; they do not establish a successful Cowork install/audit round trip. "
+ "The Grok user-scope control does exercise install and audit.",
+ "A selected native runtime without an isolated replay backend is reported as "
+ "unsupported before its live writer. No new native database scratch backend "
+ "or hosted-runtime evidence is supplied by this inventory.",
+ "A source-only coupled probe shows that local acquisition dereferences an admitted "
+ "internal resource symlink, but inherited replay plans from the original source "
+ "representation and can falsely report the deployed regular file as orphaned "
+ "during unchanged CI audit. The narrowly expected-failing regression separately "
+ "verifies content integrity and unchanged live state; an escaping-link refusal "
+ "control remains unsuppressed. This is a replay limitation, not evidence of an "
+ "escape, external-file read or security bypass.",
+ "The retained manifest schema rejects git entries with a path modifier and id "
+ "entries with an explicit registry modifier. It also accepts malformed or "
+ "wrong-length policy.hash strings structurally. Schema acceptance is not evidence "
+ "of Consumer digest-envelope enforcement under req-mf-018 or req-lk-016.",
+ "Inherited Git-tree boundaries for symlink blobs, gitlinks/submodules and "
+ "CRLF/LFS-filtered checkout bytes lack cross-platform execution evidence in this "
+ "assessment. The req-lk-015 obligation and digest construction remain unchanged.",
+]
-def _ensure_coverage() -> dict[str, list[dict[str, str]]]:
- if not COVERAGE_PATH.exists():
- res = subprocess.run(
- [
- sys.executable,
- "-m",
- "pytest",
- "tests/spec_conformance",
- "--collect-only",
- "-q",
- "-p",
- "no:randomly",
- "--no-header",
- ],
- cwd=REPO_ROOT,
- capture_output=True,
- text=True,
- )
- if not COVERAGE_PATH.exists():
- sys.stderr.write(res.stderr)
- raise SystemExit(2)
- with COVERAGE_PATH.open(encoding="utf-8") as f:
- return json.load(f)
+def _ensure_coverage() -> Coverage:
+ """Require fresh collection and exact four-way binding before rendering."""
+ coverage = collect_coverage()
+ if check_bindings(coverage):
+ raise ValueError("gen_statement refuses to write while the four-way bind fails")
+ return coverage
def _extract_waivers() -> dict[str, list[str]]:
@@ -121,6 +130,7 @@ def _aggregate_status(rows: list[dict[str, str]]) -> str:
def build_json() -> dict:
coverage = _ensure_coverage()
+ assessment = selected_assessment()
waivers = _extract_waivers()
reqs = load_requirements()
entries = []
@@ -150,11 +160,17 @@ def build_json() -> dict:
for c in ALLOWED_CLASSES
}
return {
- "spec_version": SPEC_VERSION,
+ **assessment.stamp(),
+ "spec_path": assessment.spec_path.relative_to(REPO_ROOT).as_posix(),
+ "spec_citation": assessment.citation,
"generator": GENERATOR,
+ "assessment_status": "DRAFT",
+ "human_ratification": "UNSATISFIED",
+ "activation": "UNSATISFIED",
"total_requirements": len(entries),
"summary_by_class": summary,
"consumer_user_scope": USER_SCOPE_DISCLOSURE,
+ "assessment_limitations": ASSESSMENT_LIMITATIONS,
"requirements": entries,
}
@@ -173,24 +189,39 @@ def _md_class_summary(summary: dict) -> str:
def build_md(doc: dict) -> str:
preamble = (
- f"# OpenAPM Conformance Statement -- {SPEC_VERSION}\n\n"
+ f"# OpenAPM Conformance Binding Inventory -- {doc['spec_version']} (DRAFT)\n\n"
f"Generator: {GENERATOR}.\n"
- "Spec: [docs/src/content/docs/specs/openapm-v0.1.md]"
- "(docs/src/content/docs/specs/openapm-v0.1.md)\n\n"
+ f"Spec: [{doc['spec_path']}]({doc['spec_path']})\n"
+ f"Exact revision citation: {doc['spec_citation']}\n\n"
"This file is generated. Do NOT edit by hand. Run\n"
"`uv run python -m tests.spec_conformance.gen_statement` to regenerate.\n\n"
"## Honesty contract\n\n"
"There is NO automated CI detector for spec-vs-behaviour drift "
"beyond the four sets enforced by `orphan_check.py`: spec anchors, "
"manifest entries, Appendix C rows, and `@pytest.mark.req` markers. "
- "A requirement marked `status=active` is exercised by at least one "
- "assertion. A requirement marked `status=skipped` carries a written "
- "waiver below; this is debt, not coverage. A requirement with "
- "`status=xfail` is asserted-but-known-broken.\n\n"
+ "Statuses are a static binding inventory from fresh full-suite collection, "
+ "not executed test results or a runtime pass certificate. `status=active` "
+ "means a collected binding is not statically marked skipped or xfail; "
+ "it does not prove that an assertion ran or passed. `status=skipped` "
+ "and `status=xfail` describe static markers or waiver calls, not measured "
+ "execution outcomes. Waivers are listed below as debt. Separate test "
+ "execution and implementation evidence remain necessary.\n\n"
+ "This inventory assesses only the selected DRAFT corrective revision. It does "
+ "not establish historical CLI conformance to the previous minor's "
+ "req-mf-016 blanket project-root refusal. Human ratification and activation "
+ "are UNSATISFIED. A prepared specification and collected bindings do not "
+ "establish publication or ratification.\n\n"
+ "Two qualified nonauthor human approvals (one with implementation experience "
+ "and one with consumer/integrator experience), the process-issue label, "
+ "and explicit human ratification/publication remain required. No human "
+ "approval is recorded by this inventory. Only the public-comment requirement "
+ "was waived by the [recorded decision]"
+ "(https://github.com/microsoft/apm/issues/2818#issuecomment-5558647529). "
+ "Automated reviews and passing spec-conformance checks do not ratify.\n\n"
"## Conformance classes\n\n"
- "All four conformance classes (Producer, Consumer, Registry, "
- "Governance) carry active coverage in this statement. The "
- "Registry class is exercised via the trust-anchor invariant "
+ "The four conformance classes (Producer, Consumer, Registry, "
+ "Governance) are inventoried below, not certified by this report. The "
+ "Registry binding includes the trust-anchor invariant "
"test in `tests/spec_conformance/test_registry_reqs.py`, "
"which hashes the committed Registry-archive fixture and "
"asserts equality with the digest the paired lockfile "
@@ -204,9 +235,7 @@ def build_md(doc: dict) -> str:
"case-sensitive. Policy matching and repository identity use the same "
"rule (req-rs-016 clause 3; req-pl-018).\n\n"
)
- summary_section = (
- "## Coverage summary\n\n" + _md_class_summary(doc["summary_by_class"]) + "\n\n"
- )
+ summary_section = "## Binding summary\n\n" + _md_class_summary(doc["summary_by_class"]) + "\n\n"
user_scope = doc["consumer_user_scope"]
scope_section = (
"## Consumer user-scope disclosure\n\n"
@@ -216,13 +245,13 @@ def build_md(doc: dict) -> str:
f"`{user_scope['target_capability_declaration']}`\n\n"
)
rows = [
- "## Per-requirement coverage\n",
+ "## Per-requirement bindings\n",
"| Req ID | Keyword | Sec | Class | Status | Tests | Oracle |",
"|--------|---------|----:|-------|--------|------:|--------|",
]
for e in doc["requirements"]:
rows.append(
- f"| [{e['id']}](docs/src/content/docs/specs/openapm-v0.1.md#{e['id']}) "
+ f"| [{e['id']}]({doc['spec_path']}#{e['id']}) "
f"| {e['keyword']} | {e['section']} | {e['conformance_class']} "
f"| {e['status']} | {e['test_count']} | {e.get('oracle', '-')} |"
)
@@ -235,7 +264,10 @@ def build_md(doc: dict) -> str:
waivers_section.append(f"- {w}")
waivers_section.append("")
waivers_md = "\n".join(waivers_section) + "\n"
- return preamble + scope_section + summary_section + table + waivers_md
+ limitations = (
+ "## Assessment limitations\n\n" + "\n\n".join(doc["assessment_limitations"]) + "\n\n"
+ )
+ return preamble + limitations + scope_section + summary_section + table + waivers_md
def _is_ascii(text: str) -> bool:
@@ -256,26 +288,11 @@ def write_outputs() -> None:
def main() -> int:
- # F11 honesty: refuse to generate if spec anchors disagree with the
- # manifest. The orphan_check is the canonical gate, so we run it.
- res = subprocess.run(
- [sys.executable, "-m", "tests.spec_conformance.orphan_check"],
- cwd=REPO_ROOT,
- capture_output=True,
- text=True,
- )
- if res.returncode != 0:
- sys.stderr.write(res.stderr)
- sys.stderr.write(
- "\n[x] gen_statement refuses to write while orphan_check fails. "
- "Fix the 4-way bind first.\n"
- )
- return 1
- sanity = SPEC_PATH.read_text(encoding="utf-8").count(' set[str]:
def set_markers() -> set[str]:
- """Run pytest in collect-only mode and harvest the coverage map.
-
- We invoke pytest in a sub-process to honour the spec_conformance
- conftest's marker-validation step. The coverage map is written by
- pytest_collection_modifyitems.
- """
- if COVERAGE_PATH.exists():
- COVERAGE_PATH.unlink()
- cmd = [
- sys.executable,
- "-m",
- "pytest",
- "tests/spec_conformance",
- "--collect-only",
- "-q",
- "-p",
- "no:randomly",
- "--no-header",
- ]
- res = subprocess.run(cmd, cwd=REPO_ROOT, capture_output=True, text=True)
- if res.returncode != 0 and not COVERAGE_PATH.exists():
- sys.stderr.write(
- "orphan_check: pytest --collect-only failed before producing "
- "coverage map. pytest stderr below:\n"
- )
- sys.stderr.write(res.stderr)
- sys.exit(2)
- if not COVERAGE_PATH.exists():
- return set()
- with COVERAGE_PATH.open(encoding="utf-8") as f:
- data = json.load(f)
- return set(data.keys())
+ """Collect the complete active assessment through the shared owner."""
+ return set(collect_coverage())
def diff_report(label_a: str, set_a: set[str], label_b: str, set_b: set[str]) -> list[str]:
@@ -135,11 +104,12 @@ def check_appendix_c_consistency() -> list[str]:
return problems
-def main() -> int:
+def check_bindings(coverage: Coverage) -> int:
+ """Compare all four projections using an already freshly collected inventory."""
anchors = set_anchors()
manifest = set_manifest()
appc = set_appc()
- markers = set_markers()
+ markers = set(coverage)
failures: list[str] = []
if anchors != manifest:
failures.append("[x] anchors != manifest")
@@ -170,5 +140,13 @@ def main() -> int:
return 0
+def main() -> int:
+ try:
+ return check_bindings(collect_coverage())
+ except (ValueError, RuntimeError, OSError) as error:
+ sys.stderr.write(f"[x] orphan_check: {error}\n")
+ return 2
+
+
if __name__ == "__main__":
raise SystemExit(main())
diff --git a/tests/spec_conformance/test_audit_current_intent_contract.py b/tests/spec_conformance/test_audit_current_intent_contract.py
new file mode 100644
index 0000000000..662063e3ce
--- /dev/null
+++ b/tests/spec_conformance/test_audit_current_intent_contract.py
@@ -0,0 +1,536 @@
+"""Current-intent and read-only CLI controls bound to the corrective assessment."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+import shutil
+import sqlite3
+from collections.abc import Iterator
+from contextlib import closing
+from dataclasses import dataclass, replace
+from pathlib import Path
+from unittest.mock import Mock
+
+import pytest
+import requests
+from click.testing import CliRunner, Result
+
+from apm_cli import config
+from apm_cli.cli import cli
+from apm_cli.install.audit_target_roots import resolve_audit_targets
+from apm_cli.integration.targets import resolve_targets
+from apm_cli.utils import console
+from apm_cli.utils.yaml_io import dump_yaml, load_yaml
+from tests.utils.artifact_snapshot import ArtifactSnapshot, assert_unchanged
+from tests.utils.isolated_apm_environment import IsolatedApmEnvironment
+from tests.utils.local_package import LocalPackageFactory
+
+pytestmark = pytest.mark.component
+
+_DEPLOYED_SKILL = ".grok/skills/intent/SKILL.md"
+
+
+@dataclass(frozen=True)
+class _AuditProject:
+ project: Path
+ home: Path
+ source: Path
+
+ def command(self, *arguments: str) -> Result:
+ """Invoke the real command stack in process, with no binary prerequisite."""
+ return CliRunner().invoke(cli, list(arguments), catch_exceptions=False)
+
+ def audit_result(self, expected_exit: int) -> Result:
+ """Keep all live manifest, lockfile, configuration and target bytes intact."""
+ project_before = ArtifactSnapshot.capture(self.project)
+ home_before = ArtifactSnapshot.capture(self.home)
+ result = self.command("audit", "--ci", "--no-policy", "--no-fail-fast", "-f", "json")
+ assert result.exit_code == expected_exit, result.output
+ assert_unchanged(project_before, ArtifactSnapshot.capture(self.project))
+ assert_unchanged(home_before, ArtifactSnapshot.capture(self.home))
+ return result
+
+ def audit(self, expected_exit: int) -> dict:
+ """Return structured checks from a completed audit."""
+ result = self.audit_result(expected_exit)
+ return {row["name"]: row for row in json.loads(result.stdout)["checks"]}
+
+
+@pytest.fixture
+def installed(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[_AuditProject]:
+ """Materialize source, saved intent and ownership through actual commands."""
+ isolated = IsolatedApmEnvironment.create(tmp_path / "audit", base_env=dict(os.environ))
+ environment = isolated.subprocess_env()
+ for key in os.environ.keys() - environment.keys():
+ monkeypatch.delenv(key)
+ for key, value in environment.items():
+ monkeypatch.setenv(key, value)
+ monkeypatch.setattr(config, "CONFIG_DIR", str(isolated.config_root))
+ monkeypatch.setattr(config, "CONFIG_FILE", str(isolated.config_root / "config.json"))
+ monkeypatch.setattr(config, "_config_cache", None)
+ monkeypatch.setattr(console, "_console_instance", None)
+ monkeypatch.setattr(console, "_console_stderr", False)
+
+ def no_network(*args: object, **kwargs: object) -> None:
+ raise AssertionError("Conformance audit must not issue HTTP requests")
+
+ monkeypatch.setattr(requests.Session, "request", no_network)
+ factory = LocalPackageFactory(isolated.package_root)
+ package = factory.create("source")
+ source = factory.add_skill(
+ package, "intent", "---\nname: intent\ndescription: Source intent\n---\n# Expected bytes\n"
+ )
+ project = (
+ LocalPackageFactory(isolated.work_root)
+ .create("consumer", dependencies=[{"path": str(package.root)}])
+ .root
+ )
+ sentinel = project / ".github/workflows/unrelated.yml"
+ sentinel.parent.mkdir(parents=True)
+ sentinel.write_text("name: unrelated\n", encoding="utf-8")
+ monkeypatch.chdir(project)
+ case = _AuditProject(project, isolated.home, source)
+ for arguments in (
+ ("experimental", "enable", "grok-cloud"),
+ ("config", "set", "target", "grok-cloud"),
+ ("install", "--no-policy", "--parallel-downloads", "0"),
+ ):
+ result = case.command(*arguments)
+ assert result.exit_code == 0, result.output
+ assert (project / _DEPLOYED_SKILL).read_bytes() == source.read_bytes()
+ yield case
+
+
+@pytest.mark.req("req-lk-023")
+@pytest.mark.parametrize("cold", [False, True], ids=["warm", "cold"])
+def test_saved_target_drives_read_only_source_replay(installed: _AuditProject, cold: bool) -> None:
+ """An unrelated detection signal must not replace the saved current target."""
+ if cold:
+ shutil.rmtree(installed.project / "apm_modules")
+ checks = installed.audit(0)
+ assert all(row["passed"] for row in checks.values())
+ assert checks["drift"]["passed"] is True
+ assert not (installed.project / ".agents/skills/intent").exists()
+
+
+@pytest.mark.req("req-lk-023")
+def test_user_scope_replay_keeps_user_target_bytes(
+ installed: _AuditProject, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """The user manifest and HOME deployment root remain distinct during replay."""
+ result = installed.command(
+ "install",
+ "--global",
+ str(installed.source.parents[2]),
+ "--no-policy",
+ "--parallel-downloads",
+ "0",
+ )
+ assert result.exit_code == 0, result.output
+ assert (installed.home / _DEPLOYED_SKILL).read_bytes() == installed.source.read_bytes()
+ user = replace(installed, project=installed.home / ".apm")
+ monkeypatch.chdir(user.project)
+ assert all(check["passed"] for check in user.audit(0).values())
+
+
+@pytest.mark.req("req-lk-023")
+def test_full_audit_does_not_recreate_absent_configuration(
+ installed: _AuditProject, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """Scanner discovery must share the replay adapter's read-only configuration."""
+ manifest_path = installed.project / "apm.yml"
+ manifest = load_yaml(manifest_path)
+ manifest["targets"] = ["grok-build"]
+ dump_yaml(manifest, manifest_path)
+ config_path = installed.home / ".apm/config.json"
+ config_path.unlink()
+ monkeypatch.setattr(config, "_config_cache", None)
+ assert all(check["passed"] for check in installed.audit(0).values())
+ assert not config_path.exists()
+
+
+@pytest.mark.req("req-lk-023")
+@pytest.mark.parametrize("invalid", ["claudee", [], 42, None])
+@pytest.mark.parametrize("manifest_wins", [False, True])
+def test_malformed_saved_intent_fails_only_when_selected(
+ installed: _AuditProject, invalid: object, manifest_wins: bool
+) -> None:
+ """Absent and invalid saved targets differ, but manifest precedence remains."""
+ config.update_config({"install_target": invalid})
+ if manifest_wins:
+ path = installed.project / "apm.yml"
+ manifest = load_yaml(path)
+ manifest["targets"] = ["grok-build"]
+ dump_yaml(manifest, path)
+ assert all(check["passed"] for check in installed.audit(0).values())
+ else:
+ checks = installed.audit(1)
+ assert checks["target-resolution"]["passed"] is False
+
+
+@pytest.mark.req("req-lk-023")
+def test_native_workflow_replay_fails_before_writer(
+ installed: _AuditProject, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """An available native DB is not a scratch backend; no native writer may run."""
+ from apm_cli.integration.copilot_app_workflow_integrator import CopilotAppWorkflowIntegrator
+
+ runtime = installed.home / "stand-in-app"
+ runtime.mkdir()
+ database = runtime / "data.db"
+ with closing(sqlite3.connect(database)) as connection:
+ connection.executescript(
+ "CREATE TABLE workflows(id TEXT PRIMARY KEY, prompt TEXT, enabled INTEGER);"
+ "INSERT INTO workflows VALUES('sentinel', 'unchanged', 1);"
+ "PRAGMA user_version=13;"
+ )
+ for suffix in ("-wal", "-shm"):
+ Path(f"{database}{suffix}").write_bytes(b"sidecar sentinel")
+ monkeypatch.setenv("APM_COPILOT_APP_DB", str(database))
+ native_writer = Mock(side_effect=AssertionError("Native workflow writer reached by audit"))
+ monkeypatch.setattr(CopilotAppWorkflowIntegrator, "integrate", native_writer)
+ prompt = installed.source.parents[2] / ".apm/prompts/daily.prompt.md"
+ prompt.parent.mkdir(parents=True)
+ prompt.write_text("---\nname: daily\ninterval: daily\n---\nDo not deploy during audit.\n")
+ assert installed.command("experimental", "enable", "copilot-app").exit_code == 0
+ assert installed.command("config", "set", "target", "copilot-app").exit_code == 0
+ checks = installed.audit(1)
+ native_writer.assert_not_called()
+ assert "scratch replay" in checks["drift"]["message"]
+
+
+@pytest.mark.req("req-lk-023")
+@pytest.mark.parametrize(
+ "mutation",
+ [
+ "clean",
+ "missing-claim",
+ "contracted",
+ "legacy-directory",
+ "legacy-contracted",
+ "forged-content",
+ "unavailable-root",
+ "unavailable-root-no-config",
+ "symlink",
+ ],
+)
+def test_native_user_skill_replay_preserves_layout_and_comparison(
+ installed: _AuditProject, monkeypatch: pytest.MonkeyPatch, mutation: str
+) -> None:
+ """Audit a controlled installed snapshot; Cowork install itself is not exercised."""
+ native_root = installed.home / "stand-in-cowork-skills"
+ native_root.mkdir()
+ neighbor = native_root / "unrelated.txt"
+ neighbor.write_bytes(b"untouched neighbor")
+ monkeypatch.setenv("APM_COPILOT_COWORK_SKILLS_DIR", str(native_root))
+ assert installed.command("experimental", "enable", "copilot-cowork").exit_code == 0
+ assert installed.command("config", "set", "target", "copilot-cowork").exit_code == 0
+ native_source = installed.source.parents[2] / "SKILL.md"
+ shutil.copy2(installed.source, native_source)
+ installed.source.unlink()
+ shutil.copytree(native_source.parent, native_root / "source")
+ user = replace(installed, project=installed.home / ".apm")
+ shutil.copy2(installed.project / "apm.yml", user.project / "apm.yml")
+ shutil.copytree(installed.project / "apm_modules", user.project / "apm_modules", symlinks=True)
+ document = load_yaml(installed.project / "apm.lock.yaml")
+ for deployment in document["deployments"]:
+ deployment.update(
+ kind="uri",
+ target="copilot-cowork",
+ scope="user",
+ value=deployment["value"].replace(".grok/skills/intent", "cowork://skills/source"),
+ )
+ for dependency in document["dependencies"]:
+ dependency["deployed_files"] = [
+ value.replace(".grok/skills/intent", "cowork://skills/source")
+ for value in dependency["deployed_files"]
+ ]
+ dependency["deployed_file_hashes"] = {
+ value.replace(".grok/skills/intent", "cowork://skills/source"): digest
+ for value, digest in dependency["deployed_file_hashes"].items()
+ }
+ dump_yaml(document, user.project / "apm.lock.yaml")
+ assert (native_root / "source/SKILL.md").read_bytes() == native_source.read_bytes()
+ monkeypatch.chdir(user.project)
+ if mutation in {"missing-claim", "legacy-directory", "legacy-contracted", "forged-content"}:
+ path = user.project / "apm.lock.yaml"
+ document = load_yaml(path)
+ if mutation == "forged-content":
+ contents = b"Locally forged bytes\n"
+ (native_root / "source/SKILL.md").write_bytes(contents)
+ digest = "sha256:" + hashlib.sha256(contents).hexdigest()
+ for deployment in document["deployments"]:
+ if deployment["value"] == "cowork://skills/source/SKILL.md":
+ deployment["content_hash"] = digest
+ for dependency in document["dependencies"]:
+ dependency["deployed_file_hashes"]["cowork://skills/source/SKILL.md"] = digest
+ else:
+ document["deployments"] = []
+ if mutation != "missing-claim":
+ document.pop("deployments")
+ for dependency in document["dependencies"]:
+ dependency["deployed_files"] = (
+ [] if mutation == "missing-claim" else ["cowork://skills/source"]
+ )
+ dependency["deployed_file_hashes"] = {}
+ dump_yaml(document, path)
+ if mutation in {
+ "contracted",
+ "legacy-contracted",
+ "unavailable-root",
+ "unavailable-root-no-config",
+ }:
+ assert installed.command("config", "set", "target", "claude").exit_code == 0
+ if mutation.startswith("unavailable-root"):
+ native_root.rename(native_root.with_name("unavailable-cowork-skills"))
+ monkeypatch.delenv("APM_COPILOT_COWORK_SKILLS_DIR")
+ if mutation == "unavailable-root-no-config":
+ manifest_path = user.project / "apm.yml"
+ manifest = load_yaml(manifest_path)
+ manifest["targets"] = ["claude"]
+ dump_yaml(manifest, manifest_path)
+ Path(config.CONFIG_FILE).unlink()
+ config._invalidate_config_cache()
+ elif mutation == "symlink":
+ moved = native_root / "source"
+ external = installed.home / "outside-native-root"
+ moved.rename(external)
+ moved.symlink_to(external, target_is_directory=True)
+ checks = user.audit(0 if mutation in {"clean", "legacy-directory"} else 1)
+ if mutation in {"clean", "legacy-directory"}:
+ assert all(check["passed"] for check in checks.values())
+ elif mutation.startswith("unavailable-root"):
+ assert "deployment root is unavailable" in checks["drift"]["message"]
+ else:
+ kind = {
+ "missing-claim": "unrecorded",
+ "forged-content": "modified",
+ "symlink": "unintegrated",
+ }.get(mutation, "orphaned")
+ assert any(
+ detail.startswith(f"{kind}: ") and detail.endswith("source/SKILL.md")
+ for detail in checks["drift"]["details"]
+ )
+ if not mutation.startswith("unavailable-root"):
+ assert neighbor.read_bytes() == b"untouched neighbor"
+
+
+@pytest.mark.req("req-lk-023")
+@pytest.mark.parametrize("intent", ["manifest", "config", "detection"])
+def test_current_intent_overrides_old_target_ownership(
+ installed: _AuditProject, intent: str
+) -> None:
+ """Contracted targets stay comparable; ownership cannot authorize replay."""
+ expected = ".claude/skills/intent/SKILL.md"
+ if intent == "manifest":
+ path = installed.project / "apm.yml"
+ document = load_yaml(path)
+ document["targets"] = ["claude"]
+ dump_yaml(document, path)
+ elif intent == "config":
+ assert installed.command("config", "set", "target", "claude").exit_code == 0
+ else:
+ assert installed.command("config", "unset", "target").exit_code == 0
+ expected = ".agents/skills/intent/SKILL.md"
+ checks = installed.audit(1)
+ assert f"unintegrated: {expected}" in checks["drift"]["details"]
+ if intent != "detection":
+ assert f"orphaned: {_DEPLOYED_SKILL}" in checks["drift"]["details"]
+ else:
+ # Existing .grok detection also selects Grok Build, which shares this root.
+ assert (installed.project / _DEPLOYED_SKILL).read_bytes() == installed.source.read_bytes()
+
+
+@pytest.mark.req("req-lk-023")
+@pytest.mark.parametrize("invalid", ["disabled", "manifest"])
+def test_invalid_current_selection_cannot_pass_empty_replay(
+ installed: _AuditProject, invalid: str
+) -> None:
+ """A malformed manifest or unavailable selected target is not absent intent."""
+ if invalid == "disabled":
+ assert installed.command("experimental", "disable", "grok-cloud").exit_code == 0
+ checks = installed.audit(1)
+ assert checks["target-resolution"]["passed"] is False
+ else:
+ path = installed.project / "apm.yml"
+ document = load_yaml(path)
+ document["targets"] = ["grok-cloud"]
+ dump_yaml(document, path)
+ result = installed.audit_result(2)
+ assert "Unknown target 'grok-cloud'" in result.output
+
+
+@pytest.mark.req("req-lk-023")
+@pytest.mark.parametrize("mutation", ["content", "missing-claim"])
+def test_configured_target_keeps_integrity_and_membership_checks(
+ installed: _AuditProject, mutation: str
+) -> None:
+ """Re-verify configured-target hashes and detect omitted deployment records."""
+ if mutation == "content":
+ (installed.project / _DEPLOYED_SKILL).write_bytes(b"Unexpected deployed bytes\n")
+ else:
+ path = installed.project / "apm.lock.yaml"
+ document = load_yaml(path)
+ document["deployments"] = []
+ for dependency in document["dependencies"]:
+ dependency["deployed_files"] = []
+ dependency["deployed_file_hashes"] = {}
+ dump_yaml(document, path)
+ checks = installed.audit(1)
+ if mutation == "content":
+ assert checks["content-integrity"]["passed"] is False
+ kind = "modified"
+ else:
+ kind = "unrecorded"
+ assert f"{kind}: {_DEPLOYED_SKILL}" in checks["drift"]["details"]
+
+
+@pytest.mark.req("req-pl-016")
+def test_configured_target_does_not_authorize_invalid_owners(installed: _AuditProject) -> None:
+ """Current target intent cannot bless an owner missing from the dependency set."""
+ path = installed.project / "apm.lock.yaml"
+ document = load_yaml(path)
+ for deployment in document["deployments"]:
+ deployment["owners"] = ["removed/owner"]
+ deployment["active_owner"] = "removed/owner"
+ dump_yaml(document, path)
+ checks = installed.audit(1)
+ assert checks["deployment-ledger-owners"]["passed"] is False
+
+
+@pytest.mark.req("req-lk-023")
+def test_saved_explicit_only_target_replays_without_detection(
+ installed: _AuditProject, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """Saved agent-skills intent is explicit selection, not a detection predicate."""
+ project = (
+ LocalPackageFactory(installed.project.parent)
+ .create("explicit-only-consumer", dependencies=[{"path": str(installed.source.parents[2])}])
+ .root
+ )
+ case = replace(installed, project=project)
+ monkeypatch.chdir(project)
+ manifest = load_yaml(project / "apm.yml")
+ assert "target" not in manifest and "targets" not in manifest
+ assert case.command("config", "set", "target", "agent-skills").exit_code == 0
+ result = case.command("install", "--no-policy", "--parallel-downloads", "0")
+ assert result.exit_code == 0, result.output
+ assert {target.name for target in resolve_audit_targets(project)} == {"agent-skills"}
+ assert "agent-skills" not in {
+ target.name for target in resolve_targets(project, create_config=False)
+ }
+ deployed = project / ".agents/skills/intent/SKILL.md"
+ assert deployed.read_bytes() == case.source.read_bytes()
+ assert all(check["passed"] for check in case.audit(0).values())
+
+
+@pytest.mark.req("req-lk-023")
+@pytest.mark.parametrize("representation", ["canonical", "legacy", "both"])
+def test_historical_native_claims_cannot_pass_filesystem_only_audit(
+ installed: _AuditProject, monkeypatch: pytest.MonkeyPatch, representation: str
+) -> None:
+ """Portable filesystem intent cannot silently discard retained native ownership."""
+ from apm_cli.integration.copilot_app_workflow_integrator import CopilotAppWorkflowIntegrator
+
+ manifest_path = installed.project / "apm.yml"
+ manifest = load_yaml(manifest_path)
+ manifest["targets"] = ["grok-build"]
+ dump_yaml(manifest, manifest_path)
+ assert all(check["passed"] for check in installed.audit(0).values())
+
+ database = installed.home / "retained-native.db"
+ with closing(sqlite3.connect(database)) as connection:
+ connection.executescript(
+ "CREATE TABLE workflows(id TEXT PRIMARY KEY, prompt TEXT, enabled INTEGER);"
+ "INSERT INTO workflows VALUES('retained', 'original host state', 1);"
+ )
+ for suffix in ("-wal", "-shm"):
+ Path(f"{database}{suffix}").write_bytes(b"retained native sidecar")
+ monkeypatch.setenv("APM_COPILOT_APP_DB", str(database))
+ writer = Mock(side_effect=AssertionError("Historical native writer reached by audit"))
+ monkeypatch.setattr(CopilotAppWorkflowIntegrator, "integrate", writer)
+
+ lock_path = installed.project / "apm.lock.yaml"
+ document = load_yaml(lock_path)
+ owner = document["deployments"][0]["active_owner"]
+ uri = "copilot-app-db://workflows/retained-host-workflow"
+ if representation in {"canonical", "both"}:
+ document["deployments"].append(
+ {
+ "kind": "uri",
+ "target": "copilot-app",
+ "value": uri,
+ "runtime": None,
+ "scope": "project",
+ "owners": [owner],
+ "active_owner": owner,
+ "content_hash": None,
+ }
+ )
+ if representation in {"legacy", "both"}:
+ document["dependencies"][0]["deployed_files"].append(uri)
+ dump_yaml(document, lock_path)
+
+ checks = installed.audit(1)
+ writer.assert_not_called()
+ assert all(check["passed"] for name, check in checks.items() if name != "drift"), checks
+ assert checks["drift"]["passed"] is False
+ assert "no isolated filesystem scratch replay backend" in checks["drift"]["message"]
+
+
+class _KnownInternalLinkReplayGap(AssertionError):
+ """Only the demonstrated false-orphan outcome is an expected failure."""
+
+
+@pytest.mark.req("req-lk-023")
+@pytest.mark.xfail(
+ strict=True,
+ raises=_KnownInternalLinkReplayGap,
+ reason="Inherited replay omits an internal resource link dereferenced by local acquisition",
+)
+def test_admitted_internal_resource_link_survives_unchanged_audit(
+ installed: _AuditProject,
+) -> None:
+ """Acquired regular-file content should not become obsolete during replay."""
+ payload = installed.source.parent / "payload.txt"
+ payload.write_bytes(b"Internal resource payload\n")
+ (installed.source.parent / "linked.txt").symlink_to("payload.txt")
+ result = installed.command("install", "--no-policy", "--parallel-downloads", "0")
+ assert result.exit_code == 0, result.output
+ relative = ".grok/skills/intent/linked.txt"
+ deployed = installed.project / relative
+ assert deployed.is_file() and not deployed.is_symlink()
+ assert deployed.read_bytes() == payload.read_bytes()
+
+ project_before = ArtifactSnapshot.capture(installed.project)
+ home_before = ArtifactSnapshot.capture(installed.home)
+ result = installed.command("audit", "--ci", "--no-policy", "--no-fail-fast", "-f", "json")
+ assert_unchanged(project_before, ArtifactSnapshot.capture(installed.project))
+ assert_unchanged(home_before, ArtifactSnapshot.capture(installed.home))
+ checks = {row["name"]: row for row in json.loads(result.stdout)["checks"]}
+ assert checks["content-integrity"]["passed"] is True, checks
+ assert all(row["passed"] for name, row in checks.items() if name != "drift"), checks
+ if (
+ result.exit_code == 1
+ and checks["drift"]["passed"] is False
+ and checks["drift"]["details"] == [f"orphaned: {relative}"]
+ ):
+ raise _KnownInternalLinkReplayGap(
+ "Unchanged CI audit must preserve admitted linked.txt content, not report it orphaned"
+ )
+ assert result.exit_code == 0, result.output
+ assert checks["drift"]["passed"] is True
+
+
+@pytest.mark.req("req-mf-016")
+def test_local_resource_escape_is_rejected_before_audit(installed: _AuditProject) -> None:
+ """The replay limitation does not permit acquiring an escaping resource link."""
+ outside = installed.source.parents[2].parent / "outside.txt"
+ outside.write_bytes(b"Outside selected package\n")
+ (installed.source.parent / "escaped.txt").symlink_to(outside)
+ result = installed.command("install", "--no-policy", "--parallel-downloads", "0")
+ assert result.exit_code != 0, result.output
+ assert outside.read_bytes() == b"Outside selected package\n"
+ assert not (installed.project / ".grok/skills/intent/escaped.txt").exists()
diff --git a/tests/spec_conformance/test_gen_statement.py b/tests/spec_conformance/test_gen_statement.py
index ff5beedc16..22d0919bd3 100644
--- a/tests/spec_conformance/test_gen_statement.py
+++ b/tests/spec_conformance/test_gen_statement.py
@@ -6,12 +6,11 @@
import subprocess
import sys
-from tests.spec_conformance._manifest import REPO_ROOT
+from tests.spec_conformance._manifest import REPO_ROOT, selected_assessment
from tests.spec_conformance.gen_statement import (
CONFORMANCE_JSON,
CONFORMANCE_MD,
GENERATOR,
- SPEC_VERSION,
USER_SCOPE_DISCLOSURE,
)
@@ -51,7 +50,7 @@ def test_gen_statement_emits_ascii_only():
def test_gen_statement_md_advertises_spec_version_and_generator():
_run_gen()
md = CONFORMANCE_MD.read_text(encoding="ascii")
- assert SPEC_VERSION in md, f"missing '{SPEC_VERSION}' in CONFORMANCE.md"
+ assert selected_assessment().version in md
assert GENERATOR in md, f"missing '{GENERATOR}' in CONFORMANCE.md"
diff --git a/tests/spec_conformance/test_local_path_reqs.py b/tests/spec_conformance/test_local_path_reqs.py
new file mode 100644
index 0000000000..2ad599fe3f
--- /dev/null
+++ b/tests/spec_conformance/test_local_path_reqs.py
@@ -0,0 +1,507 @@
+"""Executable req-mf-016 source anchoring, admission, and containment contracts."""
+
+from __future__ import annotations
+
+import shutil
+from collections.abc import Iterator
+from contextlib import contextmanager
+from pathlib import Path
+from unittest.mock import MagicMock
+
+import pytest
+
+from apm_cli.core.scope import InstallScope
+from apm_cli.deps.apm_resolver import APMDependencyResolver
+from apm_cli.deps.tiered_ref_resolver import RefFreshnessPolicy
+from apm_cli.install.context import InstallContext
+from apm_cli.install.package_resolution import user_scope_rejection_reason
+from apm_cli.install.phases.local_content import _copy_local_package
+from apm_cli.install.phases.resolve import _materialization, _resolve_dependencies
+from apm_cli.install.resolution_staging import ResolutionStagingSession
+from apm_cli.install.sources import LocalDependencySource
+from apm_cli.models.apm_package import APMPackage
+from apm_cli.models.dependency import DependencyReference
+from apm_cli.utils.diagnostics import DiagnosticCollector
+from apm_cli.utils.path_security import PathTraversalError
+from apm_cli.utils.yaml_io import dump_yaml
+from tests.spec_conformance._helpers import load_yaml_fixture
+from tests.utils.local_package import LocalPackageFactory
+
+pytestmark = pytest.mark.component
+
+
+@contextmanager
+def _resolved_scope(
+ manifest: Path, scope: InstallScope, downloader: MagicMock | None = None
+) -> Iterator[InstallContext]:
+ """Run the real resolution phase with fixture transport and scoped storage."""
+ root = manifest.parent
+ package = APMPackage.from_apm_yml(manifest, source_path=root)
+ modules = root / "apm_modules"
+ modules.mkdir()
+ ctx = InstallContext(
+ project_root=root,
+ apm_dir=root,
+ apm_package=package,
+ scope=scope,
+ all_apm_deps=package.get_apm_dependencies(),
+ apm_modules_dir=modules,
+ ref_freshness_policy=RefFreshnessPolicy.REPRODUCIBLE,
+ downloader=downloader if downloader is not None else MagicMock(shared_clone_cache=None),
+ diagnostics=DiagnosticCollector(),
+ )
+ staging = ResolutionStagingSession(modules)
+ try:
+ _resolve_dependencies(ctx, staging, _materialization.CachedMaterializationPathReader())
+ yield ctx
+ finally:
+ staging.rollback()
+
+
+@pytest.mark.req("req-mf-016")
+@pytest.mark.parametrize(
+ "reference", ["./child", "../child", "/child", "~/child", ".\\child", "..\\child", "~\\child"]
+)
+def test_local_path_prefixes_are_recognized(reference: str) -> None:
+ """Recognize local spelling without confusing it with a remote coordinate."""
+ dep = DependencyReference.parse(reference)
+ assert dep.is_local
+ assert dep.local_path == reference
+
+
+@pytest.mark.req("req-mf-016")
+@pytest.mark.parametrize("scope", [InstallScope.PROJECT, InstallScope.USER])
+def test_local_sibling_uses_original_source_in_each_scope(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, scope: InstallScope
+) -> None:
+ """Both real admission consumers retain the original parent, not CWD/staging."""
+ factory = LocalPackageFactory(tmp_path / "sources")
+ child = factory.create("child", targets=["cursor"])
+ source = factory.add_command(child, "child", "---\ndescription: child\n---\nOriginal child\n")
+ parent = factory.create("parent")
+ dump_yaml(load_yaml_fixture("manifest", "valid-local-parent.yml"), parent.manifest_path)
+ consumer = LocalPackageFactory(tmp_path / "scope").create(
+ "consumer", dependencies=[{"path": parent.root.as_posix()}]
+ )
+ unrelated = tmp_path / "unrelated"
+ decoy = LocalPackageFactory(unrelated).create("child")
+ (unrelated / "cwd").mkdir()
+ monkeypatch.chdir(unrelated / "cwd")
+ with _resolved_scope(consumer.manifest_path, scope) as ctx:
+ modules = ctx.apm_modules_dir
+ assert not ctx.callback_failures
+ assert {dep.repo_url for dep in ctx.deps_to_install} == {"_local/parent", "_local/child"}
+ child_ref = next(dep for dep in ctx.deps_to_install if dep.repo_url == "_local/child")
+ node = ctx.dependency_graph.dependency_tree.get_node(child_ref.get_unique_key())
+ assert node.parent.package.source_path == parent.root
+ assert node.package.source_path == child.root
+ assert not child.root.is_relative_to(consumer.root)
+ assert decoy.manifest_path.is_file()
+ assert child_ref.local_path == "../child"
+ assert child_ref.declaring_parent == parent.root.as_posix()
+ assert child_ref.anchored_local_path == child.root.as_posix()
+ assert child_ref.get_unique_key() in ctx.callback_downloaded
+ materialized = LocalDependencySource(
+ ctx, child_ref, child_ref.get_install_path(modules), child_ref.get_unique_key()
+ ).acquire()
+ assert materialized is not None
+ assert materialized.package_info.package.source_path == child.root
+ assert (
+ child_ref.get_install_path(modules)
+ .joinpath(".apm", "prompts", "child.prompt.md")
+ .read_bytes()
+ == source.read_bytes()
+ )
+ ctx.downloader.download_package.assert_not_called()
+
+
+@pytest.mark.req("req-mf-016")
+def test_user_multihop_chain_keeps_each_original_anchor(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """Grandchildren resolve from their immediate source parent, never an ancestor."""
+ first = LocalPackageFactory(tmp_path / "sources" / "first")
+ second = LocalPackageFactory(tmp_path / "sources" / "second")
+ grandchild = second.create("grandchild")
+ child = second.create("child", dependencies=[{"path": "../grandchild"}])
+ parent = first.create("parent", dependencies=[{"path": "../../second/child"}])
+ ancestor_decoy = first.create("grandchild", version="9.9.9")
+ unrelated = LocalPackageFactory(tmp_path / "unrelated")
+ cwd_decoy = unrelated.create("grandchild", version="8.8.8")
+ cwd = unrelated.create("cwd")
+ monkeypatch.chdir(cwd.root)
+ consumer = LocalPackageFactory(tmp_path / "user").create(
+ "consumer", dependencies=[{"path": parent.root.as_posix()}]
+ )
+ with _resolved_scope(consumer.manifest_path, InstallScope.USER) as ctx:
+ assert not ctx.callback_failures
+ refs = {dep.repo_url: dep for dep in ctx.deps_to_install}
+ assert set(refs) == {"_local/parent", "_local/child", "_local/grandchild"}
+ for package, declaring, spelling in (
+ (child, parent, "../../second/child"),
+ (grandchild, child, "../grandchild"),
+ ):
+ ref = refs[f"_local/{package.name}"]
+ node = ctx.dependency_graph.dependency_tree.get_node(ref.get_unique_key())
+ assert node.parent.package.source_path == declaring.root
+ assert node.package.source_path == package.root
+ assert ctx.dep_base_dirs[ref.get_unique_key()] == declaring.root
+ assert ref.local_path == spelling
+ assert ref.anchored_local_path == package.root.as_posix()
+ materialized = LocalDependencySource(
+ ctx, ref, ref.get_install_path(ctx.apm_modules_dir), ref.get_unique_key()
+ ).acquire()
+ assert materialized is not None
+ assert materialized.package_info.package.source_path == package.root
+ assert (materialized.install_path / "apm.yml").read_bytes() == (
+ package.manifest_path.read_bytes()
+ )
+ assert ancestor_decoy.manifest_path.is_file()
+ assert cwd_decoy.manifest_path.is_file()
+ assert (
+ refs["_local/grandchild"]
+ .get_install_path(ctx.apm_modules_dir)
+ .joinpath("apm.yml")
+ .read_bytes()
+ != ancestor_decoy.manifest_path.read_bytes()
+ )
+ ctx.downloader.download_package.assert_not_called()
+
+
+@pytest.mark.req("req-mf-016")
+def test_missing_user_anchor_does_not_use_other_scope_install(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """Recorded identity and a matching project installation do not authorize replay."""
+ factory = LocalPackageFactory(tmp_path / "sources")
+ child = factory.create("child")
+ parent = factory.create("parent", dependencies=[{"path": "../child"}])
+ consumer = LocalPackageFactory(tmp_path / "user").create(
+ "consumer", dependencies=[{"path": parent.root.as_posix()}]
+ )
+ project = LocalPackageFactory(tmp_path / "project").create("consumer")
+ monkeypatch.chdir(project.root)
+ with _resolved_scope(consumer.manifest_path, InstallScope.USER) as ctx:
+ ref = next(dep for dep in ctx.deps_to_install if dep.repo_url == "_local/child")
+ project_copy = ref.get_install_path(project.root / "apm_modules")
+ shutil.copytree(child.root, project_copy)
+ before = (project_copy / "apm.yml").read_bytes()
+ destination = ref.get_install_path(ctx.apm_modules_dir)
+ shutil.rmtree(destination)
+ node = ctx.dependency_graph.dependency_tree.get_node(ref.get_unique_key())
+ node.parent.package.source_path = None
+ assert ref.declaring_parent and ref.anchored_local_path
+ assert ctx.dep_base_dirs[ref.get_unique_key()] == parent.root
+ assert (
+ user_scope_rejection_reason(ref, InstallScope.USER, parent_pkg=node.parent.package)
+ is not None
+ )
+ result = LocalDependencySource(ctx, ref, destination, ref.get_unique_key()).acquire()
+ assert result is None
+ assert not destination.exists()
+ assert (project_copy / "apm.yml").read_bytes() == before
+ assert child.manifest_path.read_bytes() == before
+ ctx.downloader.download_package.assert_not_called()
+
+
+@pytest.mark.req("req-mf-016")
+@pytest.mark.parametrize("scope", [InstallScope.PROJECT, InstallScope.USER])
+@pytest.mark.parametrize("repository", ["org/repo", "_local/parent"])
+@pytest.mark.parametrize(
+ "reference", ["../child", "../../../outside", "absolute"], ids=["sibling", "escape", "absolute"]
+)
+def test_remote_paths_are_routed_before_local_admission(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ reference: str,
+ repository: str,
+ scope: InstallScope,
+) -> None:
+ """Parsed Git origin outranks scope and repository spelling before local reads."""
+ outside = LocalPackageFactory(tmp_path).create("outside")
+ if reference == "absolute":
+ reference = outside.root.as_posix()
+ factory = LocalPackageFactory(tmp_path / "remote" / "packages")
+ parent = factory.create("parent", dependencies=[{"path": reference}])
+ child = factory.create("child")
+ remote = {
+ "git": f"https://gitlab.example.invalid:8443/{repository}",
+ "path": "packages/parent",
+ "ref": "a" * 40,
+ }
+ consumer = LocalPackageFactory(tmp_path / "user").create("consumer", dependencies=[remote])
+ fixtures = {"packages/parent": parent, "packages/child": child}
+
+ def download(dep: DependencyReference, destination: Path) -> None:
+ assert not dep.is_local and dep.local_path is None
+ shutil.copytree(fixtures[dep.virtual_path].root, destination)
+
+ downloader = MagicMock(shared_clone_cache=None)
+ downloader.download_package.side_effect = download
+ local_copy = MagicMock(side_effect=AssertionError("Remote path reached local acquisition"))
+ monkeypatch.setattr("apm_cli.install.phases.local_content._copy_local_package", local_copy)
+ with _resolved_scope(consumer.manifest_path, scope, downloader) as ctx:
+ local_copy.assert_not_called()
+ requested = [call.args[0] for call in downloader.download_package.call_args_list]
+ expected_paths = ["packages/parent"]
+ if reference == "../child":
+ expected_paths.append("packages/child")
+ assert not ctx.callback_failures
+ else:
+ assert ctx.callback_failures == {DependencyReference.parse(reference).get_unique_key()}
+ assert [dep.virtual_path for dep in requested] == expected_paths
+ assert {dep.virtual_path for dep in ctx.deps_to_install} == set(expected_paths)
+ original = DependencyReference.parse_from_dict(remote)
+ for dep in requested:
+ assert (dep.host, dep.port, dep.repo_url, dep.reference, dep.explicit_scheme) == (
+ original.host,
+ original.port,
+ original.repo_url,
+ original.reference,
+ original.explicit_scheme,
+ )
+ assert user_scope_rejection_reason(dep, InstallScope.USER) is None
+ assert dep.get_unique_key() in ctx.callback_downloaded
+ node = ctx.dependency_graph.dependency_tree.get_node(dep.get_unique_key())
+ assert node.package.proven_source_kind == "git"
+ assert node.package.source_path.is_relative_to(ctx.apm_modules_dir)
+ assert dep.get_install_path(ctx.apm_modules_dir).joinpath("apm.yml").read_bytes() == (
+ fixtures[dep.virtual_path].manifest_path.read_bytes()
+ )
+ assert all(not dep.is_local for dep in ctx.deps_to_install)
+ assert (
+ not DependencyReference.parse(outside.root.as_posix())
+ .get_install_path(ctx.apm_modules_dir)
+ .exists()
+ )
+ assert outside.manifest_path.is_file()
+ local_copy.assert_not_called()
+
+
+@pytest.mark.req("req-mf-016")
+@pytest.mark.parametrize(
+ "context", ["direct", "missing-parent", "missing-source", "relative-source", "remote"]
+)
+def test_user_relative_admission_requires_proven_local_parent(tmp_path: Path, context: str) -> None:
+ """Existing files and a claimed anchor alone cannot authorize a global read."""
+ child = LocalPackageFactory(tmp_path).create("child")
+ dep = DependencyReference.parse("./child")
+ if context != "direct":
+ dep.declaring_parent = tmp_path.as_posix()
+ parent = APMPackage(
+ name="parent",
+ version="1.0.0",
+ source="org/remote" if context == "remote" else "_local/parent",
+ source_path=(
+ None
+ if context == "missing-source"
+ else Path("relative")
+ if context == "relative-source"
+ else tmp_path
+ ),
+ )
+ reason = user_scope_rejection_reason(
+ dep, InstallScope.USER, parent_pkg=None if context == "missing-parent" else parent
+ )
+ assert child.manifest_path.is_file()
+ assert reason is not None
+ assert "relative local paths" in reason
+ assert "absolute path" in reason
+ assert (
+ user_scope_rejection_reason(
+ DependencyReference.parse(child.root.as_posix()), InstallScope.USER
+ )
+ is None
+ )
+
+
+@pytest.mark.req("req-mf-016")
+@pytest.mark.parametrize("case", ["project-relative", "user-absolute", "user-home"])
+def test_direct_local_source_is_anchored_before_copy(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, case: str
+) -> None:
+ """Absolute/home sources work globally; project-relative sources use the project."""
+ package = LocalPackageFactory(tmp_path / "sources").create("package")
+ consumer = tmp_path / "consumer"
+ consumer.mkdir()
+ monkeypatch.setenv("HOME", str(tmp_path))
+ monkeypatch.setenv("USERPROFILE", str(tmp_path))
+ reference = {
+ "project-relative": "../sources/package",
+ "user-absolute": package.root.as_posix(),
+ "user-home": "~/sources/package",
+ }[case]
+ scope = InstallScope.PROJECT if case == "project-relative" else InstallScope.USER
+ dep = DependencyReference.parse(reference)
+ assert user_scope_rejection_reason(dep, scope) is None
+ destination = consumer / "apm_modules" / "_local" / "package"
+ assert (
+ _copy_local_package(dep, destination, consumer, project_root=consumer, logger=None)
+ == destination
+ )
+ assert (destination / "apm.yml").read_bytes() == package.manifest_path.read_bytes()
+
+
+@pytest.mark.req("req-mf-016")
+@pytest.mark.parametrize("reference", ["../child", "..\\child"])
+def test_remote_relative_child_retains_repository_and_ref(tmp_path: Path, reference: str) -> None:
+ """A sibling inside the remote repository remains remote, not a host-file read."""
+ modules = tmp_path / "apm_modules"
+ factory = LocalPackageFactory(modules / "repo" / "packages")
+ parent = factory.create("parent")
+ child = factory.create("child")
+ parent_dep = DependencyReference.parse_from_dict(
+ {
+ "git": "https://gitlab.example.invalid:8443/org/repo",
+ "path": "packages/parent",
+ "ref": "a" * 40,
+ }
+ )
+ parent_pkg = APMPackage.from_apm_yml(parent.manifest_path, source_path=parent.root)
+ parent_pkg.source = parent_dep.repo_url
+ resolver = APMDependencyResolver(apm_modules_dir=modules)
+ expanded = resolver._expand_or_reject_remote_parent_local_path(
+ parent_dep, parent_pkg, DependencyReference.parse(reference)
+ )
+ assert child.manifest_path.is_file()
+ assert expanded is not None
+ assert not expanded.is_local
+ assert expanded.local_path is None
+ assert expanded.virtual_path == "packages/child"
+ assert (
+ expanded.host,
+ expanded.port,
+ expanded.repo_url,
+ expanded.reference,
+ expanded.explicit_scheme,
+ ) == (
+ parent_dep.host,
+ parent_dep.port,
+ parent_dep.repo_url,
+ parent_dep.reference,
+ parent_dep.explicit_scheme,
+ )
+ assert not resolver._rejected_remote_local_keys
+
+
+@pytest.mark.req("req-mf-016")
+@pytest.mark.parametrize(
+ "kind", ["escape", "absolute", "absolute-inside", "home", "windows-absolute", "symlink"]
+)
+def test_remote_parent_cannot_expand_into_host_files(
+ tmp_path: Path, capsys: pytest.CaptureFixture[str], kind: str
+) -> None:
+ """The real expansion and loader gates refuse remote-to-host transitions."""
+ modules = tmp_path / "apm_modules"
+ parent = LocalPackageFactory(modules / "repo" / "packages").create("parent")
+ outside = LocalPackageFactory(tmp_path).create("outside")
+ parent_dep = DependencyReference.parse_from_dict(
+ {"git": "https://gitlab.example.invalid/org/repo", "path": "packages/parent", "ref": "v1"}
+ )
+ parent_pkg = APMPackage.from_apm_yml(parent.manifest_path, source_path=parent.root)
+ parent_pkg.source = parent_dep.repo_url
+ paths = {
+ "escape": "../../../../outside",
+ "absolute": outside.root.as_posix(),
+ "absolute-inside": parent.root.as_posix(),
+ "home": "~/outside",
+ "windows-absolute": "C:/outside",
+ "symlink": "../linked",
+ }
+ if kind == "symlink":
+ (parent.root.parent / "linked").symlink_to(outside.root, target_is_directory=True)
+ dep = DependencyReference.parse_from_dict({"path": paths[kind]})
+ callback = MagicMock(side_effect=AssertionError("Rejected remote path reached acquisition"))
+ resolver = APMDependencyResolver(apm_modules_dir=modules, download_callback=callback)
+ assert resolver._expand_or_reject_remote_parent_local_path(parent_dep, parent_pkg, dep) is None
+ assert dep.get_unique_key() in resolver._rejected_remote_local_keys
+ assert resolver._try_load_dependency_package(dep, parent_pkg=parent_pkg) is None
+ callback.assert_not_called()
+ assert outside.manifest_path.is_file()
+ assert not (modules / "_local").exists()
+ assert paths[kind] in "".join(capsys.readouterr().out.split())
+
+
+@pytest.mark.req("req-mf-016")
+@pytest.mark.parametrize(
+ "selected_alias", [False, True], ids=["source-directory", "resolved-source-alias"]
+)
+def test_selected_local_root_allows_only_internal_symlink_content(
+ tmp_path: Path, selected_alias: bool
+) -> None:
+ """Selecting a source-directory alias is distinct from following its contents."""
+ package = LocalPackageFactory(tmp_path / "sources").create("package")
+ target = package.root / "content.txt"
+ target.write_text("Internal content\n", encoding="ascii")
+ (package.root / "link.txt").symlink_to("content.txt")
+ selected = package.root
+ if selected_alias:
+ selected = tmp_path / "chosen-package"
+ selected.symlink_to(package.root, target_is_directory=True)
+ consumer = tmp_path / "consumer"
+ destination = consumer / "apm_modules" / "_local" / "package"
+ result = _copy_local_package(
+ DependencyReference.parse(selected.as_posix()),
+ destination,
+ consumer,
+ project_root=consumer,
+ logger=None,
+ )
+ assert result == destination
+ assert (destination / "link.txt").read_bytes() == target.read_bytes()
+ assert not (destination / "link.txt").is_symlink()
+ assert (destination / "content.txt").read_bytes() == target.read_bytes()
+
+
+@pytest.mark.req("req-mf-016")
+@pytest.mark.parametrize("kind", ["outside-file", "outside-directory", "broken", "directory-cycle"])
+def test_local_package_rejects_uncontained_or_unresolvable_symlink(
+ tmp_path: Path, kind: str
+) -> None:
+ """A selected local source cannot import external or invalid link content."""
+ package = LocalPackageFactory(tmp_path / "sources").create("package")
+ outside = tmp_path / "outside"
+ outside.mkdir()
+ secret = outside / "not-package-content.txt"
+ secret.write_text("Outside content\n", encoding="ascii")
+ targets = {
+ "outside-file": secret,
+ "outside-directory": outside,
+ "broken": package.root / "missing",
+ "directory-cycle": package.root,
+ }
+ link = package.root / "linked"
+ link.symlink_to(
+ targets[kind], target_is_directory=kind in {"outside-directory", "directory-cycle"}
+ )
+ destination = tmp_path / "consumer" / "apm_modules" / "_local" / "package"
+ with pytest.raises(PathTraversalError) as error:
+ _copy_local_package(
+ DependencyReference.parse(package.root.as_posix()),
+ destination,
+ tmp_path / "consumer",
+ project_root=tmp_path / "consumer",
+ logger=None,
+ )
+ assert "linked" in str(error.value)
+ assert not destination.exists()
+ assert secret.read_text(encoding="ascii") == "Outside content\n"
+
+
+@pytest.mark.req("req-mf-016")
+def test_local_package_rejects_file_symlink_cycle(tmp_path: Path) -> None:
+ """An OS-detected file cycle also fails; no whole-install rollback is promised."""
+ package = LocalPackageFactory(tmp_path / "sources").create("package")
+ (package.root / "linked").symlink_to("linked")
+ destination = tmp_path / "consumer" / "apm_modules" / "_local" / "package"
+ # pathlib reports a cycle as RuntimeError on Python 3.12; newer versions
+ # use OSError, which the copier translates to PathTraversalError.
+ with pytest.raises((PathTraversalError, RuntimeError), match="linked"):
+ _copy_local_package(
+ DependencyReference.parse(package.root.as_posix()),
+ destination,
+ tmp_path / "consumer",
+ project_root=tmp_path / "consumer",
+ logger=None,
+ )
+ assert not (destination / "linked").exists()
diff --git a/tests/spec_conformance/test_lockfile_reqs.py b/tests/spec_conformance/test_lockfile_reqs.py
index b1d7b08273..25cc59ebe5 100644
--- a/tests/spec_conformance/test_lockfile_reqs.py
+++ b/tests/spec_conformance/test_lockfile_reqs.py
@@ -269,7 +269,7 @@ def test_lockfile_tree_sha256_canonicalisation_invariant():
@pytest.mark.req("req-lk-016")
def test_lockfile_reader_tolerates_bare_hex_hash():
- """v0.1 schema tolerates bare-hex; v0.2 will require envelope."""
+ """The unchanged wire schema and corrective revision retain bare-hex readers."""
schema = load_schema("lockfile-v0.1.schema.json")
pattern = schema["properties"]["local_deployed_file_hashes"]["additionalProperties"]["pattern"]
assert "[0-9a-f]{64}" in pattern
@@ -314,7 +314,7 @@ def test_lockfile_should_record_publish_timestamp():
"requires registry interaction to exercise end-to-end. The "
"schema affordance (generated_at) is asserted above; full "
"publisher coverage requires the registry wire conformance "
- "module which is not in v0.1 scope."
+ "module which remains outside this revision's scope."
)
diff --git a/tests/spec_conformance/test_manifest_reqs.py b/tests/spec_conformance/test_manifest_reqs.py
index a4c12ad879..a308035c97 100644
--- a/tests/spec_conformance/test_manifest_reqs.py
+++ b/tests/spec_conformance/test_manifest_reqs.py
@@ -179,19 +179,6 @@ def test_producer_rejects_unknown_registries_keys():
validate_against("manifest-v0.1.schema.json", doc)
-@pytest.mark.req("req-mf-016")
-def test_consumer_rejects_absolute_paths_in_apm_source():
- """Spec restricts apm-source `path:` to relative form."""
- assert_spec_contains("path")
- waive(
- "Path-shape negative test requires apm_cli's path-policy loader "
- "to be invokable from the test harness; the JSON Schema currently "
- "models `path` as a free-form string. Tracked as a follow-up: "
- "tighten the schema to forbid leading `/` and document the "
- "absolute-path rejection in the schema additionalProperties."
- )
-
-
@pytest.mark.req("req-mf-017")
def test_producer_publishes_apm_yml_at_repo_root():
assert_spec_contains("apm.yml")
@@ -204,6 +191,18 @@ def test_consumer_restricts_policy_hash_algorithm_to_strong_set():
assert set(enum) == {"sha256", "sha384", "sha512"}
+@pytest.mark.req("req-mf-018")
+@pytest.mark.parametrize("digest", ["not-a-digest", "sha256:" + "a" * 63])
+def test_retained_schema_accepts_invalid_policy_hash_without_semantic_evidence(
+ digest: str,
+) -> None:
+ """Structural acceptance is a schema limitation, not consumer hash enforcement."""
+ document = load_yaml_fixture("manifest", "valid-minimal.yml")
+ document["policy"] = {"hash_algorithm": "sha256", "hash": digest}
+ validate_against("manifest-v0.1.schema.json", document)
+ assert_spec_contains('id="req-mf-018"', 'id="req-lk-016"', "`policy.hash`")
+
+
@pytest.mark.req("req-mf-019")
def test_consumer_supports_default_host_field():
schema = load_schema("manifest-v0.1.schema.json")
@@ -254,9 +253,13 @@ def test_consumer_resolves_runtime_argument_templates_without_secret_leakage():
@pytest.mark.req("req-mf-021")
-def test_producer_workspaces_must_not_use_in_v0_1():
- """req-mf-021 forbids workspaces in v0.1."""
- assert_spec_contains("workspaces", "v0.1")
+def test_producer_workspaces_remain_reserved():
+ """The corrective revision does not activate the old future-version promise."""
+ assert_spec_contains(
+ "**producer** MUST NOT\ndeclare a top-level `workspaces:` key",
+ "reserved for a future revision and MUST NOT attach any semantics",
+ "The diagnostic MUST NOT fail install.",
+ )
@pytest.mark.req("req-mf-022")
diff --git a/tests/spec_conformance/test_mode_b_detector.py b/tests/spec_conformance/test_mode_b_detector.py
index 43586f7615..5db9a3825e 100644
--- a/tests/spec_conformance/test_mode_b_detector.py
+++ b/tests/spec_conformance/test_mode_b_detector.py
@@ -17,11 +17,18 @@
import os
import shutil
import subprocess
+import sys
from pathlib import Path
import pytest
-REPO_ROOT = Path(__file__).resolve().parents[2]
+from tests.spec_conformance._manifest import (
+ MANIFEST_PATH,
+ REPO_ROOT,
+ SCHEMA_PATH,
+ SPEC_PATH,
+)
+
DETECTOR = REPO_ROOT / "tests" / "spec_conformance" / "mode_b_detector.sh"
PATHS_FILE = REPO_ROOT / "tests" / "spec_conformance" / "critical_paths.txt"
WORKFLOW = REPO_ROOT / ".github" / "workflows" / "spec-conformance.yml"
@@ -74,16 +81,16 @@ def test_critical_paths_file_lists_known_directories():
)
-def test_detector_short_circuits_on_spec_concurrent_edit(tmp_path):
+@pytest.mark.parametrize("selected_path", [SPEC_PATH, MANIFEST_PATH])
+def test_detector_short_circuits_on_spec_concurrent_edit(tmp_path, selected_path):
"""A PR that edits the spec body MUST short-circuit (exit 0)."""
repo = _make_repo(tmp_path)
# Add a substantive critical-path change AND a spec edit.
(repo / "src" / "apm_cli" / "deps" / "new.py").write_text(
"\n".join(f"x = {i}" for i in range(40)) + "\n"
)
- spec = repo / "docs" / "src" / "content" / "docs" / "specs"
- spec.mkdir(parents=True, exist_ok=True)
- (spec / "openapm-v0.1.md").write_text("placeholder spec edit\n")
+ selected = repo / selected_path.relative_to(REPO_ROOT)
+ selected.write_text(selected.read_text() + "\n# Informative draft note\n")
_git(repo, "add", "-A")
_git(repo, "commit", "-m", "feature + spec edit")
out = _run_detector(repo)
@@ -91,6 +98,18 @@ def test_detector_short_circuits_on_spec_concurrent_edit(tmp_path):
assert "spec-concurrent edit detected" in out.stdout, out.stdout
+def test_retained_previous_minor_does_not_satisfy_active_spec_citation(tmp_path):
+ """Changing only the previous minor cannot cite the active assessment."""
+ repo = _make_repo(tmp_path)
+ (repo / "src/apm_cli/deps/new.py").write_text("\n".join(f"x = {i}" for i in range(40)) + "\n")
+ (repo / "docs/src/content/docs/specs/openapm-v0.1.md").write_text("old history\n")
+ _git(repo, "add", "-A")
+ _git(repo, "commit", "-m", "critical change with old-only notice")
+ result = _run_detector(repo)
+ assert result.returncode == 1
+ assert "Mode B detector" in result.stdout
+
+
def test_detector_short_circuits_on_new_top_level_req_marker(tmp_path):
"""A new column-0 ``@pytest.mark.req`` marker MUST short-circuit (exit 0).
@@ -167,6 +186,26 @@ def test_detector_fires_on_substantive_critical_path_add(tmp_path):
assert "apm-spec-waiver" in out.stdout
+def test_detector_controls_ignore_ambient_gate_overrides(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """A host's gate configuration must not change the synthetic control."""
+ repo = _make_repo(tmp_path)
+ (repo / "src/apm_cli/deps/new.py").write_text("\n".join(f"x = {i}" for i in range(40)) + "\n")
+ _git(repo, "add", "-A")
+ _git(repo, "commit", "-m", "uncited critical change")
+ for name, value in {
+ "BASE_REF": "missing-base",
+ "MODE_B_THRESHOLD": "99999",
+ "GH_PR_BODY": "apm-spec-waiver: ambient waiver must not apply",
+ "GITHUB_ACTIONS": "true",
+ }.items():
+ monkeypatch.setenv(name, value)
+ result = _run_detector(repo)
+ assert result.returncode == 1
+ assert "Mode B detector" in result.stdout
+
+
def test_detector_respects_waiver_trailer(tmp_path):
"""A commit with an `apm-spec-waiver:` trailer MUST pass."""
repo = _make_repo(tmp_path)
@@ -255,6 +294,10 @@ def test_workflow_checkout_uses_full_history():
)
+def test_workflow_triggers_on_public_spec_assets():
+ assert "'docs/public/specs/**'" in WORKFLOW.read_text()
+
+
def test_workflow_invokes_detector_after_orphan_check():
"""The CI workflow MUST wire the detector as a step."""
body = WORKFLOW.read_text()
@@ -317,6 +360,16 @@ def _make_repo(tmp_path: Path) -> Path:
shutil.copy2(DETECTOR, repo / "tests" / "spec_conformance" / "mode_b_detector.sh")
(repo / "tests" / "spec_conformance" / "mode_b_detector.sh").chmod(0o755)
shutil.copy2(PATHS_FILE, repo / "tests" / "spec_conformance" / "critical_paths.txt")
+ for relative in ("tests/__init__.py", "tests/spec_conformance/__init__.py"):
+ (repo / relative).write_text("")
+ shutil.copy2(
+ REPO_ROOT / "tests/spec_conformance/_manifest.py",
+ repo / "tests/spec_conformance/_manifest.py",
+ )
+ for source in (SPEC_PATH, MANIFEST_PATH, SCHEMA_PATH):
+ destination = repo / source.relative_to(REPO_ROOT)
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(source, destination)
_git(repo, "add", "-A")
_git(repo, "commit", "-m", "base", "--quiet")
# Create a feature branch and an origin/main reference the detector
@@ -328,27 +381,14 @@ def _make_repo(tmp_path: Path) -> Path:
def _run_detector(repo: Path) -> subprocess.CompletedProcess:
- env = {**os.environ, "BASE_REF": "origin/main"}
- # Disable the env-var waiver path; we test commit-trailer waivers
- # by leaving GH_PR_BODY unset.
- env.pop("GH_PR_BODY", None)
- return subprocess.run(
- ["bash", "tests/spec_conformance/mode_b_detector.sh"],
- cwd=repo,
- env=env,
- capture_output=True,
- text=True,
- check=False,
- )
+ return _run_detector_with_env(repo, BASE_REF="origin/main")
def _run_detector_with_env(repo: Path, **overrides: str) -> subprocess.CompletedProcess:
- """Run the detector with explicit env overrides. GH_PR_BODY is
- cleared so only the supplied vars drive behaviour."""
- env = {**os.environ}
- env.pop("GH_PR_BODY", None)
- # Strip any ambient GITHUB_ACTIONS so callers control it explicitly.
- env.pop("GITHUB_ACTIONS", None)
+ """Only explicit overrides may configure the synthetic detector."""
+ env = {**os.environ, "PYTHON": sys.executable, "PYTHONPATH": str(repo)}
+ for name in ("GH_PR_BODY", "GITHUB_ACTIONS", "BASE_REF", "MODE_B_THRESHOLD"):
+ env.pop(name, None)
env.update(overrides)
return subprocess.run(
["bash", "tests/spec_conformance/mode_b_detector.sh"],
diff --git a/tests/spec_conformance/test_registry_reqs.py b/tests/spec_conformance/test_registry_reqs.py
index 6144e1dd6f..d653f56b80 100644
--- a/tests/spec_conformance/test_registry_reqs.py
+++ b/tests/spec_conformance/test_registry_reqs.py
@@ -2,8 +2,8 @@
req-rg-001 (trust anchor): the SHA-256 of the archive bytes the
Registry serves MUST equal the digest the Registry advertises for
-that version. This is the ONE substantive normative statement v0.1
-places on Registry implementations; the rest is reserved for v0.2.
+that version. This is the ONE substantive normative statement this revision
+places on Registry implementations; the broader wire contract remains reserved.
The fixture `integrity/security-baseline-2.3.1.tar.gz` simulates
a Registry's published archive; its paired
diff --git a/tests/spec_conformance/test_resolution_reqs.py b/tests/spec_conformance/test_resolution_reqs.py
index 57c1aaca72..38e6aa74ef 100644
--- a/tests/spec_conformance/test_resolution_reqs.py
+++ b/tests/spec_conformance/test_resolution_reqs.py
@@ -180,18 +180,18 @@ def test_resolver_records_resolved_ref_in_lockfile():
@pytest.mark.req("req-rs-013")
def test_resolver_fails_closed_on_ambiguous_resolution():
- """`conflict_resolution: nest` MUST be rejected in v0.1."""
+ """`conflict_resolution: nest` remains refused in the corrective revision."""
assert_spec_contains(
"conflict_resolution: nest",
- "reserved for v0.2",
+ "reserved for a future revision",
)
# Schema enum pin (round-3 fold): the manifest schema MUST admit
- # only `intersection-pick` in v0.1; `nest` is reserved for v0.2.
+ # only `intersection-pick`; `nest` remains reserved.
schema = load_schema("manifest-v0.1.schema.json")
enum = schema["$defs"]["depsBlock"]["properties"]["conflict_resolution"]["enum"]
assert enum == ["intersection-pick"], (
f"manifest schema conflict_resolution enum MUST be exactly "
- f"['intersection-pick'] in v0.1; got {enum!r}"
+ f"['intersection-pick']; got {enum!r}"
)
diff --git a/tests/spec_conformance/test_spec_version_contract.py b/tests/spec_conformance/test_spec_version_contract.py
new file mode 100644
index 0000000000..5ca3958810
--- /dev/null
+++ b/tests/spec_conformance/test_spec_version_contract.py
@@ -0,0 +1,273 @@
+"""Version identity, preservation, and fresh-evidence boundary regressions."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import re
+import subprocess
+from pathlib import Path
+from urllib.parse import urlparse
+
+import pytest
+import yaml
+
+from tests.spec_conformance import _manifest, gen_statement, orphan_check
+from tests.spec_conformance._helpers import spec_text
+
+pytestmark = pytest.mark.component
+
+PRESERVATION_BASE = "f8df1b751efc30b32dc01b125616b51f777b4c81"
+PRESERVED_DIGESTS = {
+ "docs/src/content/docs/specs/openapm-v0.1.md": (
+ "e1178aaa3f23eb1de76578dab63660d319f3695d593742a3ca3b226236524698"
+ ),
+ "docs/public/specs/manifests/openapm-v0.1.requirements.yml": (
+ "034b9ede42be09ec53d4b8b6843003121a92bc7c3015341e8eb828fe9c9f5f2c"
+ ),
+ "docs/public/specs/schemas/lockfile-v0.1.schema.json": (
+ "6c0dca9e7994035b55da17340b1f9f6c6673501c63ebd947400cf472d5723560"
+ ),
+ "docs/public/specs/schemas/manifest-v0.1.schema.json": (
+ "7bdefbe443d3315d71add021c777d776c9cfd4942acb19750a799f46fa0d1344"
+ ),
+ "docs/public/specs/schemas/policy-v0.1.schema.json": (
+ "577d7ef2aa1f84d280074f1713258afdd3f8bfb96c4b3b7e5b8ac58e0bb724bb"
+ ),
+ "docs/public/specs/schemas/requirements-v0.1.schema.json": (
+ "5e294eef59498538efbe3b5f9ba54423f5d21dd07dc174d9588e6f15cb8b5d6f"
+ ),
+}
+
+
+@pytest.mark.parametrize("relative,digest", PRESERVED_DIGESTS.items())
+def test_previous_minor_bytes_are_preserved(relative: str, digest: str) -> None:
+ """Pin the actual main baseline without rewriting its immutable contracts."""
+ content = (_manifest.REPO_ROOT / relative).read_bytes()
+ assert hashlib.sha256(content).hexdigest() == digest, PRESERVATION_BASE
+
+
+def test_selected_identity_citation_and_exact_content_route_agree() -> None:
+ selection = _manifest.selected_assessment()
+ assert selection.version == _manifest.load_manifest_raw()["spec_version"]
+ assert urlparse(selection.citation).path == f"/apm/spec/{selection.version}"
+ assert selection.version in selection.coverage_path.name
+ config = (_manifest.REPO_ROOT / "docs/astro.config.mjs").read_text()
+ redirects = dict(re.findall(r"'(/spec[^']*)': '([^']+)'", config))
+ assert urlparse(redirects[f"/spec/{selection.version}"]).path == (
+ f"/apm/specs/openapm-{selection.version.replace('.', '')}/"
+ )
+ assert redirects["/spec/latest"] == redirects["/spec/v0.1"]
+ assert redirects["/spec"] == redirects["/spec/v0.1"]
+
+
+def test_every_exact_citation_resolves_to_its_own_revision() -> None:
+ """Overwriting the minor-line file for a later patch must not strand an exact pin."""
+ config = (_manifest.REPO_ROOT / "docs/astro.config.mjs").read_text()
+ exact_routes = re.findall(r"'(/spec/v[0-9]+\.[0-9]+\.[0-9]+)': '([^']+)'", config)
+ artifacts = {}
+ for path in _manifest.SPEC_DIR.glob("openapm-*.md"):
+ frontmatter = yaml.safe_load(path.read_text().split("---\n", 2)[1])
+ if "slug" in frontmatter:
+ artifacts[f"/apm/{frontmatter['slug']}/"] = frontmatter["title"]
+ assert exact_routes
+ for source, destination in exact_routes:
+ version = urlparse(source).path.rsplit("/", 1)[-1]
+ assert artifacts[urlparse(destination).path] == f"OpenAPM {version}"
+
+
+@pytest.mark.parametrize(
+ "old,new",
+ [
+ ("title: OpenAPM v0.2.0", "title: OpenAPM v0.1"),
+ ("slug: specs/openapm-v020", "slug: specs/openapm-v02"),
+ ],
+)
+def test_artifact_identity_mismatch_is_rejected(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, old: str, new: str
+) -> None:
+ artifact = tmp_path / "selected.md"
+ artifact.write_text(_manifest.SPEC_PATH.read_text().replace(old, new), encoding="utf-8")
+ monkeypatch.setattr(_manifest, "SPEC_PATH", artifact)
+ with pytest.raises(ValueError, match=r"identity|exact-revision"):
+ _manifest.selected_assessment()
+
+
+@pytest.mark.parametrize("version", ["v0.1.39", "v0.2"])
+def test_manifest_must_select_an_exact_revision_of_the_active_minor(
+ monkeypatch: pytest.MonkeyPatch, version: str
+) -> None:
+ raw = {**_manifest.load_manifest_raw(), "spec_version": version}
+ monkeypatch.setattr(_manifest, "load_manifest_raw", lambda: raw)
+ with pytest.raises(ValueError, match=r"exact specification|minor artifact"):
+ _manifest.selected_assessment()
+
+
+@pytest.mark.parametrize("field", ["spec_version", "spec_sha256", "manifest_sha256"])
+def test_mismatched_or_stale_coverage_is_rejected(tmp_path: Path, field: str) -> None:
+ document = _manifest.coverage_document({})
+ document[field] = "stale"
+ path = tmp_path / "coverage.json"
+ path.write_text(json.dumps(document))
+ with pytest.raises(ValueError, match="fingerprints"):
+ _manifest.load_coverage(path)
+
+
+def test_unversioned_old_coverage_is_rejected(tmp_path: Path) -> None:
+ path = tmp_path / "old-coverage.json"
+ path.write_text(json.dumps({"req-mf-016": [{"test_nodeid": "old", "status": "active"}]}))
+ with pytest.raises(ValueError, match="identity"):
+ _manifest.load_coverage(path)
+
+
+@pytest.mark.parametrize("exit_code,write_output", [(1, True), (2, False), (0, False)])
+def test_failed_or_incomplete_collection_cannot_reuse_coverage(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ exit_code: int,
+ write_output: bool,
+) -> None:
+ prior = tmp_path / "old.json"
+ prior.write_text(json.dumps(_manifest.coverage_document({})))
+ monkeypatch.setenv(_manifest.COVERAGE_ENV, str(prior))
+
+ def collect(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
+ env = kwargs["env"]
+ assert isinstance(env, dict)
+ output = Path(env[_manifest.COVERAGE_ENV])
+ assert output != prior
+ if write_output:
+ output.write_text(json.dumps(_manifest.coverage_document({})))
+ return subprocess.CompletedProcess(command, exit_code, "collection details", "failed")
+
+ monkeypatch.setattr(_manifest.subprocess, "run", collect)
+ with pytest.raises(RuntimeError, match=r"collection failed|no binding inventory"):
+ _manifest.collect_coverage()
+ assert prior.is_file()
+
+
+def test_every_collection_is_fresh_and_ignores_ambient_test_selectors(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setenv("PYTEST_ADDOPTS", "-k no_tests --lf")
+ outputs: list[Path] = []
+
+ def collect(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
+ env = kwargs["env"]
+ assert isinstance(env, dict)
+ assert "PYTEST_ADDOPTS" not in env
+ assert command[3:5] == ["tests/spec_conformance", "--collect-only"]
+ assert "addopts=" in command
+ output = Path(env[_manifest.COVERAGE_ENV])
+ assert not output.exists()
+ outputs.append(output)
+ output.write_text(json.dumps(_manifest.coverage_document({})))
+ return subprocess.CompletedProcess(command, 0, "", "")
+
+ monkeypatch.setattr(_manifest.subprocess, "run", collect)
+ assert _manifest.collect_coverage() == {}
+ assert _manifest.collect_coverage() == {}
+ assert len(set(outputs)) == 2
+ assert all(not path.exists() for path in outputs)
+
+
+def test_generator_refuses_collection_failure_without_overwriting_outputs(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ json_path, md_path = tmp_path / "report.json", tmp_path / "report.md"
+ json_path.write_bytes(b"original-json")
+ md_path.write_bytes(b"original-md")
+ monkeypatch.setattr(gen_statement, "CONFORMANCE_JSON", json_path)
+ monkeypatch.setattr(gen_statement, "CONFORMANCE_MD", md_path)
+
+ def fail() -> _manifest.Coverage:
+ raise RuntimeError("collection failed")
+
+ monkeypatch.setattr(gen_statement, "collect_coverage", fail)
+ assert gen_statement.main() == 2
+ assert json_path.read_bytes() == b"original-json"
+ assert md_path.read_bytes() == b"original-md"
+
+
+def test_orphan_check_refuses_failed_collection(monkeypatch: pytest.MonkeyPatch) -> None:
+ def fail() -> _manifest.Coverage:
+ raise RuntimeError("collection failed despite a stale map")
+
+ monkeypatch.setattr(orphan_check, "collect_coverage", fail)
+ assert orphan_check.main() == 2
+
+
+def test_generator_requires_complete_current_bindings(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(gen_statement, "collect_coverage", lambda: {})
+ with pytest.raises(ValueError, match="four-way bind"):
+ gen_statement.build_json()
+
+
+def test_reserved_features_are_not_activated_by_the_new_minor() -> None:
+ text = spec_text().split("## Appendix D.", 1)[0]
+ for phrase in (
+ "**producer** MUST NOT\ndeclare a top-level `workspaces:` key",
+ "MUST refuse the install",
+ "Readers MUST accept bare 64-character",
+ "one of `sha256`, `sha384`, or `sha512`",
+ "The frozen-install operation is opt-in in\nthis revision",
+ "broader wire contract remains reserved",
+ "manifest remains informative in this revision",
+ "cryptographic publisher provenance and does not activate",
+ ):
+ assert phrase in text
+ assert not re.search(r"v0\.2 (?:will|introduces|normative)", text)
+ assert "reserved-for-v02" not in text
+
+
+def test_report_identity_and_links_follow_the_selected_assessment() -> None:
+ document = gen_statement.build_json()
+ selection = _manifest.selected_assessment()
+ assert document["spec_version"] == selection.version
+ assert document["inventory_kind"] == "static-test-bindings"
+ assert document["assessment_status"] == "DRAFT"
+ assert document["human_ratification"] == "UNSATISFIED"
+ assert document["activation"] == "UNSATISFIED"
+ assert urlparse(document["spec_citation"]) == urlparse(selection.citation)
+ markdown = gen_statement.build_md(document)
+ assert "not executed test results or a runtime pass certificate" in markdown
+ assert "Human ratification and activation are UNSATISFIED" in markdown
+ requirement_links = re.findall(r"\[req-[^\]]+\]\(([^)]+)\)", markdown)
+ assert len(requirement_links) == len(document["requirements"])
+ assert {urlparse(link).path for link in requirement_links} == {document["spec_path"]}
+
+
+def test_corrective_revision_adds_only_the_approved_audit_requirement() -> None:
+ old = (_manifest.SPEC_DIR / "openapm-v0.1.md").read_text()
+ current = spec_text()
+ pattern = r''
+ old_ids, new_ids = set(re.findall(pattern, old)), re.findall(pattern, current)
+ assert len(old_ids) == 122
+ assert "req-pl-018" in old_ids
+ assert len(new_ids) == 123
+ assert len(new_ids) == len(set(new_ids))
+ assert set(new_ids) == old_ids | {"req-lk-023"}
+ assert len(new_ids) == len(_manifest.load_requirements())
+ assert re.search(
+ rf"\*\*{len(new_ids)} normative statements \(118 MUST, 5 SHOULD\)\*\*", current
+ )
+ assert f"**Total normative statements: {len(new_ids)}**" in current
+
+
+def test_corrective_revision_preserves_current_main_amendment_process() -> None:
+ """The new assessment cannot restore a stale version of Section 9."""
+ old = (_manifest.SPEC_DIR / "openapm-v0.1.md").read_text()
+ heading = "## 9. Versioning and amendment process"
+ following = "## 10. Security considerations"
+ previous_process = old.split(heading, 1)[1].split(following, 1)[0]
+ current_process = spec_text().split(heading, 1)[1].split(following, 1)[0]
+ assert current_process == previous_process
+
+
+def test_binding_inventory_discloses_unresolved_bare_audit_conformance_limit() -> None:
+ """A new assessed version cannot turn a known implementation gap into a pass."""
+ document = gen_statement.build_json()
+ assert document["assessment_limitations"] == gen_statement.ASSESSMENT_LIMITATIONS
+ markdown = gen_statement.build_md(document)
+ assert "does not claim full Consumer conformance in bare audit mode" in markdown
+ assert "controlled pre-existing standalone-skill" in markdown
diff --git a/tests/test_apm_resolver.py b/tests/test_apm_resolver.py
index e13f1699e0..aa660f2295 100644
--- a/tests/test_apm_resolver.py
+++ b/tests/test_apm_resolver.py
@@ -480,8 +480,8 @@ def test_dependency_graph_error_handling(self):
# ===========================================================================
-class TestIsRemoteParentHeuristic(unittest.TestCase):
- """_is_remote_parent must NOT misclassify _local/ as remote (#940)."""
+class TestIsRemoteParentProvenance(unittest.TestCase):
+ """Explicit acquisition provenance, not location spelling, controls the backstop."""
def setUp(self):
from apm_cli.deps.apm_resolver import APMDependencyResolver
@@ -493,6 +493,7 @@ def test_local_underscore_prefix_is_local(self):
pkg = APMPackage(name="specialized", version="1.0.0")
pkg.source = "_local/specialized"
+ pkg.proven_source_kind = "local"
self.assertFalse(self.resolver._is_remote_parent(pkg))
def test_owner_repo_slash_is_remote(self):
@@ -502,11 +503,11 @@ def test_owner_repo_slash_is_remote(self):
pkg.source = "microsoft/apm-sample-package"
self.assertTrue(self.resolver._is_remote_parent(pkg))
- def test_no_source_is_local(self):
+ def test_no_provenance_requires_backstop(self):
from apm_cli.models.apm_package import APMPackage
pkg = APMPackage(name="root", version="1.0.0")
- self.assertFalse(self.resolver._is_remote_parent(pkg))
+ self.assertTrue(self.resolver._is_remote_parent(pkg))
class TestSignatureFallback(unittest.TestCase):
@@ -591,6 +592,7 @@ def test_local_parent_local_path_not_rejected_or_tracked(self):
resolver = APMDependencyResolver(apm_modules_dir=Path(tmpdir))
local_parent = APMPackage(name="specialized", version="1.0.0")
local_parent.source = "_local/specialized"
+ local_parent.proven_source_kind = "local"
local_dep = DependencyReference(
repo_url="",
diff --git a/tests/unit/core/test_output_mode.py b/tests/unit/core/test_output_mode.py
index 2136084065..c7bfb5bde6 100644
--- a/tests/unit/core/test_output_mode.py
+++ b/tests/unit/core/test_output_mode.py
@@ -10,25 +10,30 @@
from apm_cli.policy.discovery import PolicyFetchResult
+pytestmark = pytest.mark.component
+
@pytest.mark.parametrize(
- "args",
+ ("args", "expects_update"),
[
- ["policy", "status", "--output=json"],
- ["policy", "status", "-ojson"],
- ["audit", "--ci", "--no-drift", "--no-policy", "--format=json"],
- ["audit", "--ci", "--no-drift", "--no-policy", "-fjson"],
- ["--verbose", "policy", "status", "--output", "json"],
+ (["policy", "status", "--output=json"], True),
+ (["policy", "status", "-ojson"], True),
+ (["audit", "--ci", "--no-drift", "--no-policy", "--format=json"], False),
+ (["audit", "--ci", "--no-drift", "--no-policy", "-fjson"], False),
+ (["--verbose", "policy", "status", "--output", "json"], True),
],
)
-def test_machine_output_keeps_update_notice_off_stdout(args: list[str]) -> None:
+def test_machine_output_keeps_update_notice_off_stdout(
+ args: list[str], expects_update: bool, monkeypatch: pytest.MonkeyPatch
+) -> None:
"""Every Click spelling must leave stdout as one parseable JSON document."""
from apm_cli.cli import cli
+ monkeypatch.delenv("APM_E2E_TESTS", raising=False)
with (
patch("apm_cli.commands._helpers.is_self_update_enabled", return_value=True),
patch("apm_cli.commands._helpers.get_version", return_value="1.0.0"),
- patch("apm_cli.commands._helpers.check_for_updates", return_value="2.0.0"),
+ patch("apm_cli.commands._helpers.check_for_updates", return_value="2.0.0") as check_updates,
patch(
"apm_cli.commands.policy.discover_policy_with_chain",
return_value=PolicyFetchResult(outcome="absent"),
@@ -39,7 +44,12 @@ def test_machine_output_keeps_update_notice_off_stdout(args: list[str]) -> None:
assert result.exception is None, result.output
json.loads(result.stdout)
- assert "A new version of APM is available" in result.stderr
+ if expects_update:
+ check_updates.assert_called_once_with("1.0.0")
+ assert "A new version of APM is available" in result.stderr
+ else:
+ check_updates.assert_not_called()
+ assert "A new version of APM is available" not in result.stderr
assert "A new version of APM is available" not in result.stdout
diff --git a/tests/unit/deps/test_apm_resolver_edge_cases.py b/tests/unit/deps/test_apm_resolver_edge_cases.py
index 977bb8fa7c..0f338a268b 100644
--- a/tests/unit/deps/test_apm_resolver_edge_cases.py
+++ b/tests/unit/deps/test_apm_resolver_edge_cases.py
@@ -562,14 +562,15 @@ class TestIsRemoteParent:
def test_none_parent_returns_false(self) -> None:
assert APMDependencyResolver._is_remote_parent(None) is False
- def test_parent_with_no_source_returns_false(self) -> None:
+ def test_parent_with_unknown_provenance_requires_backstop(self) -> None:
pkg = MagicMock()
pkg.source = None
- assert APMDependencyResolver._is_remote_parent(pkg) is False
+ assert APMDependencyResolver._is_remote_parent(pkg) is True
def test_local_prefix_returns_false(self) -> None:
pkg = MagicMock()
pkg.source = "_local/mypkg"
+ pkg.proven_source_kind = "local"
assert APMDependencyResolver._is_remote_parent(pkg) is False
def test_https_source_returns_true(self) -> None:
@@ -590,11 +591,13 @@ def test_owner_repo_shorthand_returns_true(self) -> None:
def test_relative_local_path_returns_false(self) -> None:
pkg = MagicMock()
pkg.source = "../relative/path"
+ pkg.proven_source_kind = "local"
assert APMDependencyResolver._is_remote_parent(pkg) is False
def test_absolute_local_path_returns_false(self) -> None:
pkg = MagicMock()
pkg.source = "/abs/local/path"
+ pkg.proven_source_kind = "local"
assert APMDependencyResolver._is_remote_parent(pkg) is False
diff --git a/tests/unit/deps/test_apm_resolver_phase3.py b/tests/unit/deps/test_apm_resolver_phase3.py
index e846febd09..fa8963e482 100644
--- a/tests/unit/deps/test_apm_resolver_phase3.py
+++ b/tests/unit/deps/test_apm_resolver_phase3.py
@@ -436,14 +436,15 @@ class TestIsRemoteParent:
def test_none_parent_returns_false(self) -> None:
assert APMDependencyResolver._is_remote_parent(None) is False
- def test_parent_with_no_source_returns_false(self) -> None:
+ def test_parent_with_unknown_provenance_requires_backstop(self) -> None:
pkg = MagicMock()
pkg.source = None
- assert APMDependencyResolver._is_remote_parent(pkg) is False
+ assert APMDependencyResolver._is_remote_parent(pkg) is True
def test_local_prefix_returns_false(self) -> None:
pkg = MagicMock()
pkg.source = "_local/mypkg"
+ pkg.proven_source_kind = "local"
assert APMDependencyResolver._is_remote_parent(pkg) is False
def test_https_source_returns_true(self) -> None:
@@ -464,11 +465,13 @@ def test_owner_repo_shorthand_returns_true(self) -> None:
def test_relative_local_path_returns_false(self) -> None:
pkg = MagicMock()
pkg.source = "../relative/path"
+ pkg.proven_source_kind = "local"
assert APMDependencyResolver._is_remote_parent(pkg) is False
def test_absolute_local_path_returns_false(self) -> None:
pkg = MagicMock()
pkg.source = "/abs/local/path"
+ pkg.proven_source_kind = "local"
assert APMDependencyResolver._is_remote_parent(pkg) is False
diff --git a/tests/unit/deps/test_declaring_source_provenance.py b/tests/unit/deps/test_declaring_source_provenance.py
new file mode 100644
index 0000000000..514b0b6a08
--- /dev/null
+++ b/tests/unit/deps/test_declaring_source_provenance.py
@@ -0,0 +1,169 @@
+"""Acquisition provenance is transient context, never package-controlled spelling."""
+
+from __future__ import annotations
+
+import shutil
+from pathlib import Path
+from unittest.mock import MagicMock
+
+import pytest
+
+from apm_cli.core.scope import InstallScope
+from apm_cli.deps.apm_resolver import APMDependencyResolver
+from apm_cli.install.context import InstallContext
+from apm_cli.install.resolution_staging import ResolutionStagingSession
+from apm_cli.install.sources import LocalDependencySource
+from apm_cli.models.apm_package import APMPackage
+from apm_cli.models.dependency import DependencyReference
+from apm_cli.utils.diagnostics import DiagnosticCollector
+from apm_cli.utils.yaml_io import dump_yaml
+from tests.utils.local_package import LocalPackageFactory
+
+pytestmark = pytest.mark.component
+
+
+@pytest.mark.parametrize("kind", ["local", "git", "registry"])
+@pytest.mark.parametrize("layout", ["manifest", "skill", "native", "marketplace"])
+def test_every_loaded_layout_retains_acquisition_kind(
+ tmp_path: Path, kind: str, layout: str
+) -> None:
+ """All resolver return paths carry actual origin through staging activation."""
+ source = tmp_path / "source"
+ source.mkdir()
+ if layout == "manifest":
+ dump_yaml({"name": "package", "version": "1.0.0"}, source / "apm.yml")
+ elif layout == "skill":
+ (source / "SKILL.md").write_text(
+ "---\nname: package\ndescription: Fixture\n---\nContent\n", encoding="ascii"
+ )
+ else:
+ fixtures = Path(__file__).resolve().parents[2] / "fixtures"
+ fixture = (
+ fixtures / "agent_plugins" / "portable"
+ if layout == "native"
+ else fixtures / "mock-marketplace-plugin"
+ )
+ shutil.copytree(fixture, source, dirs_exist_ok=True)
+ ref = (
+ DependencyReference.parse(source.as_posix())
+ if kind == "local"
+ else DependencyReference.parse_from_dict(
+ {"git": "https://gitlab.example.invalid/_local/parent", "ref": "v1"}
+ )
+ )
+ if kind == "registry":
+ ref.source = "registry"
+ modules = tmp_path / "modules"
+ modules.mkdir()
+ staging = ResolutionStagingSession(modules)
+
+ def download(dep: DependencyReference, root: Path, parent_chain: str = "") -> Path:
+ destination = staging.prepare_replacement(dep.get_install_path(root))
+ shutil.copytree(source, destination)
+ return destination
+
+ resolver = APMDependencyResolver(
+ apm_modules_dir=modules,
+ download_callback=download,
+ activation_callback=staging.publish_replacement,
+ )
+ try:
+ loaded = resolver._try_load_dependency_package(ref)
+ assert loaded is not None
+ assert loaded.proven_source_kind == kind
+ assert APMDependencyResolver._is_remote_parent(loaded) is (kind != "local")
+ assert loaded.package_path == ref.get_install_path(modules)
+ assert loaded.source_path == (source if kind == "local" else ref.get_install_path(modules))
+ finally:
+ staging.rollback()
+
+
+def test_provenance_projection_does_not_poison_manifest_cache(tmp_path: Path) -> None:
+ """The same cached manifest cannot gain authority from a previous acquisition."""
+ package = LocalPackageFactory(tmp_path).create("parent")
+ dump_yaml(
+ {
+ "name": "parent",
+ "version": "1.0.0",
+ "source": "_local/forged",
+ "proven_source_kind": "local",
+ },
+ package.manifest_path,
+ )
+ cached = APMPackage.from_apm_yml(package.manifest_path, source_path=package.root)
+ local_ref = DependencyReference.parse(package.root.as_posix())
+ remote_ref = DependencyReference.parse_from_dict(
+ {"git": "https://gitlab.example.invalid/_local/parent"}
+ )
+ resolver = APMDependencyResolver()
+ local = resolver._activate_validated_package(cached, None, True, local_ref)
+ remote = resolver._activate_validated_package(cached, None, True, remote_ref)
+ assert local.proven_source_kind == "local"
+ assert remote.proven_source_kind == "git"
+ assert cached.proven_source_kind is None
+ tree = resolver.build_dependency_tree(package.root, root_package=cached)
+ assert tree.root_package.proven_source_kind == "local"
+ assert (
+ APMPackage.from_apm_yml(package.manifest_path, source_path=package.root).proven_source_kind
+ is None
+ )
+ assert local is not remote and local is not cached and remote is not cached
+
+
+@pytest.mark.parametrize("scope", [InstallScope.PROJECT, InstallScope.USER])
+@pytest.mark.parametrize("kind", ["git", "registry", "unknown"])
+@pytest.mark.parametrize("absolute", [False, True], ids=["relative", "absolute"])
+@pytest.mark.parametrize("boundary", ["loader", "acquire"])
+def test_boundaries_refuse_nonlocal_declaring_context(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ scope: InstallScope,
+ kind: str,
+ absolute: bool,
+ boundary: str,
+) -> None:
+ """Both backstops refuse remote/unknown parents before local acquisition."""
+ factory = LocalPackageFactory(tmp_path / "packages")
+ child = factory.create("child")
+ parent = factory.create("parent")
+ parent_ref = DependencyReference.parse_from_dict(
+ {"git": "https://gitlab.example.invalid/_local/parent", "ref": "v1"}
+ )
+ if kind == "registry":
+ parent_ref.source = "registry"
+ parent_pkg = APMPackage.from_apm_yml(parent.manifest_path, source_path=parent.root)
+ if kind != "unknown":
+ parent_pkg = APMDependencyResolver()._activate_validated_package(
+ parent_pkg, None, True, parent_ref
+ )
+ child_ref = DependencyReference.parse(child.root.as_posix() if absolute else "../child")
+ child_ref.declaring_parent = parent_ref.get_unique_key()
+ child_ref.anchored_local_path = child.root.as_posix()
+ graph = MagicMock()
+ graph.dependency_tree.get_node.return_value.parent.package = parent_pkg
+ ctx = InstallContext(
+ project_root=tmp_path,
+ apm_dir=tmp_path,
+ scope=scope,
+ dependency_graph=graph,
+ diagnostics=DiagnosticCollector(),
+ )
+ ctx.dep_base_dirs = {child_ref.get_unique_key(): parent.root}
+ copy = MagicMock(side_effect=AssertionError("Untrusted origin reached filesystem copy"))
+ monkeypatch.setattr("apm_cli.install.phases.local_content._copy_local_package", copy)
+ destination = child_ref.get_install_path(tmp_path / "modules")
+ callback = MagicMock(side_effect=AssertionError("Untrusted origin reached callback"))
+ if boundary == "loader":
+ resolver = APMDependencyResolver(
+ apm_modules_dir=tmp_path / "modules", download_callback=callback
+ )
+ assert resolver._try_load_dependency_package(child_ref, parent_pkg=parent_pkg) is None
+ else:
+ materialized = LocalDependencySource(
+ ctx, child_ref, destination, child_ref.get_unique_key()
+ )
+ assert materialized.acquire() is None
+ callback.assert_not_called()
+ copy.assert_not_called()
+ assert child.manifest_path.is_file()
+ assert not destination.exists()
diff --git a/tests/unit/install/test_audit_target_roots.py b/tests/unit/install/test_audit_target_roots.py
new file mode 100644
index 0000000000..853776f7f2
--- /dev/null
+++ b/tests/unit/install/test_audit_target_roots.py
@@ -0,0 +1,305 @@
+"""Current-intent audit resolution is read-only and scope-aware."""
+
+import json
+import sys
+from collections import Counter
+from collections.abc import Iterator
+from pathlib import Path
+from types import FrameType
+from unittest.mock import patch
+
+import pytest
+
+from apm_cli.install.audit_target_roots import AuditTargetError, resolve_audit_targets
+from tests.utils.artifact_snapshot import ArtifactSnapshot, assert_unchanged
+
+pytestmark = pytest.mark.component
+
+
+@pytest.mark.parametrize("user_scope", [False, True])
+def test_configured_experimental_target_is_read_only(tmp_path: Path, user_scope: bool) -> None:
+ """Resolve configured cloud profiles without materializing any target root."""
+ project = tmp_path / "project"
+ project.mkdir()
+ before = ArtifactSnapshot.capture(tmp_path)
+ with (
+ patch("apm_cli.config.get_install_target", return_value="grok-cloud") as config,
+ patch("apm_cli.core.experimental.is_enabled", return_value=True) as enabled,
+ patch.object(Path, "home", return_value=tmp_path / "home"),
+ ):
+ profiles = resolve_audit_targets(project, user_scope=user_scope)
+ assert tuple(profile.name for profile in profiles) == ("grok-cloud",)
+ assert profiles[0].root_dir == ".grok"
+ config.assert_called_once_with(create_config=False, strict=True)
+ enabled.assert_called_once_with("grok_cloud", create_config=False)
+ assert_unchanged(before, ArtifactSnapshot.capture(tmp_path))
+
+
+@pytest.mark.parametrize(
+ "manifest",
+ [
+ "targets: [grok-cloud]\n",
+ "targets: []\n",
+ "target: null\n",
+ "targets: [claude]\ntarget: copilot\n",
+ "- not-a-mapping\n",
+ "targets: [\n",
+ ],
+)
+def test_bad_manifest_never_falls_through_to_config(tmp_path: Path, manifest: str) -> None:
+ """Invalid declarations cannot silently widen to configuration or detection."""
+ (tmp_path / "apm.yml").write_text(manifest, encoding="utf-8")
+ with (
+ patch("apm_cli.config.get_install_target", return_value="claude") as config,
+ pytest.raises(AuditTargetError),
+ ):
+ resolve_audit_targets(tmp_path)
+ config.assert_not_called()
+
+
+def test_absent_config_retains_legacy_audit_detection(tmp_path: Path) -> None:
+ """Do not import install-v2 ambiguity errors into existing audit fallback."""
+ (tmp_path / ".github").mkdir()
+ (tmp_path / ".claude").mkdir()
+ with (
+ patch("apm_cli.config.get_install_target", return_value=None),
+ patch("apm_cli.core.experimental.is_enabled", return_value=False),
+ ):
+ targets = resolve_audit_targets(tmp_path)
+ assert {target.name for target in targets} == {"copilot", "claude"}
+
+
+def test_disabled_configured_target_fails_instead_of_empty_success(tmp_path: Path) -> None:
+ """Explicit current intent must be eligible for the requested scope."""
+ with (
+ patch("apm_cli.config.get_install_target", return_value="grok-cloud"),
+ patch("apm_cli.core.experimental.is_enabled", return_value=False),
+ pytest.raises(AuditTargetError, match="Cannot audit selected target"),
+ ):
+ resolve_audit_targets(tmp_path)
+
+
+@pytest.mark.parametrize("configured", ["intellij", "vscode", "agents"])
+def test_runtime_alias_uses_canonical_primitive_profile(tmp_path: Path, configured: str) -> None:
+ """Use the owner's primitive projection, not an independent alias mapping."""
+ with patch("apm_cli.config.get_install_target", return_value=configured):
+ targets = resolve_audit_targets(tmp_path)
+ assert tuple(target.name for target in targets) == ("copilot",)
+
+
+def test_missing_config_is_not_created(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ """The real config reader must not initialize HOME during target discovery."""
+ from apm_cli import config
+
+ home = tmp_path / "home"
+ home.mkdir()
+ monkeypatch.setattr(Path, "home", lambda: home)
+ monkeypatch.setattr(config, "CONFIG_DIR", str(home / ".apm"))
+ monkeypatch.setattr(config, "CONFIG_FILE", str(home / ".apm/config.json"))
+ monkeypatch.setattr(config, "_config_cache", None)
+ before = ArtifactSnapshot.capture(home)
+ assert resolve_audit_targets(tmp_path)[0].name == "copilot"
+ assert_unchanged(before, ArtifactSnapshot.capture(home))
+
+
+@pytest.mark.parametrize("claim_kind", ["file", "directory", "replaced-file"])
+def test_removed_target_claims_remain_in_comparison(tmp_path: Path, claim_kind: str) -> None:
+ """Legacy directory ownership also preserves old-target drift coverage."""
+ from apm_cli.deps.lockfile import LockedDependency, LockFile
+ from apm_cli.install.drift import DriftFinding, diff_scratch_against_project
+ from apm_cli.integration.targets import KNOWN_TARGETS
+
+ project = tmp_path / "project"
+ scratch = tmp_path / "scratch"
+ scratch.mkdir()
+ relative = ".claude/skills/old/SKILL.md"
+ deployed = project / relative
+ deployed.parent.mkdir(parents=True)
+ deployed.write_text("Previously deployed skill\n", encoding="utf-8")
+ claim = relative if claim_kind == "file" else ".claude/skills/old"
+ hashes = {claim: "sha256:" + "a" * 64} if claim_kind == "replaced-file" else {}
+ lock = LockFile(
+ dependencies={
+ "owner/pkg": LockedDependency(
+ repo_url="owner/pkg", deployed_files=[claim], deployed_file_hashes=hashes
+ )
+ }
+ )
+ findings = diff_scratch_against_project(scratch, project, lock, [KNOWN_TARGETS["grok-cloud"]])
+ assert findings == (
+ []
+ if claim_kind == "replaced-file"
+ else [DriftFinding(path=relative, kind="orphaned", package="owner/pkg")]
+ )
+
+
+@pytest.mark.parametrize("work", ["owner-probes", "enumeration"])
+def test_directory_claim_work_is_bounded(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, work: str
+) -> None:
+ """Fixed-depth 50/500 claims must not multiply probes or repeat safe walks."""
+ from apm_cli.deps.lockfile import LockedDependency, LockFile
+ from apm_cli.install import drift
+
+ measurements = []
+ original_rglob = Path.rglob
+ for size in (50, 500):
+ project = tmp_path / str(size) / "project"
+ scratch = tmp_path / str(size) / "scratch"
+ scratch.mkdir(parents=True)
+ dependencies = {}
+ expected = []
+ for index in range(size):
+ # Half already lie under a governed root; the rest need additional
+ # comparison roots. Deep claims deliberately precede their parents.
+ top = ".apm" if index % 2 == 0 else "retired"
+ parent = f"{top}/bundle-{index:04d}"
+ deep = f"{parent}/deep"
+ exact = f"{deep}/exact.md"
+ for claim, owner in (
+ (deep + "/", f"deep/pkg-{index}"),
+ (parent, f"parent/pkg-{index}"),
+ (exact, f"exact/pkg-{index}"),
+ ):
+ dependencies[owner] = LockedDependency(repo_url=owner, deployed_files=[claim])
+ for relative, owner in (
+ (f"{parent}/outer.md", f"parent/pkg-{index}"),
+ (f"{deep}/orphan.md", f"deep/pkg-{index}"),
+ (exact, f"exact/pkg-{index}"),
+ # Similar spelling must not count as ancestor ownership.
+ (f".apm/bundle-{index:04d}-neighbor/note.md", None),
+ (f".apm/file-{index:04d}.md/neighbor.md", None),
+ ):
+ path = project / relative
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text("unchanged comparison-only bytes\n", encoding="utf-8")
+ if owner is not None:
+ expected.append(drift.DriftFinding(relative, "orphaned", owner))
+ file_claim = f".apm/file-{index:04d}.md"
+ owner = f"hashed/pkg-{index}"
+ dependencies[owner] = LockedDependency(
+ repo_url=owner,
+ deployed_files=[file_claim],
+ deployed_file_hashes={file_claim: "sha256:" + "a" * 64},
+ )
+
+ enumerated: Counter[Path] = Counter()
+ probes = 0
+
+ def counted_rglob(
+ path: Path, pattern: str, counts: Counter[Path] = enumerated
+ ) -> Iterator[Path]:
+ """Count real yielded filesystem entries, not elapsed time."""
+ for entry in original_rglob(path, pattern):
+ counts[entry] += 1
+ yield entry
+
+ def count_owner_probes(frame: FrameType, event: str, arg: object) -> None:
+ """Count old prefix comparisons and replacement dictionary probes."""
+ nonlocal probes
+ if event != "c_call" or frame.f_code.co_filename != drift.__file__:
+ return
+ name = getattr(arg, "__name__", "")
+ if name == "startswith" or (
+ name == "get"
+ and getattr(arg, "__self__", None) is frame.f_locals.get("prefix_owners")
+ ):
+ probes += 1
+
+ before = ArtifactSnapshot.capture(project)
+ previous_profile = sys.getprofile()
+ with monkeypatch.context() as measured:
+ measured.setattr(Path, "rglob", counted_rglob)
+ try:
+ sys.setprofile(count_owner_probes)
+ findings = drift.diff_scratch_against_project(
+ scratch, project, LockFile(dependencies=dependencies), targets=[]
+ )
+ finally:
+ sys.setprofile(previous_profile)
+ assert findings == sorted(expected, key=lambda finding: finding.path)
+ assert_unchanged(before, ArtifactSnapshot.capture(project))
+ assert list(scratch.iterdir()) == [], "Comparison claims must not authorize replay"
+ measurements.append(
+ {
+ "size": size,
+ "owner-probes": probes,
+ "enumeration": enumerated.total(),
+ "max-visits": max(enumerated.values()),
+ }
+ )
+
+ print(json.dumps({"work": work, "measurements": measurements}, sort_keys=True))
+ small, large = measurements
+ assert small[work] > 0, "The operation counter must observe the real comparison path"
+ assert large[work] < 15 * small[work], f"{work} grew at least 15x: {measurements}"
+ if work == "enumeration":
+ assert [sample["max-visits"] for sample in measurements] == [1, 1], (
+ f"Validated covered roots were walked again: {measurements}"
+ )
+
+
+@pytest.mark.parametrize("unsafe", ["symlink", "escape"])
+def test_additional_directory_claims_keep_path_refusals(tmp_path: Path, unsafe: str) -> None:
+ """An unsafe old-target root is never eligible for a covered-root shortcut."""
+ from apm_cli.deps.lockfile import LockedDependency, LockFile
+ from apm_cli.install.drift import diff_scratch_against_project
+ from apm_cli.utils.path_security import PathTraversalError
+
+ project = tmp_path / "project"
+ scratch = tmp_path / "scratch"
+ outside = tmp_path / "outside"
+ for root in (project, scratch, outside):
+ root.mkdir()
+ (outside / "note.md").write_text("not managed\n", encoding="utf-8")
+ if unsafe == "symlink":
+ (project / "retired").symlink_to(outside, target_is_directory=True)
+ claim = "retired"
+ else:
+ claim = "../outside"
+ lock = LockFile(
+ dependencies={"owner/pkg": LockedDependency(repo_url="owner/pkg", deployed_files=[claim])}
+ )
+ before = ArtifactSnapshot.capture(tmp_path)
+ if unsafe == "escape":
+ with pytest.raises(PathTraversalError):
+ diff_scratch_against_project(scratch, project, lock, targets=[])
+ else:
+ assert diff_scratch_against_project(scratch, project, lock, targets=[]) == []
+ assert_unchanged(before, ArtifactSnapshot.capture(tmp_path))
+
+
+@pytest.mark.parametrize("placement", ["scratch-only", "project-only", "both"])
+def test_local_bundle_exclusion_preserves_other_claims(tmp_path: Path, placement: str) -> None:
+ """Exclude imperative bundles without suppressing authored orphan findings."""
+ from apm_cli.core.deployment_ledger import DeploymentLedgerCodec
+ from apm_cli.deps.lockfile import LockedDependency, LockFile
+ from apm_cli.install.drift import DriftFinding, diff_scratch_against_project
+ from apm_cli.integration.targets import KNOWN_TARGETS
+
+ project = tmp_path / "project"
+ scratch = tmp_path / "scratch"
+ bundled = ".claude/skills/bundled/SKILL.md"
+ authored = ".claude/skills/authored/SKILL.md"
+ for name, root in (("project", project), ("scratch", scratch)):
+ root.mkdir()
+ if placement in (f"{name}-only", "both"):
+ path = root / bundled
+ path.parent.mkdir(parents=True)
+ path.write_text(name, encoding="utf-8")
+ authored_path = project / authored
+ authored_path.parent.mkdir(parents=True)
+ authored_path.write_text("authored orphan\n", encoding="utf-8")
+ lock = LockFile(
+ dependencies={
+ "owner/pkg": LockedDependency(repo_url="owner/pkg", deployed_files=[authored])
+ }
+ )
+ DeploymentLedgerCodec.record_local_bundle_files(
+ lock, [bundled], {bundled: f"sha256:{'b' * 64}"}
+ )
+ before = ArtifactSnapshot.capture(tmp_path)
+ assert diff_scratch_against_project(scratch, project, lock, [KNOWN_TARGETS["claude"]]) == [
+ DriftFinding(path=authored, kind="orphaned", package="owner/pkg")
+ ]
+ assert_unchanged(before, ArtifactSnapshot.capture(tmp_path))
diff --git a/tests/unit/install/test_cached_source_identity.py b/tests/unit/install/test_cached_source_identity.py
new file mode 100644
index 0000000000..0182f3dd25
--- /dev/null
+++ b/tests/unit/install/test_cached_source_identity.py
@@ -0,0 +1,64 @@
+"""Cached materialization restores canonical Git source identity."""
+
+from __future__ import annotations
+
+from pathlib import Path
+from unittest.mock import MagicMock
+from urllib.parse import urlparse
+
+import pytest
+
+from apm_cli.core.scope import InstallScope
+from apm_cli.install.context import InstallContext
+from apm_cli.install.sources import CachedDependencySource
+from apm_cli.integration.targets import KNOWN_TARGETS
+from apm_cli.models.dependency import DependencyReference
+from apm_cli.utils.diagnostics import DiagnosticCollector
+from apm_cli.utils.yaml_io import dump_yaml
+from tests.utils.local_package import LocalPackageFactory
+
+pytestmark = pytest.mark.component
+
+
+@pytest.mark.parametrize("fetched_this_run", [False, True], ids=["cached", "fresh"])
+def test_cached_acquisition_restores_original_git_source(
+ tmp_path: Path, fetched_this_run: bool
+) -> None:
+ """Authored metadata cannot replace the Git origin used for reconstruction."""
+ package = LocalPackageFactory(tmp_path).create("parent")
+ dump_yaml(
+ {"name": "parent", "version": "1.0.0", "source": "_local/forged"},
+ package.manifest_path,
+ )
+ ref = DependencyReference.parse_from_dict(
+ {"git": "https://gitlab.example.invalid/team/parent", "ref": "v1"}
+ )
+ graph = MagicMock()
+ graph.dependency_tree.get_node.return_value = None
+ ctx = InstallContext(
+ project_root=tmp_path,
+ apm_dir=tmp_path,
+ scope=InstallScope.PROJECT,
+ dependency_graph=graph,
+ diagnostics=DiagnosticCollector(),
+ targets=[KNOWN_TARGETS["cursor"]],
+ )
+ source = CachedDependencySource(
+ ctx,
+ ref,
+ package.root,
+ ref.get_unique_key(),
+ resolved_ref=None,
+ dep_locked_chk=None,
+ fetched_this_run=fetched_this_run,
+ )
+
+ materialized = source.acquire()
+
+ assert materialized is not None
+ assert materialized.package_info is not None
+ restored = urlparse(materialized.package_info.package.source)
+ assert restored.scheme == "https"
+ assert restored.hostname == "gitlab.example.invalid"
+ assert restored == urlparse(ref.to_github_url())
+ assert ctx.installed_packages[0].dep_ref is ref
diff --git a/tests/unit/install/test_drift_detection.py b/tests/unit/install/test_drift_detection.py
index 0b02b38c51..d4ed72700b 100644
--- a/tests/unit/install/test_drift_detection.py
+++ b/tests/unit/install/test_drift_detection.py
@@ -26,6 +26,7 @@
from unittest.mock import MagicMock, patch
import pytest
+import yaml
from apm_cli.deps.lockfile import LockedDependency, LockFile
from apm_cli.install.drift import (
@@ -50,6 +51,8 @@
)
from apm_cli.integration.hook_integrator import HookIntegrator
+pytestmark = pytest.mark.component
+
# ---------------------------------------------------------------------------
# _assert_scratch_bound
# ---------------------------------------------------------------------------
@@ -326,12 +329,11 @@ def test_apm_yml_no_target_returns_none(self, tmp_path: Path) -> None:
(tmp_path / "apm.yml").write_text("name: pkg\n", encoding="utf-8")
assert _read_apm_yml_target(tmp_path) is None
- def test_apm_yml_unreadable_returns_none(self, tmp_path: Path) -> None:
+ def test_apm_yml_unreadable_fails_closed(self, tmp_path: Path) -> None:
p = tmp_path / "apm.yml"
p.write_bytes(b"\xff not yaml [[[")
- # Should not raise
- result = _read_apm_yml_target(tmp_path)
- assert result is None
+ with pytest.raises(yaml.YAMLError, match="bounded YAML parse failed"):
+ _read_apm_yml_target(tmp_path)
def test_apm_yml_with_singular_target_returns_list(self, tmp_path: Path) -> None:
# Singular 'target: copilot' form -- returns a one-element list.
@@ -347,14 +349,16 @@ def test_apm_yml_with_targets_list_returns_list(self, tmp_path: Path) -> None:
result = _read_apm_yml_target(tmp_path)
assert result == ["claude", "codex"]
- def test_parse_targets_field_exception_returns_none(self, tmp_path: Path) -> None:
+ def test_parse_targets_field_exception_propagates(self, tmp_path: Path) -> None:
(tmp_path / "apm.yml").write_text("name: pkg\ntarget: copilot\n", encoding="utf-8")
- with patch(
- "apm_cli.core.apm_yml.parse_targets_field",
- side_effect=ValueError("bad"),
+ with (
+ patch(
+ "apm_cli.core.apm_yml.parse_targets_field",
+ side_effect=ValueError("bad"),
+ ),
+ pytest.raises(ValueError, match="bad"),
):
- result = _read_apm_yml_target(tmp_path)
- assert result is None
+ _read_apm_yml_target(tmp_path)
# ---------------------------------------------------------------------------
diff --git a/tests/unit/install/test_drift_phase3.py b/tests/unit/install/test_drift_phase3.py
index a53a70758d..85dd00a471 100644
--- a/tests/unit/install/test_drift_phase3.py
+++ b/tests/unit/install/test_drift_phase3.py
@@ -26,6 +26,7 @@
from unittest.mock import MagicMock, patch
import pytest
+import yaml
from apm_cli.deps.lockfile import LockedDependency, LockFile
from apm_cli.install.drift import (
@@ -49,6 +50,8 @@
render_drift_text,
)
+pytestmark = pytest.mark.component
+
# ---------------------------------------------------------------------------
# _assert_scratch_bound
# ---------------------------------------------------------------------------
@@ -304,12 +307,11 @@ def test_apm_yml_no_target_returns_none(self, tmp_path: Path) -> None:
(tmp_path / "apm.yml").write_text("name: pkg\n", encoding="utf-8")
assert _read_apm_yml_target(tmp_path) is None
- def test_apm_yml_unreadable_returns_none(self, tmp_path: Path) -> None:
+ def test_apm_yml_unreadable_fails_closed(self, tmp_path: Path) -> None:
p = tmp_path / "apm.yml"
p.write_bytes(b"\xff not yaml [[[")
- # Should not raise
- result = _read_apm_yml_target(tmp_path)
- assert result is None
+ with pytest.raises(yaml.YAMLError, match="bounded YAML parse failed"):
+ _read_apm_yml_target(tmp_path)
def test_apm_yml_with_singular_target_returns_list(self, tmp_path: Path) -> None:
# Singular 'target: copilot' form -- returns a one-element list.
@@ -317,14 +319,16 @@ def test_apm_yml_with_singular_target_returns_list(self, tmp_path: Path) -> None
result = _read_apm_yml_target(tmp_path)
assert result == ["copilot"]
- def test_parse_targets_field_exception_returns_none(self, tmp_path: Path) -> None:
+ def test_parse_targets_field_exception_propagates(self, tmp_path: Path) -> None:
(tmp_path / "apm.yml").write_text("name: pkg\ntarget: copilot\n", encoding="utf-8")
- with patch(
- "apm_cli.core.apm_yml.parse_targets_field",
- side_effect=ValueError("bad"),
+ with (
+ patch(
+ "apm_cli.core.apm_yml.parse_targets_field",
+ side_effect=ValueError("bad"),
+ ),
+ pytest.raises(ValueError, match="bad"),
):
- result = _read_apm_yml_target(tmp_path)
- assert result is None
+ _read_apm_yml_target(tmp_path)
# ---------------------------------------------------------------------------
diff --git a/tests/unit/install/test_local_scope_admission.py b/tests/unit/install/test_local_scope_admission.py
new file mode 100644
index 0000000000..fe36098523
--- /dev/null
+++ b/tests/unit/install/test_local_scope_admission.py
@@ -0,0 +1,71 @@
+"""Real resolver and materialization boundary coverage for issue #2815."""
+
+from __future__ import annotations
+
+from pathlib import Path
+from unittest.mock import MagicMock
+
+import pytest
+
+from apm_cli.core.scope import InstallScope
+from apm_cli.deps.tiered_ref_resolver import RefFreshnessPolicy
+from apm_cli.install.context import InstallContext
+from apm_cli.install.phases.resolve import _materialization, _resolve_dependencies
+from apm_cli.install.resolution_staging import ResolutionStagingSession
+from apm_cli.install.sources import LocalDependencySource
+from apm_cli.models.apm_package import APMPackage
+from apm_cli.utils.diagnostics import DiagnosticCollector
+from tests.utils.local_package import LocalPackageFactory
+
+pytestmark = pytest.mark.component
+
+
+@pytest.mark.parametrize("boundary", ["resolve", "acquire"])
+def test_user_scope_preserves_local_parent_anchor(tmp_path: Path, boundary: str) -> None:
+ """Each production boundary must admit the same anchored transitive child."""
+ factory = LocalPackageFactory(tmp_path / "packages")
+ child = factory.create("child", targets=["cursor"])
+ parent = factory.create("parent", dependencies=[{"path": "../child"}], targets=["cursor"])
+ consumer = factory.create("consumer", dependencies=[{"path": parent.root.as_posix()}])
+ package = APMPackage.from_apm_yml(consumer.manifest_path, source_path=consumer.root)
+ modules = tmp_path / "user" / ".apm" / "apm_modules"
+ modules.mkdir(parents=True)
+ ctx = InstallContext(
+ project_root=consumer.root,
+ apm_dir=consumer.root,
+ apm_package=package,
+ # Isolate acquire from the resolver's admission decision.
+ scope=InstallScope.PROJECT if boundary == "acquire" else InstallScope.USER,
+ all_apm_deps=package.get_apm_dependencies(),
+ apm_modules_dir=modules,
+ ref_freshness_policy=RefFreshnessPolicy.REPRODUCIBLE,
+ downloader=MagicMock(shared_clone_cache=None),
+ diagnostics=DiagnosticCollector(),
+ )
+ staging = ResolutionStagingSession(modules)
+ try:
+ _resolve_dependencies(ctx, staging, _materialization.CachedMaterializationPathReader())
+ child_ref = next(dep for dep in ctx.deps_to_install if dep.repo_url == "_local/child")
+ if boundary == "resolve":
+ assert not ctx.callback_failures
+ assert child_ref.get_unique_key() in ctx.callback_downloaded
+ node = ctx.dependency_graph.dependency_tree.get_node(child_ref.get_unique_key())
+ assert node.package.source_path == child.root
+ else:
+ ctx.scope = InstallScope.USER
+ source = LocalDependencySource(
+ ctx, child_ref, child_ref.get_install_path(modules), child_ref.get_unique_key()
+ )
+ materialized = source.acquire()
+ assert materialized is not None
+ assert materialized.package_info.package.source_path == child.root
+ assert ctx.installed_packages[0].resolved_by == "_local/parent"
+ assert ctx.dep_base_dirs[child_ref.get_unique_key()] == parent.root
+ assert child_ref.local_path == "../child"
+ assert child_ref.declaring_parent == parent.root.as_posix()
+ assert child_ref.get_install_path(modules).joinpath("apm.yml").read_bytes() == (
+ child.manifest_path.read_bytes()
+ )
+ ctx.downloader.download_package.assert_not_called()
+ finally:
+ staging.rollback()
diff --git a/tests/unit/install/test_user_scope_rejection_reason.py b/tests/unit/install/test_user_scope_rejection_reason.py
index 26790d10a7..b5920753c2 100644
--- a/tests/unit/install/test_user_scope_rejection_reason.py
+++ b/tests/unit/install/test_user_scope_rejection_reason.py
@@ -6,9 +6,11 @@
* User scope, remote ref -> NEVER reject (the happy path).
* User scope, local *abs* -> NEVER reject (an absolute path is unambiguous;
see PR #937 commit message).
- * User scope, local *rel* -> ALWAYS reject (relative-to-cwd is ambiguous
+ * User scope, direct *rel* -> ALWAYS reject (relative-to-cwd is ambiguous
outside a project; ``$HOME`` is not a project
root).
+ * User scope, local child -> accept only with resolver-proven local parent
+ provenance and an absolute source anchor.
* User scope, ``git: parent`` inheritance -> ALWAYS reject (no monorepo
root at user scope).
@@ -24,6 +26,7 @@
from __future__ import annotations
import os
+from pathlib import Path
import pytest
@@ -32,6 +35,7 @@
GIT_PARENT_USER_SCOPE_ERROR,
user_scope_rejection_reason,
)
+from apm_cli.models.apm_package import APMPackage
from apm_cli.models.dependency.reference import DependencyReference
@@ -147,6 +151,65 @@ def test_user_scope_handles_empty_local_path_defensively():
assert "relative" in reason.lower()
+@pytest.mark.parametrize("local_path", ["../child", "./nested/child"])
+def test_user_scope_accepts_proven_local_parent_anchor(tmp_path: Path, local_path: str) -> None:
+ """A resolver-proven local child is anchored, not relative to arbitrary CWD."""
+ parent = APMPackage(
+ name="parent",
+ version="1.0.0",
+ source="_local/parent",
+ source_path=tmp_path,
+ proven_source_kind="local",
+ )
+ child = _local_ref(local_path)
+ child.declaring_parent = tmp_path.as_posix()
+
+ assert user_scope_rejection_reason(child, InstallScope.USER, parent_pkg=parent) is None
+
+
+@pytest.mark.parametrize(
+ ("source", "anchor", "declared", "local_path"),
+ [
+ ("_local/parent", "absolute", False, "../child"),
+ ("_local/parent", None, True, "../child"),
+ ("_local/parent", "relative", True, "../child"),
+ (None, "absolute", True, "../child"),
+ ("org/remote", "absolute", True, "../child"),
+ ("https://example.invalid/remote", "absolute", True, "../child"),
+ ("_local/parent", "absolute", True, ""),
+ ("_local/parent", "absolute", True, "../child"),
+ ],
+ ids=[
+ "direct-ref",
+ "missing-anchor",
+ "relative-anchor",
+ "unknown-provenance",
+ "remote-shorthand",
+ "remote-url",
+ "empty-path",
+ "local-looking-unknown-origin",
+ ],
+)
+def test_user_scope_does_not_infer_local_parent_trust(
+ tmp_path: Path,
+ source: str | None,
+ anchor: str | None,
+ declared: bool,
+ local_path: str,
+) -> None:
+ """A root fallback or a remote staged directory cannot authorize local reads."""
+ source_path = tmp_path if anchor == "absolute" else Path("parent") if anchor else None
+ parent = APMPackage(name="parent", version="1.0.0", source=source, source_path=source_path)
+ child = _local_ref(local_path)
+ if declared:
+ child.declaring_parent = tmp_path.as_posix()
+
+ reason = user_scope_rejection_reason(child, InstallScope.USER, parent_pkg=parent)
+
+ assert reason is not None
+ assert "absolute path" in reason
+
+
# ---------------------------------------------------------------------------
# User-scope remote / parent-inheritance handling
# ---------------------------------------------------------------------------
diff --git a/tests/unit/scripts/test_architecture_runner.py b/tests/unit/scripts/test_architecture_runner.py
index 59f7b4ed61..25333bc3ef 100644
--- a/tests/unit/scripts/test_architecture_runner.py
+++ b/tests/unit/scripts/test_architecture_runner.py
@@ -615,6 +615,7 @@ def exiting_import(
contracts-tooling-policy-identity
contracts-tooling-project-yaml-write-delegation
contracts-tooling-python-artifact-membership
+contracts-tooling-spec-assessment
install-deployment-approval-outcome-routing
install-deployment-audit-policy-discovery
install-deployment-audit-replay
@@ -632,6 +633,7 @@ def exiting_import(
install-deployment-install-scope-selection
install-deployment-local-bundle-policy-preflight
install-deployment-local-identity-anchor
+install-deployment-local-scope-admission
install-deployment-locked-skill-subset-reconstruction
install-deployment-lsp-lifecycle
install-deployment-lsp-target-contract
diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py
index 028061cdcb..de37093174 100644
--- a/tests/unit/test_config.py
+++ b/tests/unit/test_config.py
@@ -11,6 +11,8 @@
from apm_cli import config as config_mod
+pytestmark = pytest.mark.component
+
@pytest.fixture
def isolated_config(tmp_path, monkeypatch):
@@ -92,6 +94,19 @@ class TestInstallTargetConfig:
def test_default_is_none(self, isolated_config):
assert config_mod.get_install_target() is None
+ @pytest.mark.parametrize("value", ["claudee", [], 42, None])
+ def test_strict_read_rejects_present_invalid_target(self, isolated_config, value):
+ config_mod.update_config({"install_target": value})
+ before = isolated_config.read_bytes()
+ assert config_mod.get_install_target(create_config=False) is None
+ with pytest.raises(ValueError, match="Invalid saved target configuration"):
+ config_mod.get_install_target(create_config=False, strict=True)
+ assert isolated_config.read_bytes() == before
+
+ def test_strict_absent_read_does_not_create_config(self, isolated_config):
+ assert config_mod.get_install_target(create_config=False, strict=True) is None
+ assert not isolated_config.exists()
+
def test_set_and_get_roundtrip(self, isolated_config):
config_mod.set_install_target("claude")
assert config_mod.get_install_target() == "claude"
diff --git a/tests/unit/test_protocol_config_precedence.py b/tests/unit/test_protocol_config_precedence.py
index e0202f525c..56d61a5795 100644
--- a/tests/unit/test_protocol_config_precedence.py
+++ b/tests/unit/test_protocol_config_precedence.py
@@ -6,11 +6,128 @@
can be validated in isolation.
"""
+import json
import os
-from unittest.mock import patch
+import sys
+import tempfile
+from pathlib import Path
+from unittest.mock import MagicMock, patch
import pytest
+from tests.utils.artifact_snapshot import ArtifactSnapshot, assert_unchanged
+
+pytestmark = pytest.mark.component
+
+
+@pytest.mark.windows_compat
+@pytest.mark.parametrize("platform", ["linux", "win32"])
+@pytest.mark.parametrize(
+ ("create_config", "stored", "use_env", "expected_pref", "expected_fallback"),
+ [
+ (False, False, False, None, False),
+ (True, False, False, None, False),
+ (False, True, False, "ssh", True),
+ (False, True, True, "https", False),
+ ],
+ ids=["read-only-defaults", "install-bootstrap", "read-only-stored", "read-only-env"],
+)
+def test_downloader_config_bootstrap_preserves_transport_precedence(
+ tmp_path: Path,
+ tmp_path_factory: pytest.TempPathFactory,
+ monkeypatch: pytest.MonkeyPatch,
+ create_config: bool,
+ stored: bool,
+ use_env: bool,
+ expected_pref: str | None,
+ expected_fallback: bool,
+ platform: str,
+) -> None:
+ """Skipping initialization changes no configured or environment transport choice."""
+ from apm_cli import config
+ from apm_cli.deps.github_downloader import GitHubPackageDownloader
+ from apm_cli.deps.transport_selection import ProtocolPreference
+
+ config_path = tmp_path / ".apm/config.json"
+ monkeypatch.setattr(config, "CONFIG_DIR", str(config_path.parent))
+ monkeypatch.setattr(config, "CONFIG_FILE", str(config_path))
+ monkeypatch.setattr(config, "_config_cache", None)
+ for key in ("APM_GIT_PROTOCOL", "APM_ALLOW_PROTOCOL_FALLBACK", "APM_TEMP_DIR"):
+ monkeypatch.delenv(key, raising=False)
+ # Exercise the real Windows sentinel branch, not a stubbed config reader.
+ # Scratch Git config is allowed; missing user configuration is not.
+ scratch = tmp_path_factory.mktemp("git-sentinel")
+ monkeypatch.setattr(tempfile, "tempdir", str(scratch))
+ if stored:
+ config_path.parent.mkdir()
+ config_path.write_text(
+ json.dumps({"prefer_ssh": True, "allow_protocol_fallback": True}), encoding="utf-8"
+ )
+ if use_env:
+ monkeypatch.setenv("APM_GIT_PROTOCOL", "https")
+ monkeypatch.setenv("APM_ALLOW_PROTOCOL_FALLBACK", "0")
+ before = ArtifactSnapshot.capture(tmp_path)
+
+ with monkeypatch.context() as platform_patch:
+ platform_patch.setattr(sys, "platform", platform)
+ if create_config:
+ downloader = GitHubPackageDownloader(auth_resolver=MagicMock())
+ else:
+ downloader = GitHubPackageDownloader(auth_resolver=MagicMock(), create_config=False)
+
+ assert downloader._protocol_pref is ProtocolPreference.from_str(expected_pref)
+ assert downloader._allow_fallback is expected_fallback
+ assert downloader.git_env["GIT_ASKPASS"] == "echo"
+ assert downloader.git_env["GIT_CONFIG_NOSYSTEM"] == "1"
+ if platform == "win32":
+ sentinel = scratch / ".apm_empty_gitconfig"
+ assert downloader.git_env["GIT_CONFIG_GLOBAL"] == str(sentinel)
+ assert sentinel.read_bytes() == b""
+ else:
+ assert downloader.git_env["GIT_CONFIG_GLOBAL"] == os.devnull
+ if create_config:
+ assert json.loads(config_path.read_text(encoding="utf-8")) == {"default_client": "vscode"}
+ else:
+ assert_unchanged(before, ArtifactSnapshot.capture(tmp_path))
+
+
+@pytest.mark.windows_compat
+@pytest.mark.parametrize("use_env", [False, True], ids=["stored-temp", "env-temp"])
+def test_read_only_windows_sentinel_preserves_temp_precedence(
+ tmp_path: Path,
+ tmp_path_factory: pytest.TempPathFactory,
+ monkeypatch: pytest.MonkeyPatch,
+ use_env: bool,
+) -> None:
+ """The sentinel uses env > saved temp without modifying saved configuration."""
+ from apm_cli import config
+ from apm_cli.deps.github_downloader import GitHubPackageDownloader
+
+ saved_temp = tmp_path_factory.mktemp("saved-temp")
+ env_temp = tmp_path_factory.mktemp("env-temp")
+ config_path = tmp_path / ".apm/config.json"
+ config_path.parent.mkdir()
+ config_path.write_text(json.dumps({"temp_dir": str(saved_temp)}), encoding="utf-8")
+ monkeypatch.setattr(config, "CONFIG_DIR", str(config_path.parent))
+ monkeypatch.setattr(config, "CONFIG_FILE", str(config_path))
+ monkeypatch.setattr(config, "_config_cache", None)
+ monkeypatch.delenv("APM_TEMP_DIR", raising=False)
+ if use_env:
+ monkeypatch.setenv("APM_TEMP_DIR", str(env_temp))
+ before = ArtifactSnapshot.capture(tmp_path)
+
+ with monkeypatch.context() as platform_patch:
+ platform_patch.setattr(sys, "platform", "win32")
+ downloader = GitHubPackageDownloader(auth_resolver=MagicMock(), create_config=False)
+
+ expected_temp, unused_temp = (env_temp, saved_temp) if use_env else (saved_temp, env_temp)
+ sentinel = expected_temp / ".apm_empty_gitconfig"
+ assert downloader.git_env["GIT_CONFIG_GLOBAL"] == str(sentinel)
+ assert sentinel.read_bytes() == b""
+ assert not (unused_temp / ".apm_empty_gitconfig").exists()
+ assert_unchanged(before, ArtifactSnapshot.capture(tmp_path))
+
+
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
]