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
5 changes: 2 additions & 3 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,8 @@ second model call: guardrails are repo-specific merge rules from `.postil/guardr
(violations are `kind: guardrail`); content policy reviews human-readable prose only
(Markdown, comments, docstrings, user-facing strings, PR title/body — never code logic
or identifiers) against a built-in baseline plus optional `.postil/content-policy.md`
additions (violations are `kind: contentPolicy`). Content policy is off by default;
either an explicit `contentPolicy.enabled: true` or the mere presence of
`.postil/content-policy.md` turns it on, mirroring how guardrails activates.
additions (violations are `kind: contentPolicy`). Content policy is on by default;
`contentPolicy.enabled: false` fully disables the baseline and repo additions.

## Invariants

Expand Down
32 changes: 18 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,17 +99,20 @@ the rule it breaks.

## Content policy

Off by default. Reviews human-readable prose in the diff — Markdown, code comments,
docstrings, user-facing/log strings, and the PR title/description, never code logic or
identifiers — for fabricated or contradicted documentation claims, self-contradictions
the same PR creates, authoring-process narration and AI-authorship residue, leaked
conversation/transcript text, and (lower severity) stale temporal/TODO residue and house
style. Violations are reported as `contentPolicy` findings that name the rule broken.

Turn it on with `contentPolicy.enabled: true` in `.postil.yaml` for the built-in
baseline, or drop repo-specific additions in `.postil/content-policy.md` (which also
turns it on by itself, the same way `.postil/guardrails.md` does); the file's rules are
appended to the baseline, not a replacement for it.
On by default. It reviews human-readable prose in the diff, including Markdown, code
comments, docstrings, user-facing/log strings, and the PR title/description, never code
logic or identifiers. It checks for fabricated or contradicted documentation claims,
self-contradictions the same PR creates, authoring-process narration and AI-authorship
residue, leaked conversation/transcript text, and (lower severity) stale temporal/TODO
residue and house style. Violations are reported as `contentPolicy` findings that name
the rule broken.

The built-in baseline reports fabricated or contradicted claims and conversation leaks
at `error`, self-contradictions and authorship residue at `warn`, and stale/style residue
at `info`. With the default `gate.failOn: error`, genuine violations of either error rule
fail the gate. Repo-specific rules in `.postil/content-policy.md` are appended to the
baseline, not a replacement for it. Set `contentPolicy.enabled: false` in `.postil.yaml`
to fully disable both the baseline and repo-specific additions.

## Configuration

Expand All @@ -132,9 +135,10 @@ gate:
failOn: error # info | warn | error | never
onError: block # block (fail closed, default) | advisory (fail open on
# provider outage only; unusable model output still blocks)
contentPolicy:
enabled: false # true reviews prose in the diff for fabricated claims, AI-
# authorship residue, and stale/style residue (see above)
# Content policy uses the built-in baseline by default and extends it with
# .postil/content-policy.md. Uncomment to fully opt out:
# contentPolicy:
# enabled: false
model:
name: deepseek/deepseek-v4-pro
cascade: [anthropic/claude-sonnet-4.6]
Expand Down
10 changes: 5 additions & 5 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@
- SARIF 2.1.0 output (`--sarif`) for code-scanning ingestion.
- Repo guardrails file (`.postil/guardrails.md`) injected into the prompt; violations
surface as `kind: guardrail` findings that quote the rule.
- Content policy (`contentPolicy.enabled` or `.postil/content-policy.md`, off by
default): reviews prose in the diff for fabricated/contradicted doc claims,
same-PR self-contradictions, AI-authorship/process-narration residue, leaked
conversation text, and stale temporal/TODO/style residue; surfaces as
`kind: contentPolicy`.
- Content policy (built-in baseline by default, optional
`.postil/content-policy.md` additions, `contentPolicy.enabled: false` opt-out):
reviews prose in the diff for fabricated/contradicted doc claims, same-PR
self-contradictions, AI-authorship/process-narration residue, leaked conversation
text, and stale temporal/TODO/style residue; surfaces as `kind: contentPolicy`.
- `postil plan` deterministic config dry-run; `postil doctor`; pre-push hook.
- `.coderabbit.yaml` translation for zero-cost migration.
- Model cascade + concurrent multi-model consensus over any OpenAI-compatible endpoint;
Expand Down
65 changes: 47 additions & 18 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ pub struct Config {
/// rules. Findings that violate one are emitted as `kind: guardrail`.
pub guardrails: Option<String>,
/// Active content-policy rules (built-in baseline, optionally extended by
/// `.postil/content-policy.md`), or `None` when the dimension is off.
/// `.postil/content-policy.md`), or `None` after an explicit opt-out.
/// Findings are emitted as `kind: contentPolicy`. See [`load`](Self::load).
pub content_policy: Option<String>,
/// Set by an explicit `contentPolicy: { enabled: false }`, so a repo can
Expand Down Expand Up @@ -122,7 +122,7 @@ impl Default for Config {
api_base: DEFAULT_API_BASE.to_string(),
consensus: 1,
guardrails: None,
content_policy: None,
content_policy: Some(BUILTIN_CONTENT_POLICY.to_string()),
content_policy_disabled: false,
source: "defaults".to_string(),
}
Expand Down Expand Up @@ -180,8 +180,8 @@ pub struct ModelSection {
/// documentation claims, self-contradictions the same PR creates, authoring-
/// process narration and AI-authorship residue, leaked conversation/transcript
/// text, and (low severity) stale temporal/TODO residue and house style.
/// Off by default; enabling it or dropping a `.postil/content-policy.md` file
/// turns it on. Findings are reported as `kind: contentPolicy`.
/// On by default; `contentPolicy: { enabled: false }` fully disables it.
/// Findings are reported as `kind: contentPolicy`.
#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct ContentPolicySection {
Expand Down Expand Up @@ -220,9 +220,8 @@ impl Config {
cfg.guardrails = Some(trimmed.to_string());
}
}
// A repo-specific content-policy file turns the dimension on (like
// guardrails.md) even without an explicit `contentPolicy.enabled:
// true`, unless that was explicitly set to false above.
// Repo-specific content-policy rules extend the built-in baseline,
// unless the repository explicitly opted out above.
if !cfg.content_policy_disabled {
let content_policy_path = root.join(".postil").join("content-policy.md");
if let Ok(text) = std::fs::read_to_string(&content_policy_path) {
Expand Down Expand Up @@ -334,7 +333,10 @@ impl Config {
}
if let Some(cp) = f.content_policy {
match cp.enabled {
Some(true) => self.content_policy = Some(BUILTIN_CONTENT_POLICY.to_string()),
Some(true) => {
self.content_policy = Some(BUILTIN_CONTENT_POLICY.to_string());
self.content_policy_disabled = false;
}
Some(false) => {
self.content_policy = None;
self.content_policy_disabled = true;
Expand Down Expand Up @@ -471,12 +473,10 @@ model:
# # trusted local repo.
# consensus: 1

# Content policy is on by default. Repo rules in .postil/content-policy.md extend
# the built-in baseline. Uncomment this explicit opt-out to fully disable it.
# contentPolicy:
# enabled: true # review prose in the diff (docs, comments, PR body) for
# # fabricated claims, AI-authorship residue, leaked
# # conversation text, and stale/style nits. Off by default;
# # also turns on automatically if .postil/content-policy.md
# # exists. See https://postil.dev/docs/content-policy
# enabled: false # See https://postil.dev/docs/content-policy
"#;

/// Built-in content-policy baseline, active whenever the dimension is on
Expand Down Expand Up @@ -631,10 +631,11 @@ mod tests {
}

#[test]
fn content_policy_off_by_default() {
fn content_policy_on_by_default() {
let dir = tempfile::tempdir().unwrap();
let c = Config::load(dir.path(), None).unwrap();
assert!(c.content_policy.is_none());
assert_eq!(c.content_policy.as_deref(), Some(BUILTIN_CONTENT_POLICY));
assert!(!c.content_policy_disabled);
}

#[test]
Expand All @@ -650,7 +651,19 @@ mod tests {
}

#[test]
fn content_policy_file_presence_turns_it_on_and_is_appended() {
fn builtin_content_policy_severity_profile_matches_default_gate() {
assert_eq!(BUILTIN_CONTENT_POLICY.matches("report at error").count(), 2);
assert_eq!(BUILTIN_CONTENT_POLICY.matches("report at warn").count(), 2);
assert_eq!(BUILTIN_CONTENT_POLICY.matches("report at info").count(), 2);

let c = Config::default();
assert!(c.gate_fail_on.fails(Severity::Error));
assert!(!c.gate_fail_on.fails(Severity::Warn));
assert!(!c.gate_fail_on.fails(Severity::Info));
}

#[test]
fn content_policy_file_is_appended_after_the_builtin_baseline() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join(".postil")).unwrap();
write(
Expand All @@ -660,8 +673,10 @@ mod tests {
);
let c = Config::load(dir.path(), None).unwrap();
let policy = c.content_policy.as_deref().unwrap();
assert!(policy.contains("Fabricated"));
assert!(policy.contains("Never mention unreleased pricing"));
assert!(policy.starts_with(BUILTIN_CONTENT_POLICY));
assert!(
policy.contains("--- REPO-SPECIFIC ADDITIONS ---\nNever mention unreleased pricing.")
);
}

#[test]
Expand Down Expand Up @@ -697,5 +712,19 @@ mod tests {
);
let c = Config::load(dir.path(), None).unwrap();
assert!(c.content_policy.is_none());
assert!(c.content_policy_disabled);
}

#[test]
fn content_policy_explicit_false_disables_the_default_baseline() {
let dir = tempfile::tempdir().unwrap();
write(
dir.path(),
".postil.yaml",
"contentPolicy:\n enabled: false\n",
);
let c = Config::load(dir.path(), None).unwrap();
assert!(c.content_policy.is_none());
assert!(c.content_policy_disabled);
}
}
4 changes: 2 additions & 2 deletions src/envelope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ pub enum Kind {
/// Violates the content policy: a fabricated/contradicted doc claim, a
/// self-contradiction the same PR creates, authoring-process or AI
/// narration residue, leaked conversation/transcript text, or (low
/// severity, opt-in noise) stale temporal/TODO residue and house style.
/// Only emitted when `contentPolicy` is active for the repo.
/// severity) stale temporal/TODO residue and house style. Only emitted when
/// `contentPolicy` is active for the repo.
ContentPolicy,
}

Expand Down
3 changes: 2 additions & 1 deletion src/prompt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,8 @@ mod tests {

#[test]
fn system_prompt_omits_content_policy_when_inactive() {
let cfg = Config::default();
let mut cfg = Config::default();
cfg.content_policy = None;
let p = system_prompt(&cfg);
assert!(!p.contains("CONTENT POLICY"));
}
Expand Down
15 changes: 3 additions & 12 deletions tests/e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -917,7 +917,7 @@ fn llm_with_summary(summary: &str, findings: Value) -> Value {
}

// Shared GitHub remote setup for the content-policy PR-description tests. The PR
// body carries prose the model flags; content policy is enabled via config.
// body carries prose the model flags; content policy is active by default.
async fn content_policy_pr_server(llm: Value) -> MockServer {
let server = MockServer::start().await;
Mock::given(method("POST"))
Expand Down Expand Up @@ -976,11 +976,6 @@ async fn content_policy_pr_body_finding_survives_grounding() {
.await;

let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join(".postil.yaml"),
"contentPolicy:\n enabled: true\n",
)
.unwrap();
let out = postil()
.current_dir(dir.path())
.env("POSTIL_API_BASE", server.uri())
Expand Down Expand Up @@ -1030,16 +1025,11 @@ async fn content_policy_pr_body_finding_survives_grounding() {

#[tokio::test]
async fn content_policy_clean_run_does_not_fail_close() {
// With content policy on and no violations, the run stays clean: the numbered
// With default content policy and no violations, the run stays clean: the numbered
// PR-description block must not induce a spurious ungrounded/fail-closed run.
let server = content_policy_pr_server(llm_with_summary("", json!([]))).await;

let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join(".postil.yaml"),
"contentPolicy:\n enabled: true\n",
)
.unwrap();
let out = postil()
.current_dir(dir.path())
.env("POSTIL_API_BASE", server.uri())
Expand Down Expand Up @@ -1873,6 +1863,7 @@ fn init_writes_starter_and_config_shows_provenance() {
let stdout = String::from_utf8(out.get_output().stdout.clone()).unwrap();
assert!(stdout.contains("source: .postil.yaml"));
assert!(stdout.contains("gate.failOn: error"));
assert!(stdout.contains("contentPolicy: active"));
}

#[test]
Expand Down
Loading