Skip to content

fix(policy): layer dependencies.require through extends: org (closes #1201)#1290

Open
sergio-sisternes-epam wants to merge 5 commits into
mainfrom
fix/1201-extends-org-deps-require
Open

fix(policy): layer dependencies.require through extends: org (closes #1201)#1290
sergio-sisternes-epam wants to merge 5 commits into
mainfrom
fix/1201-extends-org-deps-require

Conversation

@sergio-sisternes-epam
Copy link
Copy Markdown
Collaborator

@sergio-sisternes-epam sergio-sisternes-epam commented May 12, 2026

Description

Fix policy inheritance merge so that dependencies.require and dependencies.deny are correctly layered from the org floor when a repo policy uses extends: org. Uses the same None = "no opinion" / () = "explicitly empty" pattern established in the unmanaged_files fix (#1198).

Fixes #1201

Changes

  • schema.py: deny/require defaults changed from () to None; added effective_deny/effective_require properties
  • parser.py: Detects absent vs present-but-empty dependency blocks, preserves None for absent
  • inheritance.py: New _merge_list_field() helper with None-transparency; _merge_dependencies() rewritten
  • policy_checks.py, matcher.py, discovery.py, policy.py: All consumers updated to use effective_deny/effective_require
  • CHANGELOG.md: Fixed entry under [Unreleased]

Type of change

  • Bug fix
  • New feature
  • Documentation
  • Maintenance / refactor

Testing

  • Tested locally
  • All existing tests pass (8303 passed)
  • Added tests for new functionality
    • TestDependencyTransparency: 8 new tests covering None transparency, explicit empty override, mixed merges
    • Updated test_both_defaults, test_nested_missing_sections_use_defaults, _make_policy helper

Sergio Sisternes and others added 2 commits May 12, 2026 13:33
@sergio-sisternes-epam sergio-sisternes-epam marked this pull request as ready for review May 12, 2026 12:56
Copilot AI review requested due to automatic review settings May 12, 2026 12:56
@sergio-sisternes-epam sergio-sisternes-epam added the panel-review Trigger the apm-review-panel gh-aw workflow label May 12, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes policy inheritance so dependencies.require / dependencies.deny correctly flow from parent policies when a child policy uses extends: org and omits the dependencies: block, by introducing tri-state list semantics (None = transparent, ()/[] = explicit override, non-empty = union).

Changes:

  • Update DependencyPolicy to allow deny/require to be None and add effective_* accessors for runtime checks.
  • Implement None-transparent merge behavior for deny/require during inheritance resolution and adjust parsing to distinguish absent keys.
  • Update policy enforcement code paths and unit tests, and add a changelog entry.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/apm_cli/policy/schema.py Changes deny/require defaults to None and adds effective_deny / effective_require.
src/apm_cli/policy/parser.py Parses dependencies.deny / dependencies.require as None when absent to enable transparency.
src/apm_cli/policy/inheritance.py Adds _merge_list_field() and uses it to merge deny/require with None transparency.
src/apm_cli/policy/policy_checks.py Switches deny/require checks to use effective_* lists.
src/apm_cli/policy/matcher.py Uses effective_deny for dependency deny evaluation.
src/apm_cli/policy/discovery.py Serializes dependencies using effective_* (but see review comment re: cache semantics).
src/apm_cli/commands/policy.py Counts dependencies using effective_* lists.
tests/unit/policy/test_schema.py Updates default assertions for new tri-state semantics.
tests/unit/policy/test_parser.py Updates parsing expectations for missing deny.
tests/unit/policy/test_inheritance.py Adds tests covering transparency/override for deny/require.
tests/unit/policy/test_chain_discovery_shared.py Updates helper default deny value to None.
CHANGELOG.md Adds an Unreleased fix entry for extends: org layering behavior.

Comment thread src/apm_cli/policy/discovery.py Outdated
Comment thread src/apm_cli/policy/parser.py Outdated
Comment thread src/apm_cli/policy/schema.py
Comment thread CHANGELOG.md
Fix 1 (cache round-trip): _policy_to_dict in discovery.py was using
effective_deny/effective_require which collapses None→(), meaning a
policy cached without a dependencies: block would reload as () and
stop inheriting from its parent. Now uses the raw .deny/.require
fields and _opt_list(), preserving None through the cache round-trip.

Fix 2 (YAML null): 'deny:' / 'require:' with a bare or explicit null
value were reaching _parse_tuple(None) which returned (). Added an
explicit 'or deps_data[field] is None' guard so null is treated the
same as key absent (→ None, transparent).

Fix 3 (docs): Updated policy-schema.md table (deny/require now show
null = no opinion, [] = explicit empty), the merge-rule row, and the
deny/require semantics section to match the tri-state model.
Updated governance.md used by agents.

Tests added:
- test_parser.py: 3 new cases for absent block, YAML null, explicit []
- test_discovery.py: 2 new cache round-trip cases (None preserved,
  explicit-empty () preserved)
@github-actions
Copy link
Copy Markdown

APM Review Panel: ship_with_followups

Fixes a silent policy inheritance regression: org-level dependencies.require and deny rules are now correctly propagated to repos that use extends: org without an explicit dependencies block (closes #1201).

cc @sergio-sisternes-epam @danielmeppiel -- a fresh advisory pass is ready for your review.

The panel converges cleanly on this PR. Python-architect finds the implementation technically sound, following the established unmanaged_files precedent. CLI, DevX, and Auth panelists report no regressions on their surfaces. The core fix -- changing DependencyPolicy sentinel values from () to None to signal "no opinion" -- is the right approach and the unit test suite for the new _merge_list_field logic is thorough.

The most substantive signal comes from supply-chain-security. The panelist demonstrates via a passing test (test_parent_deny_child_explicit_empty_deny) that writing deny: [] in a child policy clears the org deny list in the merged result. This is a real design question, but it is not a defect introduced by this PR -- it is an intentional three-state distinction (absent = transparent, [] = explicit override, [...] = union). The tension is with the "tighten but never relax" language in the module docstring. The resolution is documentation, not code: either update the docstring to reflect that explicit-empty is a valid repo-level override, or add a governance warning in the schema reference. The behavior itself is load-bearing for the feature to be useful. This is a follow-up item, not a blocker.

Doc-writer correctly flags that policy-schema.md line 160 is now stale -- it describes the pre-fix two-state model. This creates active user confusion for org admins reading the reference and must be patched promptly post-merge. Test-coverage-expert identifies two missing assertions: the integration merge test does not verify that require flows through when the repo omits the key, and the parser has no test for the deny: [] -> () case. Both are straightforward one-line additions and should be bundled into a fast follow-up commit or added to this PR before merge.

Aligned with: Secure by default -- core fix closes a path where org deny rules were silently dropped; the deny: [] override question is a governance design clarification, not a new vulnerability. Governed by policy -- policy inheritance now behaves as documented for the extends: org path; the module docstring and policy-schema.md need a post-merge update to match the three-state model. OSS community driven -- contributed by an external author (sergio-sisternes-epam), triaged and merged promptly, demonstrating the contributor-trust signal the project needs.

Growth signal. This fix closes a credibility gap for the segment most likely to mandate APM adoption: org platform engineers and enterprise admins. A changelog entry and release note framing this as "policy inheritance now works as documented" -- rather than leading with internal mechanism detail -- is worth a targeted post in platform-engineering communities. The growth-hacker's suggested reframe ("org-level deny/require rules are no longer silently dropped for repos that use extends: org without an explicit dependencies block") should replace the current CHANGELOG line before the release ships.

Panel summary

Persona B R N Takeaway
Python Architect 0 0 2 Clean None-transparency extension; follows established unmanaged_files precedent exactly; no architectural faults found.
CLI Logging Expert 0 0 0 CLI output surface is minimal and correct; effective_deny/effective_require fix prevents zero-count false negatives in policy summaries. No output regressions.
DevX UX Expert 0 0 0 No CLI surface touched; policy inheritance fix is internal-only. Ship.
Supply Chain Security Expert 0 1 1 Core fix is a security improvement; explicit-empty deny override contradicts tighten-only invariant and warrants governance clarification.
OSS Growth Hacker 0 1 0 Silent governance regression fixed; ship with a changelog story targeting org admins.
Doc Writer 0 1 1 One doc drift found: policy-schema.md line 160 asserts omitted deny/require = empty tuple, which contradicts the new None=transparent semantic introduced by this PR.
Test Coverage Expert 0 2 0 Unit tests for the core fix are thorough; two integration-tier gaps remain: test_policy_merge_with_repo_override does not assert require flows through, and no parser test for deny: [] producing explicit empty ().

B = blocking-severity findings, R = recommended, N = nits.
Counts are signal strength, not gates. The maintainer ships.

Top 5 follow-ups

  1. [Doc Writer] Update policy-schema.md line 160 to reflect the three-state model (omitted = transparent, [] = explicit override, [...] = union-merged with parent). -- The current docs actively contradict the new behavior; org admins reading the reference will misunderstand the semantics of omitting the dependencies block. This is the highest-priority post-merge task.
  2. [Supply Chain Security Expert] Clarify the "tighten but never relax" invariant in inheritance.py to document that explicit deny: [] is a valid repo-level override, and add a governance warning in the schema reference. -- The passing test proves deny: [] clears the org deny list. The docstring says this should never happen. The contradiction is a trap for future maintainers and org admins.
  3. [Test Coverage Expert] Add assertion to test_policy_merge_with_repo_override: assertIn("DevExpGbb/required-standards", merged.dependencies.require). -- The integration test loads a fixture with require entries but never asserts they flow through. A regression in the transparency logic would pass this test silently -- exactly the scenario this PR fixes.
  4. [Test Coverage Expert] Add parser unit test: deny: [] in YAML produces () (empty tuple) and not None. -- The three-state distinction (absent vs. explicit empty) is the semantic load-bearing change in this PR. Without a parser test for case (c), the distinction can silently regress.
  5. [OSS Growth Hacker] Reframe the CHANGELOG entry to lead with user impact: "org-level dependencies.require and deny rules are no longer silently dropped for repos that use extends: org without an explicit dependencies block (closes extends: org also fails to layer dependencies.require from parent (same root as #1198) #1201)." -- The current entry leads with the internal mechanism (None vs. ()). Org admins scanning the changelog will not recognize this as the fix they were waiting for.

Architecture

classDiagram
    direction LR
    class DependencyPolicy {
        <<ValueObject>>
        +allow tuple~str~ | None
        +deny tuple~str~ | None
        +require tuple~str~ | None
        +require_resolution str
        +max_depth int
        +effective_deny() tuple~str~
        +effective_require() tuple~str~
    }
    class ApmPolicy {
        <<ValueObject>>
        +dependencies DependencyPolicy
        +extends str | None
        +enforcement str
    }
    class _build_policy {
        <<IOBoundary>>
        +__call__(data dict) ApmPolicy
    }
    class _merge_dependencies {
        <<Pure>>
        +__call__(parent, child) DependencyPolicy
    }
    class _merge_list_field {
        <<Pure>>
        +__call__(parent, child) tuple | None
    }
    class policy_checks {
        <<Consumer>>
        +_check_dependency_denylist()
        +_check_required_packages()
    }
    class matcher {
        <<Consumer>>
        +check_dependency_allowed()
    }
    class discovery {
        <<Consumer>>
        +_opt_list()
        +_is_policy_empty()
    }
    ApmPolicy *-- DependencyPolicy : contains
    _build_policy ..> DependencyPolicy : constructs
    _merge_dependencies ..> DependencyPolicy : returns
    _merge_dependencies *-- _merge_list_field : delegates deny/require
    policy_checks ..> DependencyPolicy : reads effective_deny/require
    matcher ..> DependencyPolicy : reads effective_deny
    discovery ..> DependencyPolicy : reads effective_deny/require
    note for _merge_list_field "None-transparency pattern:
None=no opinion, ()=explicit override,
truthy=union (same as unmanaged_files fix #1198)"
    note for DependencyPolicy "frozen=True; effective_deny/require
are runtime views; None stored internally"
    class DependencyPolicy:::touched
    class _merge_list_field:::touched
    class _merge_dependencies:::touched
    class _build_policy:::touched
    classDef touched fill:#fff3b0,stroke:#d47600
Loading
flowchart TD
    A(["YAML policy file"]) -->|"data.get('dependencies')"| B["[I/O] parser._build_policy"]
    B --> C{"_raw_deps is None?"}
    C -->|"yes: block absent"| D["deny=None, require=None"]
    C -->|"no: block present"| E{"'deny' in deps_data?"}
    E -->|"no"| F["deny=None"]
    E -->|"yes"| G["deny=_parse_tuple(deps_data['deny'])"]
    F --> H["DependencyPolicy(deny=None|tuple, require=None|tuple)"]
    D --> H
    G --> H
    H --> I["inheritance._merge_dependencies(parent, child)"]
    I --> J["_merge_list_field(parent.deny, child.deny)"]
    J --> K{"child is None?"}
    K -->|"yes: transparent"| L["return parent (or None if parent also None)"]
    K -->|"no, child empty ()"| M["return () -- explicit override"]
    K -->|"no, child truthy"| N["return union(parent, child)"]
    L --> O["merged DependencyPolicy"]
    M --> O
    N --> O
    O --> P["effective_deny property: None -> ()"]
    O --> Q["effective_require property: None -> ()"]
    P --> R["policy_checks._check_dependency_denylist"]
    Q --> S["policy_checks._check_required_packages"]
    P --> T["matcher.check_dependency_allowed"]
    P --> U["discovery._is_policy_empty"]
    Q --> U
Loading

Recommendation

Merge this PR. The core fix is correct, well-tested at the unit level, and closes a silent governance regression that directly affects org admins. The five follow-ups above are post-merge tasks, not blockers: the doc drift in policy-schema.md is the most urgent (patch it in the same release), the two missing test assertions are one-line additions (bundle into a fast follow-up commit or add before merge if the PR author is available), and the CHANGELOG reframe should land before the release note goes out. The deny: [] governance question is a design clarification, not a defect in this PR.


Full per-persona findings

Python Architect

  • [nit] _merge_list_field defined after its call site in _merge_dependencies at src/apm_cli/policy/inheritance.py:214
    Python resolves names at call time so this is correct, but the module convention (helpers before callers) is inverted here. Readers scanning top-to-bottom see the call at line ~140 and must scroll to line 214 to find the definition.
    Suggested: Move the _merge_list_field block (lines 214-240) to just before _merge_dependencies (line ~137).

  • [nit] _union((), parent) in None-transparent branch adds an unnecessary dedup pass at src/apm_cli/policy/inheritance.py:226
    When child is None and parent is not None, the code returns _union((), parent). DependencyPolicy is frozen=True and its field type is tuple[str, ...] | None, so parent is always a tuple in production. The guard conflates a test-quality concern with production logic.
    Suggested: Either enforce tuple type at DependencyPolicy construction (parser already does this) so the normalization is unnecessary, or add an assertion and return parent directly.

CLI Logging Expert

No findings.

DevX UX Expert

No findings.

Supply Chain Security Expert

  • [recommended] deny: [] in a child policy silently clears the parent deny list, violating the tighten-but-never-relax invariant at src/apm_cli/policy/inheritance.py:225
    inheritance.py states "Each level can tighten but never relax the parent." However, _merge_list_field returns () when child=(), unconditionally overriding a non-empty parent deny list. A repo author who writes "deny: []" in their apm-policy.yml will silently bypass all org-level blocked packages.
    Suggested: For deny lists, treat explicit empty as "no opinion" (same as None) rather than "clear parent". If intentional, remove "tighten but never relax" from the module docstring and add a governance warning that repo policies can clear org deny lists with "deny: []".
    Proof (passed): tests/unit/policy/test_inheritance.py::test_parent_deny_child_explicit_empty_deny -- proves: A repo policy with deny:[] clears the org deny list in the merged result. [governed-by-policy,secure-by-default]

  • [nit] No integration-with-fixtures test covers the full parse -> merge -> check_dependency_allowed pipeline for the extends:org None-transparency path
    Unit tests cover _merge_list_field and parser separately. No test loads a real org YAML with deny entries, merges a repo YAML that omits dependencies:, then runs check_dependency_allowed against the merged result end-to-end.
    Suggested: Add a test in tests/integration/ that loads org-policy.yml as parent, a repo YAML with no dependencies: block as child, and asserts "test-blocked/pkg" is denied by check_dependency_allowed.

OSS Growth Hacker

  • [recommended] CHANGELOG entry should call out the user impact, not just the internal mechanism
    The primary adoption segment for policy features is org admins. A changelog entry that leads with the internal fix ("None = no opinion pattern") instead of the user-facing symptom ("org-level deny/require rules were silently dropped") converts zero existing users to upgraders.
    Suggested: Reframe: "fix: org-level dependencies.require and dependencies.deny rules are no longer silently dropped for repos that use extends: org without an explicit dependencies block (closes extends: org also fails to layer dependencies.require from parent (same root as #1198) #1201)."

Auth Expert -- inactive

No auth files touched; PR only changes policy inheritance logic (DependencyPolicy sentinel values and merge semantics) with no effect on token management, credential resolution, host classification, or AuthResolver behavior.

Doc Writer

  • [recommended] policy-schema.md line 160 contradicts the new None=transparent semantic for deny/require lists at docs/src/content/docs/reference/policy-schema.md:160
    The docs currently state: "deny and require lists are always tuples (omitted = empty); they accumulate by union during merge." This is now incorrect -- omitted is no longer equivalent to empty. A user reading the reference will wrongly believe omitting the dependencies block overrides the parent with an empty list.
    Suggested: Replace with a three-state table mirroring the allow-list table above it: omitted = no opinion (transparent), [] = explicitly empty, [...] = union-merged with parent. Also update the Default column for deny and require from [] to "omitted (null)".

  • [nit] CHANGELOG entry uses Python-internal detail (None vs ()) that may confuse YAML authors
    YAML users think in terms of omitted keys vs empty lists, not Python None. The entry is accurate but implementation-leaning.
    Suggested: Reword to: "omitting the dependencies: block signals no opinion (transparent) while an explicit empty list signals intentional override."

Test Coverage Expert

  • [recommended] Integration merge test does not assert org require flows through when repo omits the require key at tests/integration/test_policy_discovery_e2e.py:146
    test_policy_merge_with_repo_override loads org-policy.yml (which has require: [DevExpGbb/required-standards]) and a repo YAML that omits require, merges them, but does NOT assert merged.dependencies.require contains "DevExpGbb/required-standards". A transparency regression would pass this test silently.
    Suggested: Add after existing deny assertions: self.assertIn("DevExpGbb/required-standards", merged.dependencies.require)
    Proof (missing at integration-with-fixtures): tests/integration/test_policy_discovery_e2e.py::TestPolicyMerging::test_policy_merge_with_repo_override -- proves: When a repo policy extends: org and omits the require block, the org's required packages still appear in the merged policy. [governed-by-policy,secure-by-default]

  • [recommended] Parser has no test for "deny: []" in YAML producing explicit-empty () rather than None at tests/unit/policy/test_parser.py
    Three parser cases exist: (a) dependencies absent -> deny=None, (b) dependencies present but deny absent -> deny=None, (c) deny: [] present and empty -> deny=(). Case (c) -- the explicit-empty override signal -- has no test. Without it, the distinction between absent and explicit-empty can silently break.
    Suggested:

    def test_explicit_empty_deny_produces_empty_tuple(self):
        yaml_str = "name: p\ndependencies:\n  deny: []\n"
        policy, _ = load_policy(yaml_str)
        self.assertEqual(policy.dependencies.deny, ())
        self.assertIsNotNone(policy.dependencies.deny)

    Proof (missing at unit): tests/unit/policy/test_parser.py::TestLoadPolicyFromString::test_explicit_empty_deny_produces_empty_tuple -- proves: A policy YAML with "deny: []" explicitly overrides the parent deny list with empty rather than being transparent. [governed-by-policy]

This panel is advisory. It does not block merge. Re-apply the
panel-review label after addressing feedback to re-run.

Generated by PR Review Panel for issue #1290 · ● 4M ·

@github-actions github-actions Bot removed the panel-review Trigger the apm-review-panel gh-aw workflow label May 12, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 15 out of 15 changed files in this pull request and generated 7 comments.

Comment thread packages/apm-guide/.apm/skills/apm-usage/governance.md Outdated
Comment thread src/apm_cli/policy/inheritance.py
Comment thread src/apm_cli/commands/policy.py
Comment thread tests/unit/policy/test_discovery.py
Comment thread docs/src/content/docs/reference/policy-schema.md Outdated
Comment thread docs/src/content/docs/reference/policy-schema.md Outdated
Comment thread docs/src/content/docs/reference/policy-schema.md Outdated
…curacy

- Replace all non-ASCII characters (em dash U+2014, right-arrow U+2192,
  section sign U+00A7) in test_discovery.py, policy-schema.md, and
  governance.md with printable ASCII equivalents ( -- , ->, section)
- Correct _merge_list_field docstring: third bullet now accurately
  describes all three cases (transparent, explicit-empty-override, union)
  instead of the misleading 'never removes from parent'
- Replace 'Merge rules (tighten-only)' headings with unqualified 'Merge
  rules' + a prose note that deny/require allow [] empty override; same
  fix applied to the companion governance.md heading and the inline
  'tighten-only' reference in section 3

Fixes review threads from PR #1290 (4th round).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

extends: org also fails to layer dependencies.require from parent (same root as #1198)

2 participants