Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 53 additions & 7 deletions .conductor/registry/tests/lint-plan-level.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ agents:
- to: open_questions_gate
when: "{{ open_questions_policy.output.mode == 'manual' and architect.output.open_questions | length > 0 }}"
- to: open_questions_gate
when: "{{ open_questions_policy.output.mode == 'warning' and architect.output.open_questions | selectattr('severity', 'in', severities_at_or_above(open_questions_policy.output.min_severity)) | list | length > 0 }}"
when: "{{ open_questions_policy.output.mode == 'warning' and architect.output.open_questions | selectattr('severity', 'in', open_questions_policy.output.severities_at_or_above) | list | length > 0 }}"
- to: review_group
- name: open_questions_gate
type: human_gate
Expand Down Expand Up @@ -156,7 +156,7 @@ agents:
- to: open_questions_gate
when: "{{ open_questions_policy.output.mode == 'manual' and architect.output.open_questions | length > 0 }}"
- to: open_questions_gate
when: "{{ open_questions_policy.output.mode == 'warning' and architect.output.open_questions | selectattr('severity', 'in', severities_at_or_above(open_questions_policy.output.min_severity)) | list | length > 0 }}"
when: "{{ open_questions_policy.output.mode == 'warning' and architect.output.open_questions | selectattr('severity', 'in', open_questions_policy.output.severities_at_or_above) | list | length > 0 }}"
- to: review_group
- name: open_questions_gate
type: human_gate
Expand Down Expand Up @@ -231,7 +231,7 @@ agents:
- to: open_questions_gate
when: "{{ open_questions_policy.output.mode == 'manual' and architect.output.open_questions | length > 0 }}"
- to: open_questions_gate
when: "{{ open_questions_policy.output.mode == 'warning' and architect.output.open_questions | selectattr('severity', 'in', severities_at_or_above(open_questions_policy.output.min_severity)) | list | length > 0 }}"
when: "{{ open_questions_policy.output.mode == 'warning' and architect.output.open_questions | selectattr('severity', 'in', open_questions_policy.output.severities_at_or_above) | list | length > 0 }}"
- to: review_group
- name: open_questions_gate
type: human_gate
Expand Down Expand Up @@ -398,7 +398,7 @@ agents:
- to: open_questions_gate
when: "{{ open_questions_policy.output.mode == 'manual' and architect.output.open_questions | length > 0 }}"
- to: open_questions_gate
when: "{{ open_questions_policy.output.mode == 'warning' and architect.output.open_questions | selectattr('severity', 'in', severities_at_or_above(open_questions_policy.output.min_severity)) | list | length > 0 }}"
when: "{{ open_questions_policy.output.mode == 'warning' and architect.output.open_questions | selectattr('severity', 'in', open_questions_policy.output.severities_at_or_above) | list | length > 0 }}"
- name: open_questions_answer_counter
type: script
command: pwsh
Expand Down Expand Up @@ -471,7 +471,7 @@ agents:
- to: open_questions_gate
when: "{{ open_questions_policy.output.mode == 'manual' and architect.output.open_questions | length > 0 }}"
- to: open_questions_gate
when: "{{ open_questions_policy.output.mode == 'warning' and architect.output.open_questions | selectattr('severity', 'in', severities_at_or_above(open_questions_policy.output.min_severity)) | list | length > 0 }}"
when: "{{ open_questions_policy.output.mode == 'warning' and architect.output.open_questions | selectattr('severity', 'in', open_questions_policy.output.severities_at_or_above) | list | length > 0 }}"
- name: open_questions_answer_counter
type: script
command: pwsh
Expand Down Expand Up @@ -560,7 +560,7 @@ agents:
- to: open_questions_gate
when: "{{ open_questions_policy.output.mode == 'manual' and architect.output.open_questions | length > 0 }}"
- to: open_questions_gate
when: "{{ open_questions_policy.output.mode == 'warning' and architect.output.open_questions | selectattr('severity', 'in', severities_at_or_above(open_questions_policy.output.min_severity)) | list | length > 0 }}"
when: "{{ open_questions_policy.output.mode == 'warning' and architect.output.open_questions | selectattr('severity', 'in', open_questions_policy.output.severities_at_or_above) | list | length > 0 }}"
- name: open_questions_answer_counter
type: script
command: pwsh
Expand Down Expand Up @@ -646,7 +646,7 @@ agents:
- to: open_questions_gate
when: "{{ open_questions_policy.output.mode == 'manual' and architect.output.open_questions | length > 0 }}"
- to: open_questions_gate
when: "{{ open_questions_policy.output.mode == 'warning' and architect.output.open_questions | selectattr('severity', 'in', severities_at_or_above(open_questions_policy.output.min_severity)) | list | length > 0 }}"
when: "{{ open_questions_policy.output.mode == 'warning' and architect.output.open_questions | selectattr('severity', 'in', open_questions_policy.output.severities_at_or_above) | list | length > 0 }}"
- name: open_questions_answer_counter
type: script
command: pwsh
Expand Down Expand Up @@ -810,4 +810,50 @@ agents:
($output -join "`n") | Should -Match 'open-questions-policy-bad-type-field'
}
}

