From 7580a4ee2671eec9729031ac1a0933c93f88198d Mon Sep 17 00:00:00 2001 From: Lachlan Heywood Date: Tue, 18 Aug 2026 12:08:30 -0400 Subject: [PATCH 01/13] fix(lockfile): omit timestamps from new lockfiles --- CHANGELOG.md | 4 ++ .../content/docs/concepts/package-anatomy.md | 21 +++++---- docs/src/content/docs/reference/cli/lock.md | 2 +- docs/src/content/docs/reference/cli/pack.md | 1 - .../content/docs/reference/lockfile-spec.md | 10 +++-- .../.apm/skills/apm-usage/commands.md | 2 + src/apm_cli/commands/lock.py | 2 +- src/apm_cli/deps/lockfile.py | 23 ++++++++-- src/apm_cli/integration/mcp_integrator.py | 2 - .../integration/test_cache_lockfile_parity.py | 14 +++--- .../test_config_surface_lifecycle_contract.py | 2 +- .../test_install_lsp_lockfile_determinism.py | 1 + .../test_install_mcp_lockfile_determinism.py | 3 +- .../test_oci_mcp_lifecycle_contract.py | 2 +- tests/test_lockfile.py | 22 ++++++++++ .../unit/commands/test_lock_export_command.py | 17 ++++++++ .../install/test_mcp_lockfile_determinism.py | 43 ++++++++++++++----- tests/unit/integration/test_mcp_integrator.py | 2 + 18 files changed, 128 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3009383d1e..be6c37f532 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -127,6 +127,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Generated bundle and plugin metadata now uses deterministic LF line endings, keeping generated metadata byte-stable across operating systems. (by @WilliamK112, #2638) +- Newly generated `apm.lock.yaml` files no longer include volatile + `generated_at` metadata. Existing lockfiles that carry the field continue + to refresh it on substantive writes, preventing unrelated dependency + updates from producing timestamp-only merge conflicts. (closes #2572) - Lockfiles generated on Windows for marketplace-plugin / skill-subset git dependencies now pass `apm install --frozen` on Linux, and vice versa, by hashing synthetic manifests with deterministic LF line endings. diff --git a/docs/src/content/docs/concepts/package-anatomy.md b/docs/src/content/docs/concepts/package-anatomy.md index deee96e7ca..77a44ac4b2 100644 --- a/docs/src/content/docs/concepts/package-anatomy.md +++ b/docs/src/content/docs/concepts/package-anatomy.md @@ -161,7 +161,6 @@ by `apm install`; commit it. ```yaml lockfile_version: '1' -generated_at: '2026-04-21T21:45:34.516938+00:00' apm_version: 0.22.0 dependencies: @@ -199,16 +198,16 @@ local_deployed_file_hashes: Top-level fields: -| Field | Notes | -|--------------------------------|------------------------------------------------| -| `lockfile_version` | Schema version of the lockfile. | -| `generated_at` | ISO timestamp of last write. | -| `apm_version` | CLI version that generated the file. | -| `dependencies` | List of `LockedDependency` entries. | -| `mcp_servers` | Resolved MCP server identifiers. | -| `mcp_configs` | Per-harness MCP configuration blobs. | -| `local_deployed_files` | Files this package wrote to deployed dirs. | -| `local_deployed_file_hashes` | SHA-256 of each local-deployed file. | +| Field | Notes | +|--------------------------------|-----------------------------------------------------------| +| `lockfile_version` | Schema version of the lockfile. | +| `apm_version` | CLI version that generated the file. | +| `dependencies` | List of `LockedDependency` entries. | +| `mcp_servers` | Resolved MCP server identifiers. | +| `mcp_configs` | Per-harness MCP configuration blobs. | +| `local_deployed_files` | Files this package wrote to deployed dirs. | +| `local_deployed_file_hashes` | SHA-256 of each local-deployed file. | +| _(Deprecated)_ `generated_at` | Write timestamp. Remove from lockfile to avoid conflicts. | Each dependency stores canonical identity and resolution data. For case-insensitive providers, `repo_url` is the canonical comparison value while diff --git a/docs/src/content/docs/reference/cli/lock.md b/docs/src/content/docs/reference/cli/lock.md index fda2310ecf..c4314428bc 100644 --- a/docs/src/content/docs/reference/cli/lock.md +++ b/docs/src/content/docs/reference/cli/lock.md @@ -86,7 +86,7 @@ apm lock export [OPTIONS] | `--format FORMAT`, `-f FORMAT` | `cyclonedx` | SBOM output format: `cyclonedx` (1.5) or `spdx` (2.3). | | `--output FILE`, `-o FILE` | stdout | Write the SBOM to a file instead of stdout. | | `--global`, `-g` | off | Read the user-scope (`~/.apm/`) lockfile instead of the current project. | -| `--timestamp TS` | auto | Pin the SBOM timestamp for reproducible output. The value must be ISO 8601 with a timezone (e.g. `2024-06-01T00:00:00+00:00`); malformed or timezone-naive values fail. Defaults to `SOURCE_DATE_EPOCH`, then the lockfile's `generated_at`. | +| `--timestamp TS` | auto | Pin the SBOM timestamp for reproducible output. The value must be ISO 8601 with a timezone (e.g. `2024-06-01T00:00:00+00:00`); malformed or timezone-naive values fail. Defaults to `SOURCE_DATE_EPOCH`, then the lockfile's legacy `generated_at`, then the Unix epoch. | Component identity is a Package URL (`pkg:github//@` for git deps, `pkg:oci/@` for registry deps, `pkg:generic/@` for local primitives), and the declared license is passed through verbatim (or `NOASSERTION` when undeclared). Output is deterministic -- components sorted by purl with a pinned timestamp -- so two runs are byte-identical. Credentials in recorded URLs are scrubbed. Diagnostics and update notifications route to stderr from process startup, so `apm lock export | jq` stays clean. See [Inventory export (SBOM)](../../../enterprise/security/#inventory-export-sbom) for the full model. diff --git a/docs/src/content/docs/reference/cli/pack.md b/docs/src/content/docs/reference/cli/pack.md index ee90a4494a..434fc6ed93 100644 --- a/docs/src/content/docs/reference/cli/pack.md +++ b/docs/src/content/docs/reference/cli/pack.md @@ -203,7 +203,6 @@ pack: bundle_files: .github/agents/architect.md: a1b2c3... lockfile_version: '1' -generated_at: ... dependencies: - repo_url: owner/repo ``` diff --git a/docs/src/content/docs/reference/lockfile-spec.md b/docs/src/content/docs/reference/lockfile-spec.md index fdda059c14..26b623105d 100644 --- a/docs/src/content/docs/reference/lockfile-spec.md +++ b/docs/src/content/docs/reference/lockfile-spec.md @@ -59,7 +59,6 @@ on any machine. ```yaml lockfile_version: "1" -generated_at: "2026-05-10T20:14:00+00:00" apm_version: "0.6.4" dependencies: - repo_url: https://github.com/acme-corp/security-baseline @@ -135,7 +134,7 @@ deployments: | Field | Type | Required | Notes | |---|---|---|---| | `lockfile_version` | string | yes | Schema version. `"1"` for plain Git projects; `"2"` when any dependency has `source: "registry"` or Git semver resolution fields (`constraint`, `resolved_tag`, `resolved_at`). | -| `generated_at` | ISO 8601 string | yes | UTC timestamp of the last write. Ignored by equivalence checks. | +| `generated_at` | ISO 8601 string | no | Legacy write timestamp. New lockfiles omit it; when an existing lockfile carries it, APM refreshes it on substantive writes. Ignored by equivalence checks. | | `apm_version` | string | no | APM CLI version that wrote the file. Diagnostic only. | | `dependencies` | list | yes | Resolved APM packages. See [per-entry fields](#per-entry-fields). | | `mcp_servers` | list of strings | no | Names of MCP servers managed as of the last install or update, including transitively contributed servers. | @@ -329,7 +328,11 @@ shipped. `apm install` only rewrites the file when its semantic content changes (`generated_at` and `apm_version` are ignored when comparing). A no-op install -leaves the file untouched. +leaves the file untouched. New lockfiles omit `generated_at` so independent +dependency changes do not manufacture timestamp conflicts. If a pre-existing +lockfile includes the field, APM retains it for compatibility and refreshes it +only on a substantive write. Remove the field once to opt an existing project +into timestamp-free output; APM will not add it back. ## Drift and integrity @@ -389,7 +392,6 @@ local skill: ```yaml lockfile_version: "1" -generated_at: "2026-05-10T20:14:00+00:00" apm_version: "0.6.4" dependencies: - repo_url: github.com/octocat/example-skills diff --git a/packages/apm-guide/.apm/skills/apm-usage/commands.md b/packages/apm-guide/.apm/skills/apm-usage/commands.md index 1948485de0..da9e3432a1 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/commands.md +++ b/packages/apm-guide/.apm/skills/apm-usage/commands.md @@ -350,6 +350,8 @@ Experimental flags MUST NOT gate security-critical behaviour (content scanning, | `apm lock` | Resolve all dependencies in `apm.yml` and write `apm.lock.yaml` **without** deploying or deleting files in agent targets. Existing deployed-file provenance stays recorded while those bytes remain on disk; the next normal install performs any deferred prune. Mirrors `cargo generate-lockfile` / `pnpm lock`. | `--update` re-resolve to latest upstream SHAs without accepting stale local bare refs, `--verbose`, `-g/--global`, `--no-policy`, `--target` (comma-separated), `--parallel-downloads N` | | `apm lock export` | Export an SBOM/inventory from the **existing** `apm.lock.yaml` -- reads the lockfile only (no re-resolve, no re-hash, no network). Emits component identity (purl), recorded hashes, and the declared license. Output is deterministic (components sorted by purl, pinned timestamp) for byte-identical reproducibility. Diagnostics and startup notices use stderr so stdout stays machine-readable. This is an inventory export, not a security attestation. | `-f/--format [cyclonedx\|spdx]` (default `cyclonedx`), `-o/--output FILE` (default stdout), `-g/--global` read user-scope lockfile, `--timestamp ISO8601` pin the document timestamp (falls back to `SOURCE_DATE_EPOCH`, then the lockfile's `generated_at`) | | `apm update [PKGS...]` | Refresh APM dependencies: resolves `apm.yml` against authenticated upstream refs, never stale bare-cache refs, prints a structured plan (added/updated/removed/unchanged), and prompts before changing refs (default `[y/N]`). Remote-resolution failure aborts without substituting cached ref state. Full-SHA pins move only to an eligible stable annotated semver tag. If none exists, APM keeps the current SHA, emits one summary warning, and continues unrelated updates; malformed or ambiguous tag records remain fatal before writes. Accepted pin updates are annotated as `# ` in `apm.yml`. Pass `[PKGS...]` to refresh only those deps, or `-g` for user scope (`~/.apm/`). Successful no-op updates still reconcile deployed artifacts, lockfile ownership, and merge-hook config/sidecar entries when the declared target set contracts. If the lock expects dependencies but `apm_modules/` is empty, an unchanged update restores the cache from the same refs without prompting or rewriting the manifest/lock; `--dry-run` remains read-only. `--force` changes collision/security handling, not freshness; use it only after independent verification. Strict superset of the deprecated `apm deps update`. Skips the ref-change prompt with `--yes`; previews with `--dry-run`. | `--yes`, `--dry-run`, `--verbose`, `-g/--global`, `--force`, `--parallel-downloads N`, `--target` (comma-separated) | +| `apm lock export` | Export an SBOM/inventory from the **existing** `apm.lock.yaml` -- reads the lockfile only (no re-resolve, no re-hash, no network). Emits component identity (purl), recorded hashes, and the declared license. Output is deterministic (components sorted by purl, pinned timestamp) for byte-identical reproducibility. Diagnostics and startup notices use stderr so stdout stays machine-readable. This is an inventory export, not a security attestation. | `-f/--format [cyclonedx\|spdx]` (default `cyclonedx`), `-o/--output FILE` (default stdout), `-g/--global` read user-scope lockfile, `--timestamp ISO8601` pin the document timestamp (falls back to `SOURCE_DATE_EPOCH`, then the lockfile's legacy `generated_at`, then the Unix epoch) | +| `apm update [PKGS...]` | Refresh APM dependencies: resolves `apm.yml` against authenticated upstream refs, never stale bare-cache refs, prints a structured plan (added/updated/removed/unchanged), and prompts before changing refs (default `[y/N]`). Remote-resolution failure aborts without substituting cached ref state. Full-SHA pins are resolved against the latest annotated semver tag, rewritten to that tag's SHA, and annotated as `# ` in `apm.yml`. Pass `[PKGS...]` to refresh only those deps, or `-g` for user scope (`~/.apm/`). Successful no-op updates still reconcile deployed artifacts, lockfile ownership, and merge-hook config/sidecar entries when the declared target set contracts. If the lock expects dependencies but `apm_modules/` is empty, an unchanged update restores the cache from the same refs without prompting or rewriting the manifest/lock; `--dry-run` remains read-only. `--force` changes collision/security handling, not freshness; use it only after independent verification. Strict superset of the deprecated `apm deps update`. Skips the ref-change prompt with `--yes`; previews with `--dry-run`. | `--yes`, `--dry-run`, `--verbose`, `-g/--global`, `--force`, `--parallel-downloads N`, `--target` (comma-separated) | | `apm self-update` | Update the APM CLI itself (or show distributor guidance when self-update is disabled at build time). | `--check` only check | `apm config set prefer-ssh true` and `apm config set allow-protocol-fallback true` persist transport preferences to `~/.apm/config.json` so SSH-only and corporate GHES users no longer need to re-pass `--ssh` / `--allow-protocol-fallback` on every `apm install`. Resolution order: CLI flag > `APM_GIT_PROTOCOL` / `APM_ALLOW_PROTOCOL_FALLBACK` env var > `apm config` value > built-in default (`false`). `apm config unset prefer-ssh` and `apm config unset allow-protocol-fallback` remove the persisted value. In `apm config` / `apm config list` / `apm config get` (no key), the two transport rows surface only when they have been enabled (the `false`-default rows are suppressed to keep the output noise-free); `apm config get ` always returns the effective value. Setting `allow-protocol-fallback=true` while `CI=1` emits a warning because the persisted value affects every subsequent `apm install` on a shared `$HOME`; prefer the env var in CI. diff --git a/src/apm_cli/commands/lock.py b/src/apm_cli/commands/lock.py index 4b8a6cf550..daebf62ad5 100644 --- a/src/apm_cli/commands/lock.py +++ b/src/apm_cli/commands/lock.py @@ -277,7 +277,7 @@ def _run_lock( help=( "Pin the SBOM timestamp (ISO 8601 with timezone required, e.g. " "2024-06-01T00:00:00+00:00) for reproducible output. Defaults to " - "SOURCE_DATE_EPOCH, then the lockfile's generated_at." + "generated_at, then the Unix epoch." ), ) def lock_export(fmt: str, output: str | None, global_: bool, timestamp: str | None) -> None: diff --git a/src/apm_cli/deps/lockfile.py b/src/apm_cli/deps/lockfile.py index 13d4207550..75c5d6fe7d 100644 --- a/src/apm_cli/deps/lockfile.py +++ b/src/apm_cli/deps/lockfile.py @@ -706,7 +706,7 @@ class LockFile: """APM lock file for reproducible dependency resolution.""" lockfile_version: str = "1" - generated_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat()) + generated_at: str | None = None apm_version: str | None = None dependencies: dict[str, LockedDependency] = field(default_factory=dict) mcp_servers: list[str] = field(default_factory=list) @@ -813,8 +813,9 @@ def to_yaml(self) -> str: try: data: dict[str, Any] = { "lockfile_version": emit_version, - "generated_at": self.generated_at, } + if self.generated_at is not None: + data["generated_at"] = self.generated_at if self.apm_version: data["apm_version"] = self.apm_version data["dependencies"] = [dep.to_dict() for dep in self.get_all_dependencies()] @@ -867,7 +868,7 @@ def from_yaml(cls, yaml_str: str) -> LockFile: data = _validate_lockfile_container(loaded) lock = cls( lockfile_version=data.get("lockfile_version", "1"), - generated_at=data.get("generated_at", ""), + generated_at=data.get("generated_at"), apm_version=data.get("apm_version"), ) for dep_data in data.get("dependencies", []): @@ -911,9 +912,23 @@ def from_yaml(cls, yaml_str: str) -> LockFile: return lock def write(self, path: Path) -> None: - """Write lock file to disk.""" + """Write lock file to disk, preserving legacy timestamp behavior. + + New lockfiles omit ``generated_at``. When the on-disk lockfile already + carries the field, keep it stable for semantic no-ops and refresh it for + substantive writes. This behavior should be changed to remove the legacy + timestamp in a future APM version, but for now it preserves backward + compatibility with older APM builds that expect the field. + """ from ..utils.atomic_io import atomic_write_text + existing = type(self).read(path) if path.exists() else None + if existing is not None and existing.generated_at is not None: + if self.is_semantically_equivalent(existing): + if self.generated_at is None: + self.generated_at = existing.generated_at + else: + self.generated_at = datetime.now(timezone.utc).isoformat() atomic_write_text(path, self.to_yaml()) @classmethod diff --git a/src/apm_cli/integration/mcp_integrator.py b/src/apm_cli/integration/mcp_integrator.py index 21c19096e0..b0b2b404b6 100644 --- a/src/apm_cli/integration/mcp_integrator.py +++ b/src/apm_cli/integration/mcp_integrator.py @@ -19,7 +19,6 @@ import shutil import warnings from collections.abc import MutableMapping -from datetime import datetime, timezone from pathlib import Path from typing import TYPE_CHECKING @@ -859,7 +858,6 @@ def update_lockfile( ): _log.debug("MCP lockfile unchanged -- skipping write") return - lockfile.generated_at = datetime.now(timezone.utc).isoformat() lockfile.save(lock_path) except Exception as exc: _log.debug( diff --git a/tests/integration/test_cache_lockfile_parity.py b/tests/integration/test_cache_lockfile_parity.py index 11a15ccbff..8e33c27a16 100644 --- a/tests/integration/test_cache_lockfile_parity.py +++ b/tests/integration/test_cache_lockfile_parity.py @@ -3,13 +3,13 @@ Regression-trap for the worst silent failure the cache layer could introduce: lockfile drift between cached and non-cached runs. If ``apm install`` produces a different ``apm.lock.yaml`` (modulo the -``generated_at`` write-timestamp) when ``APM_NO_CACHE=1`` is set vs. +optional legacy ``generated_at`` write metadata) when ``APM_NO_CACHE=1`` is set vs. when the cache is hot, a CI run that ships with a stale cache would commit a lockfile that disagrees with the reproducible-from-scratch baseline -- and downstream installs would diverge. The contract: ``apm install`` from the same ``apm.yml`` MUST produce -a content-identical lockfile (excluding ``generated_at``) regardless +a content-identical lockfile (excluding legacy ``generated_at`` metadata) regardless of cache state. This test asserts it across three regimes: Run A: cold cache (cache empty) @@ -61,10 +61,10 @@ def _run_install( def _lockfile_sha(project: Path) -> str: """Hash the lockfile excluding the `generated_at` line. - `generated_at` is a wall-clock timestamp captured at write time, so it - necessarily differs between independent runs. The parity invariant is - about resolution outcome (resolved_commit, content_hash, deployed_files, - package_type, ...), not the write timestamp. + Legacy lockfiles may carry `generated_at`, a wall-clock timestamp refreshed + at write time. The parity invariant is about resolution outcome + (resolved_commit, content_hash, deployed_files, package_type, ...), not + optional compatibility metadata. """ lock = project / "apm.lock.yaml" assert lock.is_file(), "apm.lock.yaml not produced by install" @@ -95,7 +95,7 @@ def test_lockfile_byte_identical_across_cache_regimes( hermetic_packaged_sample: HermeticPackagedSample, tmp_path: Path, ) -> None: - """A, B, C must produce content-identical apm.lock.yaml (modulo `generated_at`). + """A, B, C must match after excluding optional legacy `generated_at` metadata. A: cold cache (fresh APM_CACHE_DIR pointing at empty dir) B: warm cache (same dir, second run reuses entries) diff --git a/tests/integration/test_config_surface_lifecycle_contract.py b/tests/integration/test_config_surface_lifecycle_contract.py index b5ea6ad77a..59715d82a9 100644 --- a/tests/integration/test_config_surface_lifecycle_contract.py +++ b/tests/integration/test_config_surface_lifecycle_contract.py @@ -227,7 +227,7 @@ def _assert_semantic_lifecycle_state( expected: LifecycleStateSnapshot, actual: LifecycleStateSnapshot, ) -> None: - """Assert convergence while ignoring the separate generated-at churn lane.""" + """Assert convergence while ignoring optional legacy generated-at metadata.""" assert actual.manifest_bytes == expected.manifest_bytes assert actual.deployment_records == expected.deployment_records assert actual.mcp_state_bytes == expected.mcp_state_bytes diff --git a/tests/integration/test_install_lsp_lockfile_determinism.py b/tests/integration/test_install_lsp_lockfile_determinism.py index 22e4b7bedb..c9a7f27e40 100644 --- a/tests/integration/test_install_lsp_lockfile_determinism.py +++ b/tests/integration/test_install_lsp_lockfile_determinism.py @@ -58,6 +58,7 @@ def test_repeated_install_with_unchanged_lsp_keeps_lockfile_bytes( first_bytes = lock_path.read_bytes() first_lock = LockFile.read(lock_path) assert first_lock is not None + assert first_lock.generated_at is None assert first_lock.lsp_servers == ["pyright"] second_result = runner.invoke(cli, ["install", "--target", "copilot"]) diff --git a/tests/integration/test_install_mcp_lockfile_determinism.py b/tests/integration/test_install_mcp_lockfile_determinism.py index bebf38a6e5..1e3b01d0ba 100644 --- a/tests/integration/test_install_mcp_lockfile_determinism.py +++ b/tests/integration/test_install_mcp_lockfile_determinism.py @@ -264,6 +264,7 @@ def test_installed_mcp_lifecycle_is_no_write_until_real_target_change( baseline = _snapshot(project_root) baseline_lock = _lock(project_root) baseline_identity = _file_identity(lock_path) + assert baseline_lock.generated_at is None assert baseline_lock.mcp_target_servers == _TARGET_SERVERS assert baseline_lock.mcp_config_provenance == {_SERVER_NAME: "local-mcp"} assert baseline_lock.mcp_configs[_SERVER_NAME]["registry"] is False @@ -301,7 +302,7 @@ def test_installed_mcp_lifecycle_is_no_write_until_real_target_change( changed_lock = _lock(project_root) changed_identity = _file_identity(lock_path) assert changed.lockfile_bytes != baseline.lockfile_bytes - assert changed_lock.generated_at != baseline_lock.generated_at + assert changed_lock.generated_at is None assert changed_lock.mcp_target_servers == {"copilot": [_SERVER_NAME]} assert changed_identity != baseline_identity assert { diff --git a/tests/integration/test_oci_mcp_lifecycle_contract.py b/tests/integration/test_oci_mcp_lifecycle_contract.py index 8bfb9e198e..9370792c2f 100644 --- a/tests/integration/test_oci_mcp_lifecycle_contract.py +++ b/tests/integration/test_oci_mcp_lifecycle_contract.py @@ -234,7 +234,7 @@ def _assert_idempotent_state( first: LifecycleStateSnapshot, second: LifecycleStateSnapshot, ) -> None: - """Assert every durable field except the lock generation timestamp is exact.""" + """Assert every durable field except optional legacy timestamp metadata is exact.""" assert second.manifest_bytes == first.manifest_bytes assert second.deployment_records == first.deployment_records assert second.mcp_state_bytes == first.mcp_state_bytes diff --git a/tests/test_lockfile.py b/tests/test_lockfile.py index 032ed6bacb..ebb20a99ac 100644 --- a/tests/test_lockfile.py +++ b/tests/test_lockfile.py @@ -1,5 +1,6 @@ """Tests for the APM lock file module.""" +from datetime import datetime, timezone from pathlib import Path from unittest.mock import Mock @@ -234,6 +235,7 @@ def test_to_yaml(self): yaml_str = lock.to_yaml() data = yaml.safe_load(yaml_str) assert data["lockfile_version"] == "1" + assert "generated_at" not in data assert len(data["dependencies"]) == 1 def test_from_yaml(self): @@ -252,6 +254,26 @@ def test_write_and_read(self, tmp_path): assert loaded is not None assert loaded.has_dependency("owner/repo") + def test_write_refreshes_existing_generated_at(self, tmp_path, monkeypatch): + lock_path = tmp_path / "apm.lock.yaml" + lock_path.write_text( + "lockfile_version: '1'\ngenerated_at: '2025-01-01T00:00:00+00:00'\ndependencies: []\n", + encoding="utf-8", + ) + lock = LockFile.read(lock_path) + assert lock is not None + lock.add_dependency(LockedDependency(repo_url="owner/repo")) + next_write = datetime(2026, 1, 1, tzinfo=timezone.utc) + fixed_datetime = Mock() + fixed_datetime.now.return_value = next_write + monkeypatch.setattr("apm_cli.deps.lockfile.datetime", fixed_datetime) + + lock.write(lock_path) + + assert yaml.safe_load(lock_path.read_text(encoding="utf-8"))["generated_at"] == ( + next_write.isoformat() + ) + def test_mcp_servers_round_trip(self, tmp_path): """mcp_servers must survive a write → read cycle.""" lock = LockFile(apm_version="1.0.0") diff --git a/tests/unit/commands/test_lock_export_command.py b/tests/unit/commands/test_lock_export_command.py index 309a6a37bd..8cf37e697c 100644 --- a/tests/unit/commands/test_lock_export_command.py +++ b/tests/unit/commands/test_lock_export_command.py @@ -151,6 +151,23 @@ def test_export_invalid_timestamp_exits_2(runner, tmp_path, timestamp): assert "Expected timezone-aware ISO 8601 format" in result.stderr +def test_export_without_generated_at_uses_fixed_epoch(runner, tmp_path): + with runner.isolated_filesystem(temp_dir=tmp_path): + _seed(Path.cwd()) + lock_path = Path("apm.lock.yaml") + lock_path.write_text( + lock_path.read_text(encoding="utf-8").replace( + 'generated_at: "2024-01-01T00:00:00+00:00"\n', "" + ), + encoding="utf-8", + ) + + result = runner.invoke(cli, ["lock", "export"]) + + assert result.exit_code == 0, result.output + assert json.loads(result.output)["metadata"]["timestamp"] == ("1970-01-01T00:00:00+00:00") + + def test_export_undeclared_omits_licenses(runner, tmp_path): with runner.isolated_filesystem(temp_dir=tmp_path): _seed(Path.cwd()) diff --git a/tests/unit/install/test_mcp_lockfile_determinism.py b/tests/unit/install/test_mcp_lockfile_determinism.py index 4cb20d3229..93b10dca9d 100644 --- a/tests/unit/install/test_mcp_lockfile_determinism.py +++ b/tests/unit/install/test_mcp_lockfile_determinism.py @@ -135,10 +135,7 @@ def _run_lockfile_phase_and_mcp_persist( ctx.package_types = {dep_key: package_type} _FixedDatetime.instant = instant - with ( - patch("apm_cli.deps.lockfile.datetime", _FixedDatetime), - patch("apm_cli.integration.mcp_integrator.datetime", _FixedDatetime), - ): + with patch("apm_cli.deps.lockfile.datetime", _FixedDatetime): LockfileBuilder(ctx).build_and_save() mcp_deps = package.get_mcp_dependencies() MCPIntegrator.update_lockfile( @@ -239,7 +236,7 @@ def test_unchanged_local_instructions_do_not_rewrite_lockfile(tmp_path: Path) -> first_bytes = lock_path.read_bytes() first_lock = LockFile.read(lock_path) assert first_lock is not None - assert first_lock.generated_at == first_instant.isoformat() + assert first_lock.generated_at is None assert first_lock.local_deployed_files == [".github/instructions/local.instructions.md"] _run_lockfile_phase_and_local_persist(tmp_path, second_instant) @@ -263,7 +260,7 @@ def test_unchanged_mcp_dependencies_do_not_rewrite_lockfile(tmp_path: Path) -> N first_bytes = lock_path.read_bytes() first_lock = LockFile.read(lock_path) assert first_lock is not None - assert first_lock.generated_at == first_instant.isoformat() + assert first_lock.generated_at is None _run_lockfile_phase_and_mcp_persist(tmp_path, package, second_instant) second_bytes = lock_path.read_bytes() @@ -288,7 +285,7 @@ def test_unchanged_mcp_target_servers_do_not_rewrite_lockfile(tmp_path: Path) -> first_bytes = lock_path.read_bytes() first_lock = LockFile.read(lock_path) assert first_lock is not None - assert first_lock.generated_at == first_instant.isoformat() + assert first_lock.generated_at is None assert first_lock.mcp_target_servers == target_servers second_context = _run_lockfile_phase_and_mcp_persist( @@ -350,7 +347,7 @@ def track_changed_write(lockfile: LockFile, path: Path) -> None: changed_lock = LockFile.read(lock_path) assert changed_lock is not None assert changed_writes == [lock_path] - assert changed_lock.generated_at == second_instant.isoformat() + assert changed_lock.generated_at is None assert changed_lock.mcp_target_servers == changed_targets converged_writes: list[Path] = [] @@ -454,7 +451,7 @@ def track_repair(lockfile: LockFile, path: Path) -> None: repaired = LockFile.read(lock_path) assert repaired is not None assert repair_writes == [lock_path] - assert repaired.generated_at == second_instant.isoformat() + assert repaired.generated_at is None assert repaired.mcp_config_provenance == {} repaired_bytes = lock_path.read_bytes() @@ -640,7 +637,7 @@ def test_changed_mcp_dependencies_update_lockfile(tmp_path: Path) -> None: second_lock = LockFile.read(lock_path) assert second_lock is not None - assert second_lock.generated_at == second_instant.isoformat() + assert second_lock.generated_at is None assert second_lock.mcp_servers == ["github"] assert second_lock.mcp_configs == { "github": { @@ -653,6 +650,30 @@ def test_changed_mcp_dependencies_update_lockfile(tmp_path: Path) -> None: assert second_bytes != first_bytes +def test_changed_mcp_dependencies_refresh_legacy_generated_at(tmp_path: Path) -> None: + """A substantive rewrite keeps legacy timestamp metadata current.""" + package = _write_manifest_with_mcp(tmp_path) + first_instant = datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc) + second_instant = datetime(2026, 1, 1, 0, 1, 0, tzinfo=timezone.utc) + + _run_lockfile_phase_and_mcp_persist(tmp_path, package, first_instant) + lock_path = get_lockfile_path(tmp_path) + legacy = load_yaml(lock_path) + legacy["generated_at"] = first_instant.isoformat() + dump_yaml(legacy, lock_path) + + changed_package = _write_manifest_with_mcp( + tmp_path, + server_name="github", + server_url="https://api.githubcopilot.com/mcp/", + ) + _run_lockfile_phase_and_mcp_persist(tmp_path, changed_package, second_instant) + + changed_lock = LockFile.read(lock_path) + assert changed_lock is not None + assert changed_lock.generated_at == second_instant.isoformat() + + def test_unchanged_lsp_dependencies_do_not_rewrite_lockfile(tmp_path: Path) -> None: """The real lockfile phase stays byte-stable when LSP inputs are unchanged.""" package = _write_manifest_with_lsp(tmp_path) @@ -664,7 +685,7 @@ def test_unchanged_lsp_dependencies_do_not_rewrite_lockfile(tmp_path: Path) -> N first_bytes = lock_path.read_bytes() first_lock = LockFile.read(lock_path) assert first_lock is not None - assert first_lock.generated_at == first_instant.isoformat() + assert first_lock.generated_at is None _run_lockfile_phase_and_lsp_persist(tmp_path, package, second_instant) second_bytes = lock_path.read_bytes() diff --git a/tests/unit/integration/test_mcp_integrator.py b/tests/unit/integration/test_mcp_integrator.py index 8943760838..e95d9d6656 100644 --- a/tests/unit/integration/test_mcp_integrator.py +++ b/tests/unit/integration/test_mcp_integrator.py @@ -585,6 +585,8 @@ def test_creates_lockfile_when_missing(self, tmp_path): lock = LockFile.read(missing) assert lock is not None assert lock.mcp_servers == ["svc"] + assert lock.generated_at is None + assert "generated_at" not in missing.read_text(encoding="utf-8") def test_mcp_servers_sorted_in_lockfile(self, tmp_path): lock_path = tmp_path / "apm.lock.yaml" From 2b44f740edad0f4512cc42e002094a7a00af6c1c Mon Sep 17 00:00:00 2001 From: Lachlan Heywood Date: Tue, 18 Aug 2026 15:22:37 -0400 Subject: [PATCH 02/13] fix: address lockfile review feedback --- CHANGELOG.md | 2 +- tests/unit/commands/test_lock_export_command.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be6c37f532..5e49cf6bf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -130,7 +130,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Newly generated `apm.lock.yaml` files no longer include volatile `generated_at` metadata. Existing lockfiles that carry the field continue to refresh it on substantive writes, preventing unrelated dependency - updates from producing timestamp-only merge conflicts. (closes #2572) + updates from producing timestamp-only merge conflicts. (#2616) - Lockfiles generated on Windows for marketplace-plugin / skill-subset git dependencies now pass `apm install --frozen` on Linux, and vice versa, by hashing synthetic manifests with deterministic LF line endings. diff --git a/tests/unit/commands/test_lock_export_command.py b/tests/unit/commands/test_lock_export_command.py index 8cf37e697c..609f438df2 100644 --- a/tests/unit/commands/test_lock_export_command.py +++ b/tests/unit/commands/test_lock_export_command.py @@ -155,12 +155,13 @@ def test_export_without_generated_at_uses_fixed_epoch(runner, tmp_path): with runner.isolated_filesystem(temp_dir=tmp_path): _seed(Path.cwd()) lock_path = Path("apm.lock.yaml") + lock_text = lock_path.read_text(encoding="utf-8").replace("\r\n", "\n") lock_path.write_text( - lock_path.read_text(encoding="utf-8").replace( - 'generated_at: "2024-01-01T00:00:00+00:00"\n', "" - ), + lock_text.replace('generated_at: "2024-01-01T00:00:00+00:00"\n', ""), encoding="utf-8", + newline="", ) + assert "generated_at" not in lock_path.read_text(encoding="utf-8") result = runner.invoke(cli, ["lock", "export"]) From d7bd258ccc7d740d8b5d4ebca167fd085e62081f Mon Sep 17 00:00:00 2001 From: Lachlan Heywood Date: Tue, 18 Aug 2026 17:32:39 -0400 Subject: [PATCH 03/13] test(lockfile): cover legacy timestamp preservation --- docs/src/content/docs/reference/lockfile-spec.md | 6 +++--- src/apm_cli/deps/lockfile.py | 3 ++- tests/test_lockfile.py | 15 +++++++++++++++ 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/docs/src/content/docs/reference/lockfile-spec.md b/docs/src/content/docs/reference/lockfile-spec.md index 26b623105d..b9ab33661e 100644 --- a/docs/src/content/docs/reference/lockfile-spec.md +++ b/docs/src/content/docs/reference/lockfile-spec.md @@ -134,7 +134,7 @@ deployments: | Field | Type | Required | Notes | |---|---|---|---| | `lockfile_version` | string | yes | Schema version. `"1"` for plain Git projects; `"2"` when any dependency has `source: "registry"` or Git semver resolution fields (`constraint`, `resolved_tag`, `resolved_at`). | -| `generated_at` | ISO 8601 string | no | Legacy write timestamp. New lockfiles omit it; when an existing lockfile carries it, APM refreshes it on substantive writes. Ignored by equivalence checks. | +| _(Deprecated)_ `generated_at` | ISO 8601 string | no | Legacy write timestamp. New lockfiles omit it; when an existing lockfile carries it, APM refreshes it on substantive writes. Ignored by equivalence checks. | | `apm_version` | string | no | APM CLI version that wrote the file. Diagnostic only. | | `dependencies` | list | yes | Resolved APM packages. See [per-entry fields](#per-entry-fields). | | `mcp_servers` | list of strings | no | Names of MCP servers managed as of the last install or update, including transitively contributed servers. | @@ -331,8 +331,8 @@ shipped. leaves the file untouched. New lockfiles omit `generated_at` so independent dependency changes do not manufacture timestamp conflicts. If a pre-existing lockfile includes the field, APM retains it for compatibility and refreshes it -only on a substantive write. Remove the field once to opt an existing project -into timestamp-free output; APM will not add it back. +only on a substantive write. To migrate a legacy lockfile manually, delete the +`generated_at: ...` line from `apm.lock.yaml` once; APM will not add it back. ## Drift and integrity diff --git a/src/apm_cli/deps/lockfile.py b/src/apm_cli/deps/lockfile.py index 75c5d6fe7d..71462b2071 100644 --- a/src/apm_cli/deps/lockfile.py +++ b/src/apm_cli/deps/lockfile.py @@ -918,7 +918,8 @@ def write(self, path: Path) -> None: carries the field, keep it stable for semantic no-ops and refresh it for substantive writes. This behavior should be changed to remove the legacy timestamp in a future APM version, but for now it preserves backward - compatibility with older APM builds that expect the field. + compatibility with older APM builds that expect the field. This method + may mutate ``self.generated_at`` to preserve or refresh that metadata. """ from ..utils.atomic_io import atomic_write_text diff --git a/tests/test_lockfile.py b/tests/test_lockfile.py index ebb20a99ac..7bc431c708 100644 --- a/tests/test_lockfile.py +++ b/tests/test_lockfile.py @@ -274,6 +274,21 @@ def test_write_refreshes_existing_generated_at(self, tmp_path, monkeypatch): next_write.isoformat() ) + def test_write_preserves_existing_generated_at_on_noop(self, tmp_path): + lock_path = tmp_path / "apm.lock.yaml" + original_timestamp = "2025-01-01T00:00:00+00:00" + lock_path.write_text( + f"lockfile_version: '1'\ngenerated_at: '{original_timestamp}'\ndependencies: []\n", + encoding="utf-8", + ) + lock = LockFile() + + lock.write(lock_path) + + written = yaml.safe_load(lock_path.read_text(encoding="utf-8")) + assert written["generated_at"] == original_timestamp + assert lock.generated_at == original_timestamp + def test_mcp_servers_round_trip(self, tmp_path): """mcp_servers must survive a write → read cycle.""" lock = LockFile(apm_version="1.0.0") From 703e6cb9ccd14c2870c4db923d614546e6e48462 Mon Sep 17 00:00:00 2001 From: Lachlan Heywood Date: Sat, 22 Aug 2026 17:08:04 -0400 Subject: [PATCH 04/13] fix(lockfile): avoid validating timestamp-free overwrites --- src/apm_cli/deps/lockfile.py | 11 ++++++++++- tests/test_lockfile.py | 13 +++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/apm_cli/deps/lockfile.py b/src/apm_cli/deps/lockfile.py index 71462b2071..c2aba5e3f9 100644 --- a/src/apm_cli/deps/lockfile.py +++ b/src/apm_cli/deps/lockfile.py @@ -922,8 +922,17 @@ def write(self, path: Path) -> None: may mutate ``self.generated_at`` to preserve or refresh that metadata. """ from ..utils.atomic_io import atomic_write_text + from ..utils.yaml_io import load_yaml_str - existing = type(self).read(path) if path.exists() else None + existing = None + if path.exists(): + existing_text = path.read_text(encoding="utf-8") + try: + existing_data = load_yaml_str(existing_text) + except (yaml.YAMLError, ValueError): + existing_data = None + if isinstance(existing_data, dict) and existing_data.get("generated_at") is not None: + existing = type(self).from_yaml(existing_text) if existing is not None and existing.generated_at is not None: if self.is_semantically_equivalent(existing): if self.generated_at is None: diff --git a/tests/test_lockfile.py b/tests/test_lockfile.py index 7bc431c708..ebcdb60158 100644 --- a/tests/test_lockfile.py +++ b/tests/test_lockfile.py @@ -289,6 +289,19 @@ def test_write_preserves_existing_generated_at_on_noop(self, tmp_path): assert written["generated_at"] == original_timestamp assert lock.generated_at == original_timestamp + def test_write_overwrites_timestamp_free_file_without_schema_validation(self, tmp_path): + lock_path = tmp_path / "apm.lock.yaml" + lock_path.write_text( + "lockfile_version: '1'\ndependencies: {}\n", + encoding="utf-8", + ) + + LockFile().write(lock_path) + + written = yaml.safe_load(lock_path.read_text(encoding="utf-8")) + assert written["dependencies"] == [] + assert "generated_at" not in written + def test_mcp_servers_round_trip(self, tmp_path): """mcp_servers must survive a write → read cycle.""" lock = LockFile(apm_version="1.0.0") From b1296e2c47dd2e815b22c78263651e67819cb75f Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Sun, 23 Aug 2026 19:53:38 +0200 Subject: [PATCH 05/13] fix: preserve deterministic timestamp-free exports --- .../owners/contracts-tooling.json | 7 +++ CHANGELOG.md | 6 +-- .../checks/contracts_test_taxonomy.py | 47 +++++++++++++++++ scripts/architecture_linter/diagnostics.py | 1 + src/apm_cli/bundle/agent_plugin_exporter.py | 8 ++- src/apm_cli/commands/lock.py | 17 ++----- src/apm_cli/deps/lockfile.py | 35 +++++++++++-- .../test_architecture_owner_rule_mutations.py | 10 +++- tests/test_lockfile.py | 51 +++++++++++++++++++ tests/unit/test_agent_plugin_exporter.py | 13 +++++ 10 files changed, 174 insertions(+), 21 deletions(-) diff --git a/.apm/architecture/owners/contracts-tooling.json b/.apm/architecture/owners/contracts-tooling.json index cb21eb615a..ad66f83b0f 100644 --- a/.apm/architecture/owners/contracts-tooling.json +++ b/.apm/architecture/owners/contracts-tooling.json @@ -43,6 +43,13 @@ "selectors": ["src/apm_cli/deps/lockfile.py"], "guards": ["contracts-tooling-lockfile-read"] }, + { + "id": "lockfile-timestamp-emission", + "decision": "Lockfile timestamp emission and reproducible fallback", + "owner": "deps/lockfile.py (LockFile.write, resolve_reproducible_timestamp)", + "selectors": ["src/apm_cli/deps/lockfile.py"], + "guards": ["contracts-tooling-lockfile-timestamp"] + }, { "id": "generated-content-footer-wording", "decision": "Generated-content footer ownership wording", diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e49cf6bf8..cbc6576568 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -128,9 +128,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 keeping generated metadata byte-stable across operating systems. (by @WilliamK112, #2638) - Newly generated `apm.lock.yaml` files no longer include volatile - `generated_at` metadata. Existing lockfiles that carry the field continue - to refresh it on substantive writes, preventing unrelated dependency - updates from producing timestamp-only merge conflicts. (#2616) + `generated_at` metadata, preventing timestamp-only merge conflicts. Existing + lockfiles preserve and refresh the legacy field until it is removed once; + later writes do not add it back. (#2616) - Lockfiles generated on Windows for marketplace-plugin / skill-subset git dependencies now pass `apm install --frozen` on Linux, and vice versa, by hashing synthetic manifests with deterministic LF line endings. diff --git a/scripts/architecture_linter/checks/contracts_test_taxonomy.py b/scripts/architecture_linter/checks/contracts_test_taxonomy.py index b62e19ad4d..aa3386fe12 100644 --- a/scripts/architecture_linter/checks/contracts_test_taxonomy.py +++ b/scripts/architecture_linter/checks/contracts_test_taxonomy.py @@ -69,6 +69,9 @@ _GUARD_LOCKFILE_READ = "contracts-tooling-lockfile-read" +_GUARD_LOCKFILE_TIMESTAMP = "contracts-tooling-lockfile-timestamp" + + _GUARD_GENERATION_FOOTER = "contracts-tooling-generation-footer" @@ -252,6 +255,45 @@ def check_lockfile_read_resolution(provider: FactsProvider) -> tuple[Violation, return tuple(findings) +def _assigns_generated_at(target: ast.expr) -> bool: + """Return whether an assignment target writes lockfile timestamp metadata.""" + if isinstance(target, ast.Attribute): + return target.attr == "generated_at" + if isinstance(target, (ast.List, ast.Tuple)): + return any(_assigns_generated_at(item) for item in target.elts) + return False + + +def check_lockfile_timestamp_authority(provider: FactsProvider) -> tuple[Violation, ...]: + """Lockfile timestamp writes must stay inside the lockfile owner.""" + rule_id = _GUARD_LOCKFILE_TIMESTAMP + findings: list[Violation] = [] + for path in _python_paths(provider, _SRC_PREFIX): + if path == _LOCKFILE_OWNER: + continue + facts, failures = _facts_for(provider, path, rule_id) + findings.extend(failures) + if failures or facts.tree_index is None: + continue + for node in facts.tree_index.nodes: + if isinstance(node, ast.Assign): + targets = node.targets + elif isinstance(node, (ast.AnnAssign, ast.AugAssign)): + targets = (node.target,) + else: + continue + if any(_assigns_generated_at(target) for target in targets): + findings.append( + violation( + rule_id, + path, + "Lockfile timestamp writes must route through deps/lockfile.py", + line=node.lineno, + ) + ) + return tuple(findings) + + _TAXONOMY_PLUGIN = "tests/quality/taxonomy_inventory_plugin.py" @@ -728,6 +770,11 @@ def _structural_rule(rule_id: str, description: str, check) -> Rule: "Read-only lockfile path resolution stays owned by deps/lockfile.py.", check_lockfile_read_resolution, ), + _owner_rule( + _GUARD_LOCKFILE_TIMESTAMP, + "Lockfile timestamp emission stays owned by deps/lockfile.py.", + check_lockfile_timestamp_authority, + ), _owner_rule( _GUARD_GENERATION_FOOTER, "Generated-content footer wording stays owned by compilation/footer.py.", diff --git a/scripts/architecture_linter/diagnostics.py b/scripts/architecture_linter/diagnostics.py index b74a4d6be9..25b77699d9 100644 --- a/scripts/architecture_linter/diagnostics.py +++ b/scripts/architecture_linter/diagnostics.py @@ -35,6 +35,7 @@ "contracts-tooling-cached-policy-shape": ("AC3",), "contracts-tooling-dependency-identity": ("AC23", "AC25", "AC29"), "contracts-tooling-frontmatter-yaml": ("AC36",), + "contracts-tooling-lockfile-timestamp": ("AC2",), "install-deployment-approval-outcome-routing": ("AC3",), "install-deployment-audit-policy-discovery": ("AC3",), "install-deployment-audit-replay": ("AC4",), diff --git a/src/apm_cli/bundle/agent_plugin_exporter.py b/src/apm_cli/bundle/agent_plugin_exporter.py index 9492737de8..9c6655f992 100644 --- a/src/apm_cli/bundle/agent_plugin_exporter.py +++ b/src/apm_cli/bundle/agent_plugin_exporter.py @@ -20,7 +20,11 @@ load_agent_plugin, url_contains_literal_secret, ) -from ..deps.lockfile import LockFile, resolve_lockfile_path_for_read +from ..deps.lockfile import ( + LockFile, + resolve_lockfile_path_for_read, + resolve_reproducible_timestamp, +) from ..deps.plugin_parser import synthesize_plugin_json_from_apm_yml from ..models.apm_package import APMPackage from ..utils.archive import ( @@ -493,7 +497,7 @@ def export_agent_plugin_bundle( BundleFormat.AGENT_PLUGIN.lock_value, target or "all", bundle_files=bundle_files, - packed_at=lockfile.generated_at, + packed_at=resolve_reproducible_timestamp(None, lockfile.generated_at), ) write_text_lf(staged_bundle / "apm.lock.yaml", enriched_yaml) diff --git a/src/apm_cli/commands/lock.py b/src/apm_cli/commands/lock.py index daebf62ad5..681176bb09 100644 --- a/src/apm_cli/commands/lock.py +++ b/src/apm_cli/commands/lock.py @@ -277,7 +277,7 @@ def _run_lock( help=( "Pin the SBOM timestamp (ISO 8601 with timezone required, e.g. " "2024-06-01T00:00:00+00:00) for reproducible output. Defaults to " - "generated_at, then the Unix epoch." + "SOURCE_DATE_EPOCH, then the lockfile's legacy generated_at, then the Unix epoch." ), ) def lock_export(fmt: str, output: str | None, global_: bool, timestamp: str | None) -> None: @@ -321,8 +321,9 @@ def _resolve_export_timestamp(explicit: str | None, lockfile_generated_at: str | the lockfile's ``generated_at`` > a fixed epoch. Pinning keeps export byte-deterministic across runs. """ - import os - from datetime import datetime, timezone + from datetime import datetime + + from apm_cli.deps.lockfile import resolve_reproducible_timestamp if explicit is not None: try: @@ -337,15 +338,7 @@ def _resolve_export_timestamp(explicit: str | None, lockfile_generated_at: str | param_hint="'--timestamp'", ) return dt.isoformat() - epoch = os.environ.get("SOURCE_DATE_EPOCH") - if epoch: - try: - return datetime.fromtimestamp(int(epoch), tz=timezone.utc).isoformat() - except (ValueError, OverflowError, OSError): - pass - if lockfile_generated_at: - return lockfile_generated_at - return "1970-01-01T00:00:00+00:00" + return resolve_reproducible_timestamp(None, lockfile_generated_at) def _normalize_utc_designator(value: str) -> str: diff --git a/src/apm_cli/deps/lockfile.py b/src/apm_cli/deps/lockfile.py index c2aba5e3f9..0cb3266e87 100644 --- a/src/apm_cli/deps/lockfile.py +++ b/src/apm_cli/deps/lockfile.py @@ -6,6 +6,7 @@ from __future__ import annotations import logging +import os from dataclasses import dataclass, field from datetime import datetime, timezone from pathlib import Path @@ -28,6 +29,7 @@ _ALLOWED_HOST_TYPES = set(accepted_host_types()) _ALLOWED_EXEC_STATUS = {"deployed", "gated_pending_approval", "denied", "absent"} SUPPORTED_LOCKFILE_VERSIONS = frozenset({"1", "2"}) +_REPRODUCIBLE_EPOCH = "1970-01-01T00:00:00+00:00" def installed_apm_version() -> str: @@ -40,6 +42,25 @@ def installed_apm_version() -> str: return "unknown" +def resolve_reproducible_timestamp( + explicit: str | None, + lockfile_generated_at: str | None, +) -> str: + """Resolve stable metadata time from explicit, environment, or legacy input.""" + if explicit: + return explicit + source_date_epoch = os.environ.get("SOURCE_DATE_EPOCH") + if source_date_epoch: + try: + return datetime.fromtimestamp( + int(source_date_epoch), + tz=timezone.utc, + ).isoformat() + except (ValueError, OverflowError, OSError): + pass + return lockfile_generated_at or _REPRODUCIBLE_EPOCH + + class LockfileFormatError(ValueError): """Raised when a lockfile container does not match its schema.""" @@ -866,6 +887,11 @@ def from_yaml(cls, yaml_str: str) -> LockFile: except (yaml.YAMLError, ValueError) as exc: raise LockfileFormatError(f"Invalid lockfile YAML: {exc}") from exc data = _validate_lockfile_container(loaded) + return cls._from_validated_data(data) + + @classmethod + def _from_validated_data(cls, data: dict[str, Any]) -> LockFile: + """Construct a lockfile from an already validated YAML mapping.""" lock = cls( lockfile_version=data.get("lockfile_version", "1"), generated_at=data.get("generated_at"), @@ -932,13 +958,16 @@ def write(self, path: Path) -> None: except (yaml.YAMLError, ValueError): existing_data = None if isinstance(existing_data, dict) and existing_data.get("generated_at") is not None: - existing = type(self).from_yaml(existing_text) + existing = type(self)._from_validated_data( + _validate_lockfile_container(existing_data) + ) if existing is not None and existing.generated_at is not None: if self.is_semantically_equivalent(existing): - if self.generated_at is None: - self.generated_at = existing.generated_at + self.generated_at = existing.generated_at else: self.generated_at = datetime.now(timezone.utc).isoformat() + else: + self.generated_at = None atomic_write_text(path, self.to_yaml()) @classmethod diff --git a/tests/integration/test_architecture_owner_rule_mutations.py b/tests/integration/test_architecture_owner_rule_mutations.py index 4bd31766f5..7c09a3ac05 100644 --- a/tests/integration/test_architecture_owner_rule_mutations.py +++ b/tests/integration/test_architecture_owner_rule_mutations.py @@ -6,7 +6,7 @@ every guard executes exactly once per run. Names prove nothing about teeth: a rule whose body was gutted still registers its guard ID and still runs. -This file supplies the missing half of that contract. For each of the 55 +This file supplies the missing half of that contract. For each of the 56 registered owner guards it pins one minimal, meaningful source mutation -- a surgical edit that kills a load-bearing sub-condition of the owning decision -- and asserts the one rule that owns that guard reports a real `Violation`. @@ -147,6 +147,14 @@ class MutationCase: new=" if False and read_only:\n", intent="Read-only lockfile resolution stops guarding the mutating migration path.", ), + MutationCase( + guard_id="contracts-tooling-lockfile-timestamp", + rule_id="contracts-tooling-lockfile-timestamp", + path="src/apm_cli/integration/mcp_integrator.py", + old="_log = logging.getLogger(__name__)", + new="_log = logging.getLogger(__name__)\nMCPIntegrator.generated_at = None", + intent="An MCP consumer writes lockfile timestamp metadata outside its owner.", + ), MutationCase( guard_id="hooks-integrations-copilot-cli-mcp-paths", rule_id="mutation_writes.copilot_cli_mcp_paths", diff --git a/tests/test_lockfile.py b/tests/test_lockfile.py index ebcdb60158..132182c149 100644 --- a/tests/test_lockfile.py +++ b/tests/test_lockfile.py @@ -302,6 +302,57 @@ def test_write_overwrites_timestamp_free_file_without_schema_validation(self, tm assert written["dependencies"] == [] assert "generated_at" not in written + def test_write_parses_legacy_lockfile_once(self, tmp_path, monkeypatch): + from apm_cli.utils import yaml_io + + lock_path = tmp_path / "apm.lock.yaml" + lock_path.write_text( + "lockfile_version: '1'\ngenerated_at: '2025-01-01T00:00:00+00:00'\ndependencies: []\n", + encoding="utf-8", + ) + real_load_yaml_str = yaml_io.load_yaml_str + load_calls = 0 + + def counting_load_yaml_str(text): + nonlocal load_calls + load_calls += 1 + return real_load_yaml_str(text) + + monkeypatch.setattr(yaml_io, "load_yaml_str", counting_load_yaml_str) + + LockFile().write(lock_path) + + assert load_calls == 1 + + def test_timestamp_free_destination_discards_in_memory_legacy_timestamp(self, tmp_path): + lock_path = tmp_path / "apm.lock.yaml" + lock_path.write_text( + "lockfile_version: '1'\ndependencies: []\n", + encoding="utf-8", + ) + lock = LockFile(generated_at="2025-01-01T00:00:00+00:00") + + lock.write(lock_path) + + written = yaml.safe_load(lock_path.read_text(encoding="utf-8")) + assert "generated_at" not in written + assert lock.generated_at is None + + def test_legacy_noop_restores_persisted_timestamp(self, tmp_path): + lock_path = tmp_path / "apm.lock.yaml" + persisted_timestamp = "2025-01-01T00:00:00+00:00" + lock_path.write_text( + f"lockfile_version: '1'\ngenerated_at: '{persisted_timestamp}'\ndependencies: []\n", + encoding="utf-8", + ) + lock = LockFile(generated_at="2026-01-01T00:00:00+00:00") + + lock.write(lock_path) + + written = yaml.safe_load(lock_path.read_text(encoding="utf-8")) + assert written["generated_at"] == persisted_timestamp + assert lock.generated_at == persisted_timestamp + def test_mcp_servers_round_trip(self, tmp_path): """mcp_servers must survive a write → read cycle.""" lock = LockFile(apm_version="1.0.0") diff --git a/tests/unit/test_agent_plugin_exporter.py b/tests/unit/test_agent_plugin_exporter.py index eca3a65dc6..5b54d43bcf 100644 --- a/tests/unit/test_agent_plugin_exporter.py +++ b/tests/unit/test_agent_plugin_exporter.py @@ -633,11 +633,24 @@ def test_agent_bundle_nonportable_preflight_preserves_existing_output( @pytest.mark.parametrize("archive_format", ["zip", "tar.gz"]) +@pytest.mark.parametrize( + "timestamp_mode", + ["legacy-timestamp", "timestamp-free"], +) def test_agent_bundle_archives_are_reproducible( tmp_path: Path, archive_format: str, + timestamp_mode: str, ) -> None: project = _write_agent_project(tmp_path / "project") + if timestamp_mode == "timestamp-free": + lockfile_path = project / "apm.lock.yaml" + lockfile_data = yaml.safe_load(lockfile_path.read_text(encoding="utf-8")) + lockfile_data.pop("generated_at") + lockfile_path.write_text( + yaml.safe_dump(lockfile_data), + encoding="utf-8", + ) first = export_agent_plugin_bundle( project, tmp_path / "first", From 49e2eae7b511a818f47151f8a48b8d6011c547fb Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Sun, 23 Aug 2026 19:58:58 +0200 Subject: [PATCH 06/13] test: isolate fixed-epoch fallback --- tests/unit/commands/test_lock_export_command.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit/commands/test_lock_export_command.py b/tests/unit/commands/test_lock_export_command.py index 609f438df2..f0d70fad57 100644 --- a/tests/unit/commands/test_lock_export_command.py +++ b/tests/unit/commands/test_lock_export_command.py @@ -151,7 +151,8 @@ def test_export_invalid_timestamp_exits_2(runner, tmp_path, timestamp): assert "Expected timezone-aware ISO 8601 format" in result.stderr -def test_export_without_generated_at_uses_fixed_epoch(runner, tmp_path): +def test_export_without_generated_at_uses_fixed_epoch(runner, tmp_path, monkeypatch): + monkeypatch.delenv("SOURCE_DATE_EPOCH", raising=False) with runner.isolated_filesystem(temp_dir=tmp_path): _seed(Path.cwd()) lock_path = Path("apm.lock.yaml") From d72f51b64d36f8a00d1effa8eb19ca6f5a378aa8 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Sun, 23 Aug 2026 20:16:27 +0200 Subject: [PATCH 07/13] fix: align timestamp omission conformance apm-spec-waiver: behavior is governed by existing req-lk-005 amendment --- CHANGELOG.md | 5 ++++- CONFORMANCE.json | 5 +++-- CONFORMANCE.md | 2 +- .../manifests/openapm-v0.1.requirements.yml | 1 + docs/src/content/docs/specs/openapm-v0.1.md | 22 ++++++++++--------- tests/spec_conformance/test_lockfile_reqs.py | 11 ++++++++++ 6 files changed, 32 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cbc6576568..764ec1b052 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -130,7 +130,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Newly generated `apm.lock.yaml` files no longer include volatile `generated_at` metadata, preventing timestamp-only merge conflicts. Existing lockfiles preserve and refresh the legacy field until it is removed once; - later writes do not add it back. (#2616) + later writes do not add it back. `openapm-v0.1.md` requirement `req-lk-005` + defines these omission and opt-in semantics. Agent Plugin archive timestamps + fall back to `SOURCE_DATE_EPOCH` or the portable ZIP epoch when the field is + absent. (by @lachieh; closes #2572) (#2616) - Lockfiles generated on Windows for marketplace-plugin / skill-subset git dependencies now pass `apm install --frozen` on Linux, and vice versa, by hashing synthetic manifests with deterministic LF line endings. diff --git a/CONFORMANCE.json b/CONFORMANCE.json index 7e07ac7588..d5f9cfe801 100644 --- a/CONFORMANCE.json +++ b/CONFORMANCE.json @@ -104,9 +104,10 @@ "keyword": "MUST", "section": "5.5", "status": "active", - "test_count": 1, + "test_count": 2, "tests": [ - "tests/spec_conformance/test_lockfile_reqs.py::test_lockfile_dependency_carries_resolved_field" + "tests/spec_conformance/test_lockfile_reqs.py::test_lockfile_dependency_carries_resolved_field", + "tests/spec_conformance/test_lockfile_reqs.py::test_new_lockfile_omits_generated_at_by_default" ] }, { diff --git a/CONFORMANCE.md b/CONFORMANCE.md index 4fca09248f..21ebec7639 100644 --- a/CONFORMANCE.md +++ b/CONFORMANCE.md @@ -35,7 +35,7 @@ All four conformance classes (Producer, Consumer, Registry, Governance) carry ac | [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 | 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 | - | diff --git a/docs/public/specs/manifests/openapm-v0.1.requirements.yml b/docs/public/specs/manifests/openapm-v0.1.requirements.yml index d4e0c3bd87..115d861542 100644 --- a/docs/public/specs/manifests/openapm-v0.1.requirements.yml +++ b/docs/public/specs/manifests/openapm-v0.1.requirements.yml @@ -148,6 +148,7 @@ requirements: 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" diff --git a/docs/src/content/docs/specs/openapm-v0.1.md b/docs/src/content/docs/specs/openapm-v0.1.md index 9e3cdd3f4f..1bbd4049d6 100644 --- a/docs/src/content/docs/specs/openapm-v0.1.md +++ b/docs/src/content/docs/specs/openapm-v0.1.md @@ -1141,16 +1141,17 @@ against. **[req-lk-005]** A conforming **consumer** implementation MUST treat two lockfiles as semantically equivalent if they differ only in the -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. Consumers operating in privacy-sensitive -deployments MAY omit `generated_at` and `apm_version` entirely; -their absence MUST NOT affect content-equivalence comparison. -Consumers SHOULD expose a `--no-provenance` (or equivalent) flag -that suppresses these fields on write. Consumers SHOULD NOT include -`generated_at` or `apm_version` in lockfiles persisted by -deployments that have declared privacy sensitivity. When a -consumer writes a lockfile, the `dependencies` list MUST be +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 @@ -3850,6 +3851,7 @@ renumbering of conformance classes. | 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. | Errata (none at publication). diff --git a/tests/spec_conformance/test_lockfile_reqs.py b/tests/spec_conformance/test_lockfile_reqs.py index 8763077399..f88d6a2eba 100644 --- a/tests/spec_conformance/test_lockfile_reqs.py +++ b/tests/spec_conformance/test_lockfile_reqs.py @@ -104,6 +104,17 @@ def test_lockfile_dependency_carries_resolved_field(): ) +@pytest.mark.req("req-lk-005") +def test_new_lockfile_omits_generated_at_by_default(): + from apm_cli.deps.lockfile import LockFile + + assert "generated_at:" not in LockFile().to_yaml() + assert_spec_contains( + "MUST omit `generated_at` from newly created\nlockfiles", + "MUST\nNOT reintroduce it solely as metadata", + ) + + @pytest.mark.req("req-lk-013") def test_lockfile_dependency_carries_integrity_field_when_remote(): doc = load_yaml_fixture(*TRUST_LOCKFILE) From 1a6d49e9e8c959dfa1f69127cee8c7fcfb4aac07 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Sun, 23 Aug 2026 20:40:57 +0200 Subject: [PATCH 08/13] refactor: fold final timestamp review --- .../owners/contracts-tooling.json | 5 +- CHANGELOG.md | 4 +- apm.lock.yaml | 1 - docs/src/content/docs/specs/openapm-v0.1.md | 3 -- .../checks/contracts_test_taxonomy.py | 53 ++++++++++++++++++- scripts/architecture_linter/diagnostics.py | 1 + src/apm_cli/deps/lockfile.py | 34 ++++++++++-- src/apm_cli/install/phases/lockfile.py | 2 +- .../test_architecture_owner_rule_mutations.py | 10 +++- tests/test_lockfile.py | 25 +++++++++ .../install/test_mcp_lockfile_determinism.py | 45 ++++++++++++---- 11 files changed, 158 insertions(+), 25 deletions(-) diff --git a/.apm/architecture/owners/contracts-tooling.json b/.apm/architecture/owners/contracts-tooling.json index ad66f83b0f..cf1f3bb4df 100644 --- a/.apm/architecture/owners/contracts-tooling.json +++ b/.apm/architecture/owners/contracts-tooling.json @@ -48,7 +48,10 @@ "decision": "Lockfile timestamp emission and reproducible fallback", "owner": "deps/lockfile.py (LockFile.write, resolve_reproducible_timestamp)", "selectors": ["src/apm_cli/deps/lockfile.py"], - "guards": ["contracts-tooling-lockfile-timestamp"] + "guards": [ + "contracts-tooling-lockfile-timestamp-fallback", + "contracts-tooling-lockfile-timestamp" + ] }, { "id": "generated-content-footer-wording", diff --git a/CHANGELOG.md b/CHANGELOG.md index 764ec1b052..b961e728c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -132,8 +132,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 lockfiles preserve and refresh the legacy field until it is removed once; later writes do not add it back. `openapm-v0.1.md` requirement `req-lk-005` defines these omission and opt-in semantics. Agent Plugin archive timestamps - fall back to `SOURCE_DATE_EPOCH` or the portable ZIP epoch when the field is - absent. (by @lachieh; closes #2572) (#2616) + remain byte-reproducible without the field by using `SOURCE_DATE_EPOCH` or a + fixed epoch. (by @lachieh; closes #2572) (#2616) - Lockfiles generated on Windows for marketplace-plugin / skill-subset git dependencies now pass `apm install --frozen` on Linux, and vice versa, by hashing synthetic manifests with deterministic LF line endings. diff --git a/apm.lock.yaml b/apm.lock.yaml index 1c4699878d..bb0aabf0b1 100644 --- a/apm.lock.yaml +++ b/apm.lock.yaml @@ -1,5 +1,4 @@ lockfile_version: '1' -generated_at: '2026-08-31T13:08:37.404349+00:00' apm_version: 0.29.0 dependencies: - repo_url: _local/apm-issue-autopilot diff --git a/docs/src/content/docs/specs/openapm-v0.1.md b/docs/src/content/docs/specs/openapm-v0.1.md index 1bbd4049d6..1291e38ecf 100644 --- a/docs/src/content/docs/specs/openapm-v0.1.md +++ b/docs/src/content/docs/specs/openapm-v0.1.md @@ -819,7 +819,6 @@ Example (informative, minimal): ```yaml lockfile_version: "1" -generated_at: "2026-05-10T20:14:00+00:00" apm_version: "0.6.4" dependencies: - repo_url: github.com/octocat/example @@ -2214,7 +2213,6 @@ resolves to the lockfile: ```yaml lockfile_version: "2" -generated_at: "2026-05-10T20:14:00+00:00" apm_version: "0.7.0" dependencies: - repo_url: github.com/contoso/security-baseline @@ -3405,7 +3403,6 @@ A Consumer reading the manifest above produces the lockfile: ```yaml lockfile_version: "2" -generated_at: "2026-05-10T20:14:00+00:00" apm_version: "0.7.0" dependencies: - repo_url: github.com/contoso/common-prompts diff --git a/scripts/architecture_linter/checks/contracts_test_taxonomy.py b/scripts/architecture_linter/checks/contracts_test_taxonomy.py index aa3386fe12..9b7b7c8178 100644 --- a/scripts/architecture_linter/checks/contracts_test_taxonomy.py +++ b/scripts/architecture_linter/checks/contracts_test_taxonomy.py @@ -72,6 +72,9 @@ _GUARD_LOCKFILE_TIMESTAMP = "contracts-tooling-lockfile-timestamp" +_GUARD_LOCKFILE_TIMESTAMP_FALLBACK = "contracts-tooling-lockfile-timestamp-fallback" + + _GUARD_GENERATION_FOOTER = "contracts-tooling-generation-footer" @@ -287,13 +290,56 @@ def check_lockfile_timestamp_authority(provider: FactsProvider) -> tuple[Violati violation( rule_id, path, - "Lockfile timestamp writes must route through deps/lockfile.py", + "Lockfile timestamp writes and fallback policy must route through " + "deps/lockfile.py", line=node.lineno, ) ) return tuple(findings) +def _owns_reproducible_fallback(node: ast.AST) -> bool: + """Return whether a node reimplements the reproducible timestamp fallback.""" + if isinstance(node, ast.Constant): + return node.value == "1970-01-01T00:00:00+00:00" + if isinstance(node, ast.Call) and node.args: + first_arg = node.args[0] + return ( + isinstance(first_arg, ast.Constant) + and first_arg.value == "SOURCE_DATE_EPOCH" + and isinstance(node.func, ast.Attribute) + and node.func.attr in {"get", "getenv"} + ) + if isinstance(node, ast.Subscript): + return isinstance(node.slice, ast.Constant) and node.slice.value == "SOURCE_DATE_EPOCH" + return False + + +def check_lockfile_timestamp_fallback(provider: FactsProvider) -> tuple[Violation, ...]: + """Reproducible timestamp fallback policy must stay inside its owner.""" + rule_id = _GUARD_LOCKFILE_TIMESTAMP_FALLBACK + findings: list[Violation] = [] + for path in _python_paths(provider, _SRC_PREFIX): + if path == _LOCKFILE_OWNER: + continue + facts, failures = _facts_for(provider, path, rule_id) + findings.extend(failures) + if failures or facts.tree_index is None: + continue + findings.extend( + violation( + rule_id, + path, + "Lockfile timestamp writes and fallback policy must route through " + "deps/lockfile.py", + line=node.lineno, + ) + for node in facts.tree_index.nodes + if _owns_reproducible_fallback(node) + ) + return tuple(findings) + + _TAXONOMY_PLUGIN = "tests/quality/taxonomy_inventory_plugin.py" @@ -775,6 +821,11 @@ def _structural_rule(rule_id: str, description: str, check) -> Rule: "Lockfile timestamp emission stays owned by deps/lockfile.py.", check_lockfile_timestamp_authority, ), + _owner_rule( + _GUARD_LOCKFILE_TIMESTAMP_FALLBACK, + "Reproducible timestamp fallback stays owned by deps/lockfile.py.", + check_lockfile_timestamp_fallback, + ), _owner_rule( _GUARD_GENERATION_FOOTER, "Generated-content footer wording stays owned by compilation/footer.py.", diff --git a/scripts/architecture_linter/diagnostics.py b/scripts/architecture_linter/diagnostics.py index 25b77699d9..27ea090cf0 100644 --- a/scripts/architecture_linter/diagnostics.py +++ b/scripts/architecture_linter/diagnostics.py @@ -36,6 +36,7 @@ "contracts-tooling-dependency-identity": ("AC23", "AC25", "AC29"), "contracts-tooling-frontmatter-yaml": ("AC36",), "contracts-tooling-lockfile-timestamp": ("AC2",), + "contracts-tooling-lockfile-timestamp-fallback": ("AC2",), "install-deployment-approval-outcome-routing": ("AC3",), "install-deployment-audit-policy-discovery": ("AC3",), "install-deployment-audit-replay": ("AC4",), diff --git a/src/apm_cli/deps/lockfile.py b/src/apm_cli/deps/lockfile.py index 0cb3266e87..1686038419 100644 --- a/src/apm_cli/deps/lockfile.py +++ b/src/apm_cli/deps/lockfile.py @@ -32,6 +32,13 @@ _REPRODUCIBLE_EPOCH = "1970-01-01T00:00:00+00:00" +class _ExistingLockfileUnset: + """Sentinel for callers that have not already read the destination.""" + + +_EXISTING_LOCKFILE_UNSET = _ExistingLockfileUnset() + + def installed_apm_version() -> str: """Return the running APM distribution version for lockfile metadata.""" try: @@ -937,7 +944,12 @@ def _from_validated_data(cls, data: dict[str, Any]) -> LockFile: lock.deployment_ledger = DeploymentLedgerCodec.from_lockfile(lock) return lock - def write(self, path: Path) -> None: + def write( + self, + path: Path, + *, + existing_lockfile: LockFile | None | _ExistingLockfileUnset = (_EXISTING_LOCKFILE_UNSET), + ) -> None: """Write lock file to disk, preserving legacy timestamp behavior. New lockfiles omit ``generated_at``. When the on-disk lockfile already @@ -946,21 +958,28 @@ def write(self, path: Path) -> None: timestamp in a future APM version, but for now it preserves backward compatibility with older APM builds that expect the field. This method may mutate ``self.generated_at`` to preserve or refresh that metadata. + Callers that already loaded the destination can pass ``existing_lockfile`` + to avoid parsing the same bytes again. """ from ..utils.atomic_io import atomic_write_text from ..utils.yaml_io import load_yaml_str - existing = None - if path.exists(): + existing: LockFile | None + if isinstance(existing_lockfile, _ExistingLockfileUnset) and path.exists(): existing_text = path.read_text(encoding="utf-8") try: existing_data = load_yaml_str(existing_text) except (yaml.YAMLError, ValueError): existing_data = None + existing = None if isinstance(existing_data, dict) and existing_data.get("generated_at") is not None: existing = type(self)._from_validated_data( _validate_lockfile_container(existing_data) ) + elif isinstance(existing_lockfile, _ExistingLockfileUnset): + existing = None + else: + existing = existing_lockfile if existing is not None and existing.generated_at is not None: if self.is_semantically_equivalent(existing): self.generated_at = existing.generated_at @@ -1074,9 +1093,14 @@ def get_installed_paths(self, apm_modules_dir: Path) -> list[str]: paths.append(rel_path) return paths - def save(self, path: Path) -> None: + def save( + self, + path: Path, + *, + existing_lockfile: LockFile | None | _ExistingLockfileUnset = (_EXISTING_LOCKFILE_UNSET), + ) -> None: """Save lock file to disk (alias for write).""" - self.write(path) + self.write(path, existing_lockfile=existing_lockfile) def is_semantically_equivalent(self, other: LockFile) -> bool: """Return True if *other* has the same deps, MCP/LSP servers, and configs. diff --git a/src/apm_cli/install/phases/lockfile.py b/src/apm_cli/install/phases/lockfile.py index 3ccf9b9d45..288a16aaf3 100644 --- a/src/apm_cli/install/phases/lockfile.py +++ b/src/apm_cli/install/phases/lockfile.py @@ -526,7 +526,7 @@ def _write_if_changed(self, lockfile: LockFile, lockfile_path: Path, _LF: type) if self.ctx.logger: self.ctx.logger.verbose_detail("apm.lock.yaml unchanged -- skipping write") else: - lockfile.save(lockfile_path) + lockfile.save(lockfile_path, existing_lockfile=existing_lockfile) if self.ctx.logger: self.ctx.logger.verbose_detail( f"Generated apm.lock.yaml with {len(lockfile.dependencies)} dependencies" diff --git a/tests/integration/test_architecture_owner_rule_mutations.py b/tests/integration/test_architecture_owner_rule_mutations.py index 7c09a3ac05..0a636cb41b 100644 --- a/tests/integration/test_architecture_owner_rule_mutations.py +++ b/tests/integration/test_architecture_owner_rule_mutations.py @@ -6,7 +6,7 @@ every guard executes exactly once per run. Names prove nothing about teeth: a rule whose body was gutted still registers its guard ID and still runs. -This file supplies the missing half of that contract. For each of the 56 +This file supplies the missing half of that contract. For each of the 57 registered owner guards it pins one minimal, meaningful source mutation -- a surgical edit that kills a load-bearing sub-condition of the owning decision -- and asserts the one rule that owns that guard reports a real `Violation`. @@ -155,6 +155,14 @@ class MutationCase: new="_log = logging.getLogger(__name__)\nMCPIntegrator.generated_at = None", intent="An MCP consumer writes lockfile timestamp metadata outside its owner.", ), + MutationCase( + guard_id="contracts-tooling-lockfile-timestamp-fallback", + rule_id="contracts-tooling-lockfile-timestamp-fallback", + path="src/apm_cli/bundle/agent_plugin_exporter.py", + old="import os", + new='import os\n\nos.environ.get("SOURCE_DATE_EPOCH")', + intent="An Agent Plugin consumer reimplements the reproducible timestamp fallback.", + ), MutationCase( guard_id="hooks-integrations-copilot-cli-mcp-paths", rule_id="mutation_writes.copilot_cli_mcp_paths", diff --git a/tests/test_lockfile.py b/tests/test_lockfile.py index 132182c149..1e50a4e174 100644 --- a/tests/test_lockfile.py +++ b/tests/test_lockfile.py @@ -324,6 +324,31 @@ def counting_load_yaml_str(text): assert load_calls == 1 + def test_write_reuses_preloaded_destination(self, tmp_path, monkeypatch): + from apm_cli.utils import yaml_io + + lock_path = tmp_path / "apm.lock.yaml" + lock_path.write_text( + "lockfile_version: '1'\ngenerated_at: '2025-01-01T00:00:00+00:00'\ndependencies: []\n", + encoding="utf-8", + ) + existing = LockFile.read(lock_path) + assert existing is not None + candidate = LockFile() + candidate.add_dependency(LockedDependency(repo_url="owner/repo")) + + def unexpected_parse(_text): + raise AssertionError("preloaded lockfile must avoid a second YAML parse") + + with monkeypatch.context() as scoped_patch: + scoped_patch.setattr(yaml_io, "load_yaml_str", unexpected_parse) + candidate.write(lock_path, existing_lockfile=existing) + + assert candidate.generated_at is not None + written = LockFile.read(lock_path) + assert written is not None + assert written.has_dependency("owner/repo") + def test_timestamp_free_destination_discards_in_memory_legacy_timestamp(self, tmp_path): lock_path = tmp_path / "apm.lock.yaml" lock_path.write_text( diff --git a/tests/unit/install/test_mcp_lockfile_determinism.py b/tests/unit/install/test_mcp_lockfile_determinism.py index 93b10dca9d..34ad19d916 100644 --- a/tests/unit/install/test_mcp_lockfile_determinism.py +++ b/tests/unit/install/test_mcp_lockfile_determinism.py @@ -332,9 +332,14 @@ def test_real_mcp_target_change_writes_once_then_converges(tmp_path: Path) -> No real_save = LockFile.save changed_writes: list[Path] = [] - def track_changed_write(lockfile: LockFile, path: Path) -> None: + def track_changed_write( + lockfile: LockFile, + path: Path, + *, + existing_lockfile: LockFile | None = None, + ) -> None: changed_writes.append(path) - real_save(lockfile, path) + real_save(lockfile, path, existing_lockfile=existing_lockfile) with patch.object(LockFile, "save", track_changed_write): _run_lockfile_phase_and_mcp_persist( @@ -352,9 +357,14 @@ def track_changed_write(lockfile: LockFile, path: Path) -> None: converged_writes: list[Path] = [] - def track_converged_write(lockfile: LockFile, path: Path) -> None: + def track_converged_write( + lockfile: LockFile, + path: Path, + *, + existing_lockfile: LockFile | None = None, + ) -> None: converged_writes.append(path) - real_save(lockfile, path) + real_save(lockfile, path, existing_lockfile=existing_lockfile) with patch.object(LockFile, "save", track_converged_write): _run_lockfile_phase_and_mcp_persist( @@ -398,9 +408,14 @@ def test_legacy_lock_preserves_scalar_or_list_provenance_without_write( writes: list[Path] = [] real_save = LockFile.save - def track_write(lockfile: LockFile, path: Path) -> None: + def track_write( + lockfile: LockFile, + path: Path, + *, + existing_lockfile: LockFile | None = None, + ) -> None: writes.append(path) - real_save(lockfile, path) + real_save(lockfile, path, existing_lockfile=existing_lockfile) with patch.object(LockFile, "save", track_write): _run_lockfile_phase_and_mcp_persist( @@ -437,9 +452,14 @@ def test_stale_partial_provenance_repairs_once_then_converges(tmp_path: Path) -> real_save = LockFile.save repair_writes: list[Path] = [] - def track_repair(lockfile: LockFile, path: Path) -> None: + def track_repair( + lockfile: LockFile, + path: Path, + *, + existing_lockfile: LockFile | None = None, + ) -> None: repair_writes.append(path) - real_save(lockfile, path) + real_save(lockfile, path, existing_lockfile=existing_lockfile) with patch.object(LockFile, "save", track_repair): _run_lockfile_phase_and_mcp_persist( @@ -457,9 +477,14 @@ def track_repair(lockfile: LockFile, path: Path) -> None: converged_writes: list[Path] = [] - def track_converged(lockfile: LockFile, path: Path) -> None: + def track_converged( + lockfile: LockFile, + path: Path, + *, + existing_lockfile: LockFile | None = None, + ) -> None: converged_writes.append(path) - real_save(lockfile, path) + real_save(lockfile, path, existing_lockfile=existing_lockfile) with patch.object(LockFile, "save", track_converged): _run_lockfile_phase_and_mcp_persist( From d3f949e1c7413d98c8215e8ee015da36d2d4cf85 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Tue, 1 Sep 2026 17:33:13 +0200 Subject: [PATCH 09/13] fix(lockfile): repair malformed legacy destinations Keep legacy timestamp metadata from making otherwise replaceable malformed lockfiles irreparable. Addresses panel lockfile repair follow-up. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 5 +++-- src/apm_cli/deps/lockfile.py | 15 ++++++++++++--- tests/test_lockfile.py | 19 +++++++++++++++++++ 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b961e728c1..5100f6f861 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -129,8 +129,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 (by @WilliamK112, #2638) - Newly generated `apm.lock.yaml` files no longer include volatile `generated_at` metadata, preventing timestamp-only merge conflicts. Existing - lockfiles preserve and refresh the legacy field until it is removed once; - later writes do not add it back. `openapm-v0.1.md` requirement `req-lk-005` + lockfiles preserve the legacy field on no-op writes and refresh it on + substantive writes; deleting it once prevents APM from restoring it. + `openapm-v0.1.md` requirement `req-lk-005` defines these omission and opt-in semantics. Agent Plugin archive timestamps remain byte-reproducible without the field by using `SOURCE_DATE_EPOCH` or a fixed epoch. (by @lachieh; closes #2572) (#2616) diff --git a/src/apm_cli/deps/lockfile.py b/src/apm_cli/deps/lockfile.py index 1686038419..52b10653c3 100644 --- a/src/apm_cli/deps/lockfile.py +++ b/src/apm_cli/deps/lockfile.py @@ -965,6 +965,7 @@ def write( from ..utils.yaml_io import load_yaml_str existing: LockFile | None + legacy_timestamp_present = False if isinstance(existing_lockfile, _ExistingLockfileUnset) and path.exists(): existing_text = path.read_text(encoding="utf-8") try: @@ -973,9 +974,15 @@ def write( existing_data = None existing = None if isinstance(existing_data, dict) and existing_data.get("generated_at") is not None: - existing = type(self)._from_validated_data( - _validate_lockfile_container(existing_data) - ) + legacy_timestamp_present = True + try: + existing = type(self)._from_validated_data( + _validate_lockfile_container(existing_data) + ) + except UnsupportedLockfileVersionError: + raise + except LockfileFormatError: + existing = None elif isinstance(existing_lockfile, _ExistingLockfileUnset): existing = None else: @@ -985,6 +992,8 @@ def write( self.generated_at = existing.generated_at else: self.generated_at = datetime.now(timezone.utc).isoformat() + elif legacy_timestamp_present: + self.generated_at = datetime.now(timezone.utc).isoformat() else: self.generated_at = None atomic_write_text(path, self.to_yaml()) diff --git a/tests/test_lockfile.py b/tests/test_lockfile.py index 1e50a4e174..6a9d646bf0 100644 --- a/tests/test_lockfile.py +++ b/tests/test_lockfile.py @@ -302,6 +302,25 @@ def test_write_overwrites_timestamp_free_file_without_schema_validation(self, tm assert written["dependencies"] == [] assert "generated_at" not in written + def test_write_repairs_malformed_legacy_file_and_refreshes_timestamp( + self, tmp_path, monkeypatch + ): + lock_path = tmp_path / "apm.lock.yaml" + lock_path.write_text( + "lockfile_version: '1'\ngenerated_at: '2025-01-01T00:00:00+00:00'\ndependencies: {}\n", + encoding="utf-8", + ) + next_write = datetime(2026, 1, 1, tzinfo=timezone.utc) + fixed_datetime = Mock() + fixed_datetime.now.return_value = next_write + monkeypatch.setattr("apm_cli.deps.lockfile.datetime", fixed_datetime) + + LockFile().write(lock_path) + + written = yaml.safe_load(lock_path.read_text(encoding="utf-8")) + assert written["dependencies"] == [] + assert written["generated_at"] == next_write.isoformat() + def test_write_parses_legacy_lockfile_once(self, tmp_path, monkeypatch): from apm_cli.utils import yaml_io From 699e96baf7e1b4877b3ef2552b2cd75a13bf1e45 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Tue, 1 Sep 2026 17:33:13 +0200 Subject: [PATCH 10/13] perf(lockfile): reuse MCP destination snapshot Avoid reparsing lockfile YAML when MCP persistence already loaded the exact destination. Addresses panel performance follow-up. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/apm_cli/integration/mcp_integrator.py | 2 +- tests/unit/integration/test_mcp_integrator.py | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/apm_cli/integration/mcp_integrator.py b/src/apm_cli/integration/mcp_integrator.py index b0b2b404b6..4ca51b916c 100644 --- a/src/apm_cli/integration/mcp_integrator.py +++ b/src/apm_cli/integration/mcp_integrator.py @@ -858,7 +858,7 @@ def update_lockfile( ): _log.debug("MCP lockfile unchanged -- skipping write") return - lockfile.save(lock_path) + lockfile.save(lock_path, existing_lockfile=existing_lockfile) except Exception as exc: _log.debug( "MCP lockfile persistence failed at %s", diff --git a/tests/unit/integration/test_mcp_integrator.py b/tests/unit/integration/test_mcp_integrator.py index e95d9d6656..f67d349bf2 100644 --- a/tests/unit/integration/test_mcp_integrator.py +++ b/tests/unit/integration/test_mcp_integrator.py @@ -564,6 +564,25 @@ def test_updates_mcp_servers_in_lockfile(self, tmp_path): lf = LockFile.read(lock_path) assert set(lf.mcp_servers) == {"server-a", "server-b"} + def test_update_reuses_preloaded_lockfile(self, tmp_path, monkeypatch): + from apm_cli.utils import yaml_io + + lock_path = tmp_path / "apm.lock.yaml" + self._write_minimal_lockfile(lock_path) + real_load_yaml_str = yaml_io.load_yaml_str + load_calls = 0 + + def counting_load_yaml_str(text): + nonlocal load_calls + load_calls += 1 + return real_load_yaml_str(text) + + monkeypatch.setattr(yaml_io, "load_yaml_str", counting_load_yaml_str) + + MCPIntegrator.update_lockfile({"server-a"}, lock_path=lock_path) + + assert load_calls == 1 + def test_updates_mcp_configs_when_provided(self, tmp_path): lock_path = tmp_path / "apm.lock.yaml" self._write_minimal_lockfile(lock_path) From 79a399fe86639428e0499ad19a47103a1bc71a4e Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Tue, 1 Sep 2026 17:33:13 +0200 Subject: [PATCH 11/13] test(lockfile): reject timestamp constructor bypasses Extend the canonical-owner guard to catch LockFile generated_at constructor writes outside the owner. Addresses panel architecture follow-up. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../owners/contracts-tooling.json | 17 +++---- .../checks/contracts_test_taxonomy.py | 44 +++++++++++++++++++ scripts/architecture_linter/diagnostics.py | 1 + .../test_architecture_owner_rule_mutations.py | 12 ++++- 4 files changed, 62 insertions(+), 12 deletions(-) diff --git a/.apm/architecture/owners/contracts-tooling.json b/.apm/architecture/owners/contracts-tooling.json index cf1f3bb4df..37d9064e21 100644 --- a/.apm/architecture/owners/contracts-tooling.json +++ b/.apm/architecture/owners/contracts-tooling.json @@ -38,19 +38,14 @@ }, { "id": "read-only-lockfile-path", - "decision": "Read-only lockfile path resolution", - "owner": "deps/lockfile.py (resolve_lockfile_path_for_read)", - "selectors": ["src/apm_cli/deps/lockfile.py"], - "guards": ["contracts-tooling-lockfile-read"] - }, - { - "id": "lockfile-timestamp-emission", - "decision": "Lockfile timestamp emission and reproducible fallback", - "owner": "deps/lockfile.py (LockFile.write, resolve_reproducible_timestamp)", + "decision": "Lockfile read path, timestamp emission, and reproducible fallback", + "owner": "deps/lockfile.py (resolve_lockfile_path_for_read, LockFile.write, resolve_reproducible_timestamp)", "selectors": ["src/apm_cli/deps/lockfile.py"], "guards": [ - "contracts-tooling-lockfile-timestamp-fallback", - "contracts-tooling-lockfile-timestamp" + "contracts-tooling-lockfile-read", + "contracts-tooling-lockfile-timestamp", + "contracts-tooling-lockfile-timestamp-constructor", + "contracts-tooling-lockfile-timestamp-fallback" ] }, { diff --git a/scripts/architecture_linter/checks/contracts_test_taxonomy.py b/scripts/architecture_linter/checks/contracts_test_taxonomy.py index 9b7b7c8178..dbefe45eb2 100644 --- a/scripts/architecture_linter/checks/contracts_test_taxonomy.py +++ b/scripts/architecture_linter/checks/contracts_test_taxonomy.py @@ -75,6 +75,9 @@ _GUARD_LOCKFILE_TIMESTAMP_FALLBACK = "contracts-tooling-lockfile-timestamp-fallback" +_GUARD_LOCKFILE_TIMESTAMP_CONSTRUCTOR = "contracts-tooling-lockfile-timestamp-constructor" + + _GUARD_GENERATION_FOOTER = "contracts-tooling-generation-footer" @@ -298,6 +301,42 @@ def check_lockfile_timestamp_authority(provider: FactsProvider) -> tuple[Violati return tuple(findings) +def _constructs_lockfile_timestamp(node: ast.AST) -> bool: + """Return whether a LockFile constructor sets timestamp metadata.""" + if not isinstance(node, ast.Call): + return False + if isinstance(node.func, ast.Name): + is_lockfile = node.func.id == "LockFile" + else: + is_lockfile = isinstance(node.func, ast.Attribute) and node.func.attr == "LockFile" + return is_lockfile and any(keyword.arg == "generated_at" for keyword in node.keywords) + + +def check_lockfile_timestamp_constructor(provider: FactsProvider) -> tuple[Violation, ...]: + """Lockfile timestamp construction must stay inside its owner.""" + rule_id = _GUARD_LOCKFILE_TIMESTAMP_CONSTRUCTOR + findings: list[Violation] = [] + for path in _python_paths(provider, _SRC_PREFIX): + if path == _LOCKFILE_OWNER: + continue + facts, failures = _facts_for(provider, path, rule_id) + findings.extend(failures) + if failures or facts.tree_index is None: + continue + findings.extend( + violation( + rule_id, + path, + "Lockfile timestamp writes and fallback policy must route through " + "deps/lockfile.py", + line=node.lineno, + ) + for node in facts.tree_index.nodes + if _constructs_lockfile_timestamp(node) + ) + return tuple(findings) + + def _owns_reproducible_fallback(node: ast.AST) -> bool: """Return whether a node reimplements the reproducible timestamp fallback.""" if isinstance(node, ast.Constant): @@ -821,6 +860,11 @@ def _structural_rule(rule_id: str, description: str, check) -> Rule: "Lockfile timestamp emission stays owned by deps/lockfile.py.", check_lockfile_timestamp_authority, ), + _owner_rule( + _GUARD_LOCKFILE_TIMESTAMP_CONSTRUCTOR, + "Lockfile timestamp construction stays owned by deps/lockfile.py.", + check_lockfile_timestamp_constructor, + ), _owner_rule( _GUARD_LOCKFILE_TIMESTAMP_FALLBACK, "Reproducible timestamp fallback stays owned by deps/lockfile.py.", diff --git a/scripts/architecture_linter/diagnostics.py b/scripts/architecture_linter/diagnostics.py index 27ea090cf0..46d5570d4f 100644 --- a/scripts/architecture_linter/diagnostics.py +++ b/scripts/architecture_linter/diagnostics.py @@ -36,6 +36,7 @@ "contracts-tooling-dependency-identity": ("AC23", "AC25", "AC29"), "contracts-tooling-frontmatter-yaml": ("AC36",), "contracts-tooling-lockfile-timestamp": ("AC2",), + "contracts-tooling-lockfile-timestamp-constructor": ("AC2",), "contracts-tooling-lockfile-timestamp-fallback": ("AC2",), "install-deployment-approval-outcome-routing": ("AC3",), "install-deployment-audit-policy-discovery": ("AC3",), diff --git a/tests/integration/test_architecture_owner_rule_mutations.py b/tests/integration/test_architecture_owner_rule_mutations.py index 0a636cb41b..3491c2b84f 100644 --- a/tests/integration/test_architecture_owner_rule_mutations.py +++ b/tests/integration/test_architecture_owner_rule_mutations.py @@ -6,7 +6,7 @@ every guard executes exactly once per run. Names prove nothing about teeth: a rule whose body was gutted still registers its guard ID and still runs. -This file supplies the missing half of that contract. For each of the 57 +This file supplies the missing half of that contract. For each of the 58 registered owner guards it pins one minimal, meaningful source mutation -- a surgical edit that kills a load-bearing sub-condition of the owning decision -- and asserts the one rule that owns that guard reports a real `Violation`. @@ -155,6 +155,16 @@ class MutationCase: new="_log = logging.getLogger(__name__)\nMCPIntegrator.generated_at = None", intent="An MCP consumer writes lockfile timestamp metadata outside its owner.", ), + MutationCase( + guard_id="contracts-tooling-lockfile-timestamp-constructor", + rule_id="contracts-tooling-lockfile-timestamp-constructor", + path="src/apm_cli/integration/mcp_integrator.py", + old="_log = logging.getLogger(__name__)", + new=( + "_log = logging.getLogger(__name__)\nLockFile(generated_at='2026-01-01T00:00:00+00:00')" + ), + intent="An MCP consumer sets timestamp metadata through the LockFile constructor.", + ), MutationCase( guard_id="contracts-tooling-lockfile-timestamp-fallback", rule_id="contracts-tooling-lockfile-timestamp-fallback", From c8dd357af817b0b90639f18166e6b145c76905e8 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Wed, 2 Sep 2026 05:08:15 -0400 Subject: [PATCH 12/13] style: format lockfile architecture check Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 81d14491-7c32-46fc-b045-b7cde2b2500a --- .../architecture_linter/checks/contracts_test_taxonomy.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/scripts/architecture_linter/checks/contracts_test_taxonomy.py b/scripts/architecture_linter/checks/contracts_test_taxonomy.py index dbefe45eb2..30a23241d5 100644 --- a/scripts/architecture_linter/checks/contracts_test_taxonomy.py +++ b/scripts/architecture_linter/checks/contracts_test_taxonomy.py @@ -327,8 +327,7 @@ def check_lockfile_timestamp_constructor(provider: FactsProvider) -> tuple[Viola violation( rule_id, path, - "Lockfile timestamp writes and fallback policy must route through " - "deps/lockfile.py", + "Lockfile timestamp writes and fallback policy must route through deps/lockfile.py", line=node.lineno, ) for node in facts.tree_index.nodes @@ -369,8 +368,7 @@ def check_lockfile_timestamp_fallback(provider: FactsProvider) -> tuple[Violatio violation( rule_id, path, - "Lockfile timestamp writes and fallback policy must route through " - "deps/lockfile.py", + "Lockfile timestamp writes and fallback policy must route through deps/lockfile.py", line=node.lineno, ) for node in facts.tree_index.nodes From 7b261248ab72f10ce207e29abeef18bda483e563 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Wed, 2 Sep 2026 05:26:25 -0400 Subject: [PATCH 13/13] test: register lockfile timestamp guards Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 81d14491-7c32-46fc-b045-b7cde2b2500a --- tests/unit/scripts/test_architecture_runner.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/unit/scripts/test_architecture_runner.py b/tests/unit/scripts/test_architecture_runner.py index ad59790152..9e6950dc0f 100644 --- a/tests/unit/scripts/test_architecture_runner.py +++ b/tests/unit/scripts/test_architecture_runner.py @@ -606,6 +606,9 @@ def exiting_import( contracts-tooling-frontmatter-yaml contracts-tooling-generation-footer contracts-tooling-lockfile-read +contracts-tooling-lockfile-timestamp +contracts-tooling-lockfile-timestamp-constructor +contracts-tooling-lockfile-timestamp-fallback install-deployment-approval-outcome-routing install-deployment-audit-policy-discovery install-deployment-audit-replay