Context 'severities_at_or_above field reference (regression: dogfood apex #3043, 2026-05-08)' {

BeforeEach {
$script:TempRoot = Join-Path ([System.IO.Path]::GetTempPath()) "lint-plan-level-severities-$([guid]::NewGuid().ToString('N').Substring(0,8))"
$script:WorkflowsDir = Join-Path $script:TempRoot 'workflows'
$script:TestsDir = Join-Path $script:TempRoot 'tests'
$script:RealYaml = Join-Path $PSScriptRoot '..' 'workflows' 'plan-level.yaml'
New-Item $script:WorkflowsDir -ItemType Directory -Force | Out-Null
New-Item $script:TestsDir -ItemType Directory -Force | Out-Null
Copy-Item $script:LintScript (Join-Path $script:TestsDir 'lint-plan-level.ps1')
}

AfterEach {
Remove-Item $script:TempRoot -Recurse -Force -ErrorAction SilentlyContinue
}

It 'Real plan-level.yaml references the precomputed severities_at_or_above field' {
(Get-Content $script:RealYaml -Raw) | Should -Match 'open_questions_policy\.output\.severities_at_or_above'
}

It 'Real plan-level.yaml does NOT reference severities_at_or_above as a Jinja function call' {
(Get-Content $script:RealYaml -Raw) | Should -Not -Match 'severities_at_or_above\s*\('
}

It 'Lint fails when plan-level.yaml regresses to the legacy function-call form' {
$content = Get-Content $script:RealYaml -Raw
# Mutate every field reference back to the old function-call form
$mutated = $content -replace 'open_questions_policy\.output\.severities_at_or_above', 'severities_at_or_above(open_questions_policy.output.min_severity)'
Set-Content (Join-Path $script:WorkflowsDir 'plan-level.yaml') $mutated
$lintScript = Join-Path $script:TestsDir 'lint-plan-level.ps1'
$output = pwsh -NoProfile -File $lintScript 2>&1
$LASTEXITCODE | Should -Be 1
($output -join "`n") | Should -Match 'severities-at-or-above-as-function'
}

It 'Lint fails when the severities_at_or_above field reference is removed entirely' {
$content = Get-Content $script:RealYaml -Raw
$mutated = $content -replace 'open_questions_policy\.output\.severities_at_or_above', 'open_questions_policy.output.min_severity'
Set-Content (Join-Path $script:WorkflowsDir 'plan-level.yaml') $mutated
$lintScript = Join-Path $script:TestsDir 'lint-plan-level.ps1'
$output = pwsh -NoProfile -File $lintScript 2>&1
$LASTEXITCODE | Should -Be 1
($output -join "`n") | Should -Match 'missing-warning-mode-route'
}
}
}
20 changes: 17 additions & 3 deletions .conductor/registry/tests/lint-plan-level.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,24 @@ if ($content -notmatch "open_questions_policy\.output\.mode\s*==\s*'manual'") {
}

# ── Check 7: Policy-aware routes — mode==warning uses severities_at_or_above
if ($content -notmatch 'severities_at_or_above\(open_questions_policy\.output\.min_severity\)') {
# Rev 4 (2026-05-08): the workflow now references the precomputed
# `open_questions_policy.output.severities_at_or_above` list emitted by
# `polyphony policy resolve --domain open_questions`. The legacy form
# `severities_at_or_above(open_questions_policy.output.min_severity)` was a
# Jinja function call that conductor never honored — surfaced live in the
# #3043 dogfood as `'severities_at_or_above' is undefined`. Lint now enforces
# the field reference and rejects the legacy function form.
if ($content -notmatch 'open_questions_policy\.output\.severities_at_or_above') {
$violations += [PSCustomObject]@{
Rule = 'missing-warning-mode-route'
Detail = "No route condition using severities_at_or_above(open_questions_policy.output.min_severity)"
Detail = "No route condition referencing open_questions_policy.output.severities_at_or_above (precomputed severity list emitted by `polyphony policy resolve --domain open_questions`)."
}
}

if ($content -match 'severities_at_or_above\s*\(') {
$violations += [PSCustomObject]@{
Rule = 'severities-at-or-above-as-function'
Detail = "Found legacy `severities_at_or_above(...)` function-call form. Conductor has no such Jinja extension; reference the precomputed `open_questions_policy.output.severities_at_or_above` list field instead."
}
}

Expand All @@ -109,7 +123,7 @@ if ($content -notmatch 'severities_at_or_above\(open_questions_policy\.output\.m
if ($content -match "architect\.output\.open_questions\s*\|\s*selectattr\('severity',\s*'in',\s*\['") {
$violations += [PSCustomObject]@{
Rule = 'hardcoded-severity-filter'
Detail = "Hardcoded severity list found in architect routing — should use policy-driven severities_at_or_above()"
Detail = "Hardcoded severity list found in architect routing — should reference policy-driven open_questions_policy.output.severities_at_or_above"
}
}

Expand Down
4 changes: 2 additions & 2 deletions .conductor/registry/workflows/plan-level.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -622,7 +622,7 @@ agents:
when: "{{ open_questions_policy.output.mode == 'manual' and architect.output.open_questions | length > 0 }}"
# Mode=warning: gate only when questions at or above min_severity exist.
- to: open_questions_gate
when: "{{ open_questions_policy.output.mode == 'warning' and architect.output.open_questions | selectattr('severity', 'in', severities_at_or_above(open_questions_policy.output.min_severity)) | list | length > 0 }}"
when: "{{ open_questions_policy.output.mode == 'warning' and architect.output.open_questions | selectattr('severity', 'in', open_questions_policy.output.severities_at_or_above) | list | length > 0 }}"
# Else: no gating questions — proceed to write_plan.
- to: write_plan

Expand All @@ -638,7 +638,7 @@ agents:
finalizing the plan.

{% if open_questions_policy.output.mode == 'warning' %}
{% set blocking = architect.output.open_questions | selectattr('severity', 'in', severities_at_or_above(open_questions_policy.output.min_severity)) | list %}
{% set blocking = architect.output.open_questions | selectattr('severity', 'in', open_questions_policy.output.severities_at_or_above) | list %}
{% else %}
{% set blocking = architect.output.open_questions %}
{% endif %}
Expand Down
38 changes: 38 additions & 0 deletions docs/polyphony-state-effects-catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,31 @@ introduce new dependencies.
runtime. Pinned by lint check `open-questions-policy-bad-type-field`
in `lint-plan-level.ps1` as of this PR.

### `polyphony policy resolve --domain <d> --scope <s> [--path P]`
- **Purpose**: resolve effective policy for a `(scope, domain)` pair by
layering most-specific-wins (`type:Name` > `root` > `defaults`).
- **Pre**: `.conductor/policy.yaml` parseable (or absent — built-in
defaults applied).
- **Post**: emits `ResolvedRule` JSON. Domain-shaped fields are present
only when populated:
- `approvals` / `pr` → `{domain, scope, mode, max_revision_cycles?,
max_fix_loops?, max_remediation_cycles?, quality_avg_score_at_least?,
quality_blocking_count_at_most?}`.
- `open_questions` → adds `{min_severity, severities_at_or_above,
max_question_loops}`. `severities_at_or_above` is the precomputed
ascending list of severities at or above `min_severity` —
workflows route on this directly (no Jinja function call).
- **Side effects**: none.
- **Idempotent**: yes (read-only).
- **Severity-list gotcha**: workflows MUST reference
`open_questions_policy.output.severities_at_or_above` as a field, NOT
call `severities_at_or_above(...)` as a function. Bug #7 (dogfood
apex #3043, 2026-05-08) had two `severities_at_or_above(...)`
function-call references in `plan-level.yaml`; conductor 0.1.14 has
no such Jinja extension and crashed with `'severities_at_or_above'
is undefined`. Lint check `severities-at-or-above-as-function` in
`lint-plan-level.ps1` blocks the regression as of this PR.

---

## Helper scripts
Expand Down Expand Up @@ -229,6 +254,19 @@ introduce new dependencies.
output schemas would catch this class statically — partially
addressable under [#163](https://github.com/PolyphonyRequiem/polyphony/issues/163)
(property-based testing) but really wants its own pass.
- **Lint can enforce a Jinja contract conductor doesn't honor**: bug
#7 (dogfood apex #3043, 2026-05-08) had `lint-plan-level.ps1` Check 7
*requiring* `severities_at_or_above(open_questions_policy.output.min_severity)`
— a custom Jinja function the conductor runtime never registered.
Lint and workflow agreed; conductor crashed at runtime. Same
underlying gap as the bug-#6 entry above, but a different angle:
*lint contracts that aren't grounded in conductor's actual Jinja
environment* are equally vulnerable. Fix in this PR moved the
contract to a verb-output field (`severities_at_or_above`) emitted by
`polyphony policy resolve` so the lint, the workflow, and conductor
agree. A general remedy would be a registry of conductor-honored
Jinja functions/filters that lints can validate against — also
rolls into [#163](https://github.com/PolyphonyRequiem/polyphony/issues/163).
- **Wave integration idempotency**: not yet exercised; document after
first wave-integration smoke.
- **`apex-wave-dispatch.yaml` and per-lifecycle sub-workflows**: not
Expand Down
32 changes: 32 additions & 0 deletions src/Polyphony/Policy/PolicyResolver.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
namespace Polyphony.Policy;

using System.Text.Json.Serialization;
using Polyphony.Sdlc;

/// <summary>
Expand Down Expand Up @@ -70,12 +71,31 @@ _ when scope.StartsWith("type:", StringComparison.Ordinal) => scope,
MaxFixLoops = specific?.MaxFixLoops ?? defaults.MaxFixLoops,
MaxRemediationCycles = specific?.MaxRemediationCycles ?? defaults.MaxRemediationCycles,
MinSeverity = (specific?.MinSeverity ?? defaults.MinSeverity)?.ToString().ToLowerInvariant(),
SeveritiesAtOrAbove = ComputeSeveritiesAtOrAbove(specific?.MinSeverity ?? defaults.MinSeverity),
MaxQuestionLoops = specific?.MaxQuestionLoops ?? defaults.MaxQuestionLoops,
QualityAvgScoreAtLeast = quality?.AvgScoreAtLeast,
QualityBlockingCountAtMost = quality?.BlockingCountAtMost,
};
}

/// <summary>
/// Returns the lowercase string names of every <see cref="Severity"/> at
/// or above <paramref name="minSeverity"/>, ordered by enum declaration
/// (Low → Critical). Used by workflow routes to filter open-question
/// lists by severity threshold without needing a custom Jinja function.
/// Returns <c>null</c> when <paramref name="minSeverity"/> is null —
/// non-OpenQuestions domains have no severity threshold concept.
/// </summary>
private static List<string>? ComputeSeveritiesAtOrAbove(Severity? minSeverity)
{
if (minSeverity is null) return null;
var threshold = minSeverity.Value;
return Enum.GetValues<Severity>()
.Where(s => s >= threshold)
.Select(s => s.ToString().ToLowerInvariant())
.ToList();
}

private static QualityThreshold? MergeQuality(QualityThreshold? specific, QualityThreshold? defaults)
{
if (specific is null) return defaults;
Expand Down Expand Up @@ -153,6 +173,18 @@ public sealed record ResolvedRule
public int? MaxFixLoops { get; init; }
public int? MaxRemediationCycles { get; init; }
public string? MinSeverity { get; init; }

/// <summary>
/// Lowercase severity names at or above <see cref="MinSeverity"/>, in
/// ascending order (e.g. <c>["moderate","major","critical"]</c> for
/// <c>MinSeverity == "moderate"</c>). Null when MinSeverity is null
/// (non-OpenQuestions domains). Used by workflow routes to filter
/// architect-emitted open-question lists by severity without a
/// custom Jinja function.
/// </summary>
[JsonPropertyName("severities_at_or_above")]
public List<string>? SeveritiesAtOrAbove { get; init; }

public int? MaxQuestionLoops { get; init; }
public int? QualityAvgScoreAtLeast { get; init; }
public int? QualityBlockingCountAtMost { get; init; }
Expand Down
Loading
Loading