From ce66ffc2bbd0479f2e813c5749b1ed42a7d2c5a5 Mon Sep 17 00:00:00 2001
From: Paul Lizer
Date: Thu, 17 Sep 2026 17:19:57 -0400
Subject: [PATCH] Allow enabled empty content screening policies
Initialize a blank baseline on first activation and use normal upload processing until effective checks exist. Preserve existing holds, saved policies, and policy drafts across classic and React V2 settings changes.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
---
.../single_app/admin_settings_fields.py | 8 +-
application/single_app/config.py | 2 +-
.../single_app/content_screening/contracts.py | 13 +-
.../single_app/content_screening/jobs.py | 9 +
.../single_app/content_screening/policies.py | 8 +-
.../single_app/content_screening/service.py | 55 ++++-
application/single_app/functions_documents.py | 5 +-
application/single_app/functions_settings.py | 7 +
.../route_backend_content_screening.py | 7 +-
.../static/js/content-screening-policy.js | 130 +++++++---
.../single_app/static/js/content-screening.js | 6 +
.../admin/_panes/content-screening.html | 3 +-
.../screening/ScreeningPolicyEditor.tsx | 48 +++-
.../screening/ScreeningPolicyFields.tsx | 2 +-
.../v2_ui/src/lib/contentScreeningPolicy.ts | 25 +-
.../v2_ui/src/pages/AdminSettingsPage.tsx | 7 +-
docs/admin/security.md | 10 +-
.../features/CONTENT_SCREENING_FRAMEWORK.md | 8 +-
.../CONTENT_SCREENING_EMPTY_POLICY_FIX.md | 96 ++++++++
docs/guides/review-screened-documents.md | 13 +-
...st_content_screening_admin_settings_fix.py | 5 +-
.../test_content_screening_empty_policy.py | 223 ++++++++++++++++++
.../test_content_screening_engine.py | 13 +-
.../test_content_screening_pipeline.py | 196 ++++++++++++++-
.../test_content_screening_policy.py | 25 +-
.../test_content_screening_settings_api.py | 8 +-
.../test_v2_content_screening_logic.mjs | 39 ++-
.../fixtures/content_screening_classic.py | 13 +-
ui_tests/test_content_screening_classic.py | 42 +++-
.../test_content_screening_policy_parity.py | 173 +++++++++++++-
ui_tests/test_v2_content_screening.py | 13 +-
31 files changed, 1099 insertions(+), 113 deletions(-)
create mode 100644 docs/explanation/fixes/CONTENT_SCREENING_EMPTY_POLICY_FIX.md
create mode 100644 functional_tests/test_content_screening_empty_policy.py
diff --git a/application/single_app/admin_settings_fields.py b/application/single_app/admin_settings_fields.py
index 52a899a29..b9e0005da 100644
--- a/application/single_app/admin_settings_fields.py
+++ b/application/single_app/admin_settings_fields.py
@@ -5015,9 +5015,9 @@
"label": "Screen workspace content before publication",
"help": (
"Hold extracted workspace knowledge until required checks complete. "
- "Findings require an authorized workspace review. Configure an active "
- "screening policy before enabling; disabling future scans never releases "
- "existing holds."
+ "Enabling creates an enabled empty baseline if none exists. With no "
+ "applicable checks, new uploads use normal processing. Add checks later; "
+ "emptying a policy or disabling future scans never releases existing holds."
),
"default": False,
"requires": {
@@ -5033,7 +5033,7 @@
"type": "component",
"component": "content-screening-policy",
"label": "Screening policies and scans",
- "help": "Edit required PII, regex, value, and model checks; policies are saved separately from Admin Settings.",
+ "help": "Save an empty policy or configure PII, regex, value, and model checks; policies are saved separately from Admin Settings.",
},
],
"content-safety-section": [
diff --git a/application/single_app/config.py b/application/single_app/config.py
index 91ed41c58..f23840645 100644
--- a/application/single_app/config.py
+++ b/application/single_app/config.py
@@ -97,7 +97,7 @@
EXECUTOR_TYPE = 'thread'
EXECUTOR_MAX_WORKERS = 30
SESSION_TYPE = 'filesystem'
-VERSION = "0.261.113"
+VERSION = "0.261.114"
IS_DEVELOPMENT = is_development_env_enabled()
SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax')
diff --git a/application/single_app/content_screening/contracts.py b/application/single_app/content_screening/contracts.py
index 1886b0a27..7e1b0895f 100644
--- a/application/single_app/content_screening/contracts.py
+++ b/application/single_app/content_screening/contracts.py
@@ -53,8 +53,17 @@ class ScreeningPolicyRequiredError(ScreeningConfigurationError):
code = "screening_policy_required"
status_code = 400
public_message = (
- "Save an enabled policy with at least one rule or model check under "
- "Security > Content Screening before enabling new scans."
+ "The saved content screening policy is unavailable. Save Content Screening "
+ "settings again to initialize a missing policy. Existing holds are unchanged."
+ )
+
+
+class ScreeningChecksRequiredError(ScreeningConfigurationError):
+ code = "screening_policy_empty"
+ status_code = 400
+ public_message = (
+ "No active checks are configured for this workspace. Add and save a rule or "
+ "AI check before starting a scan. An empty policy can stay enabled; existing holds are unchanged."
)
diff --git a/application/single_app/content_screening/jobs.py b/application/single_app/content_screening/jobs.py
index b812f3c79..708090bc0 100644
--- a/application/single_app/content_screening/jobs.py
+++ b/application/single_app/content_screening/jobs.py
@@ -20,9 +20,11 @@
HELD_STATES,
SCREENING_FIELD,
SCOPE_TYPES,
+ ScreeningChecksRequiredError,
ScreeningConfigurationError,
ScreeningConflictError,
ScreeningError,
+ ScreeningPolicyRequiredError,
ScreeningValidationError,
Subject,
content_fingerprint,
@@ -35,6 +37,7 @@
)
from content_screening.repository import MAX_DOCUMENT_SELECTION, get_repository
from content_screening.permissions import ScreeningPermissionError, assert_scope_access
+from content_screening.policies import compose_policy, policy_is_active
LEASE_SECONDS = 300
@@ -162,9 +165,13 @@ def _configuration_snapshot(selection, repository):
raise ScreeningConfigurationError("Enable content screening before creating a scan job.")
service.validate_screening_configuration(settings, repository=repository, check_storage=True)
baseline = repository.get_policy("global", "global")
+ if baseline is None:
+ raise ScreeningPolicyRequiredError()
workspace = None
if not selection.get("all_workspaces"):
workspace = repository.get_policy(selection["scope_type"], selection["scope_id"])
+ if not policy_is_active(compose_policy(baseline["policy"], workspace["policy"] if workspace else None)):
+ raise ScreeningChecksRequiredError()
return {
"baseline": baseline.get("policy_fingerprint") or hash_payload(baseline["policy"]) if baseline else None,
"workspace": workspace.get("policy_fingerprint") or hash_payload(workspace["policy"]) if workspace else None,
@@ -1129,6 +1136,8 @@ def _process_item(repository, job, item, owner, processor, *, completion_only=Fa
status, code = "incomplete" if item.get("started") else "skipped", "screening_permission_revoked"
elif isinstance(exc, ScreeningConflictError):
status = "incomplete" if item.get("started") else "skipped"
+ elif code == "screening_policy_empty":
+ status = "incomplete" if item.get("started") else "skipped"
elif code == "screening_table_source_missing":
status = "incomplete"
elif int(item.get("attempts", 0)) >= MAX_ATTEMPTS:
diff --git a/application/single_app/content_screening/policies.py b/application/single_app/content_screening/policies.py
index 26e875d10..6f61d1c59 100644
--- a/application/single_app/content_screening/policies.py
+++ b/application/single_app/content_screening/policies.py
@@ -4,7 +4,7 @@
Starter rules are indicators, not a complete PII or prompt-injection classifier.
Email support is Unicode-aware; phone checks cover common North American and
international-plus formats, SSNs are US-specific, and cards use Luhn validation.
-Administrators must explicitly select checks before enabling screening.
+An enabled empty policy is valid configuration, but does not enroll new content.
"""
import math
@@ -407,8 +407,6 @@ def normalize_policy(value, *, scope_type="global"):
}
if scope_type != "global" and result["allowed_models"]:
_invalid("Only administrators can approve scanner models.")
- if result["enabled"] and not (any(rule["enabled"] for rule in result["rules"]) or result["ai"]["enabled"]):
- _invalid("Select at least one active check before enabling screening.", code="screening_policy_empty")
result["fingerprint"] = hash_payload(result)
return result
@@ -479,12 +477,8 @@ def normalize_effective_policy(value):
"baseline_fingerprint": _fingerprint(value["baseline_fingerprint"]),
"workspace_fingerprint": _fingerprint(value["workspace_fingerprint"], allow_none=True),
}
- if enabled and not (rules or ai_checks):
- _invalid("The effective policy has no active checks.", code="screening_policy_empty")
if not enabled and (rules or ai_checks):
_invalid("A disabled effective policy cannot contain required checks.")
- if enabled and not any(item["origin"] == "global" for item in rules + ai_checks):
- _invalid("An effective policy must retain required baseline checks.")
for check in ai_checks:
if check["model_selection"] not in result["allowed_models"]:
_invalid("An effective AI check selected an unapproved model.")
diff --git a/application/single_app/content_screening/service.py b/application/single_app/content_screening/service.py
index 615e47ea1..b6d97e4d6 100644
--- a/application/single_app/content_screening/service.py
+++ b/application/single_app/content_screening/service.py
@@ -21,6 +21,7 @@
DocumentHeldError,
Finding,
InspectionResult,
+ ScreeningChecksRequiredError,
ScreeningCitationsRequiredError,
ScreeningConfigurationError,
ScreeningConflictError,
@@ -42,6 +43,7 @@
capture_table_source,
publication_context,
)
+from content_screening.policies import compose_policy, default_policy, policy_is_active
LEASE_SECONDS = 1800
@@ -104,35 +106,64 @@ def _save_scan(repository, scan, **updates):
return repository.replace({**scan, **updates, "updated_at": _timestamp()}, scan["_etag"])
-def get_effective_policy(subject, *, repository=None):
- from content_screening.policies import compose_policy, default_policy, policy_is_active
+def initialize_screening_policy(*, repository=None):
+ """Create the first empty baseline without overwriting a concurrent policy."""
+ repository = _repository(repository)
+ baseline = repository.get_policy("global", "global")
+ if baseline is not None:
+ return baseline
+ policy = {**default_policy(), "enabled": True}
+ try:
+ return repository.save_policy("global", "global", policy, "system-content-screening")
+ except ScreeningConflictError:
+ baseline = repository.get_policy("global", "global")
+ if baseline is None:
+ raise
+ return baseline
+
+def get_effective_policy(subject, *, repository=None, require_active=True):
repository = _repository(repository)
baseline = repository.get_policy("global", "global")
+ if baseline is None:
+ raise ScreeningPolicyRequiredError()
workspace = repository.get_policy(subject.scope_type, subject.scope_id)
policy = compose_policy(
- baseline["policy"] if baseline else default_policy(),
+ baseline["policy"],
workspace["policy"] if workspace else None,
)
- if not policy_is_active(policy):
- raise ScreeningConfigurationError("An active content screening policy is required.")
+ if require_active and not policy_is_active(policy):
+ raise ScreeningChecksRequiredError()
return policy
-def validate_screening_configuration(settings=None, *, repository=None, check_storage=False, proposed_settings=False):
+def document_requires_screening(document, settings=None, *, repository=None):
+ """Persisted enrollment always wins over the absence of checks for new uploads."""
+ if not isinstance(document, dict):
+ raise ScreeningValidationError("The document metadata is unavailable.")
+ if SCREENING_FIELD in document:
+ return True
+ if _settings(settings).get("enable_content_screening") is not True:
+ return False
+ return policy_is_active(get_effective_policy(
+ subject_from_document(document), repository=repository, require_active=False,
+ ))
+
+
+def validate_screening_configuration(settings=None, *, repository=None, check_storage=False,
+ proposed_settings=False, allow_missing_policy=False):
settings = _settings(settings)
if settings.get("enable_content_screening") is not True:
return
if settings.get("enable_enhanced_citations") is not True:
raise ScreeningCitationsRequiredError()
- from content_screening.policies import compose_policy, default_policy, policy_is_active
-
repository = _repository(repository)
baseline = repository.get_policy("global", "global")
- effective = compose_policy(baseline["policy"] if baseline else default_policy())
- if not policy_is_active(effective):
+ if baseline is None and not allow_missing_policy:
raise ScreeningPolicyRequiredError()
+ # Settings preflight may precede first activation; the write initializes the policy.
+ effective = compose_policy(baseline["policy"] if baseline else default_policy())
if effective.get("ai_checks"):
from content_screening.model import validate_model_bindings
@@ -146,7 +177,7 @@ def validate_screening_configuration(settings=None, *, repository=None, check_st
def initial_document_marker(document, settings=None):
- if _settings(settings).get("enable_content_screening") is not True:
+ if not document_requires_screening(document, settings):
return None
subject = subject_from_document(document)
return {
@@ -378,7 +409,7 @@ def prepare_document_upload(document_id, user_id, temp_file_path, original_filen
extraction_mode_override=None):
settings = _settings()
document = _document_for_upload(document_id, user_id, group_id, public_workspace_id)
- if settings.get("enable_content_screening") is not True and SCREENING_FIELD not in document:
+ if not document_requires_screening(document, settings):
return None
if settings.get("enable_content_screening") is not True:
raise DocumentHeldError("Enable content screening before replacing inspected content.")
diff --git a/application/single_app/functions_documents.py b/application/single_app/functions_documents.py
index 1f5066d50..0bba29e95 100644
--- a/application/single_app/functions_documents.py
+++ b/application/single_app/functions_documents.py
@@ -31,6 +31,7 @@
is_publication,
)
from content_screening.service import (
+ document_requires_screening,
initial_document_marker,
prepare_document_deletion,
prepare_document_upload,
@@ -8182,7 +8183,7 @@ def _download_document_source_to_temp_file(document_item, user_id=None, group_id
def process_document_reprocess_extraction_background(document_id, user_id, target_extraction_mode, group_id=None, public_workspace_id=None):
"""Extract a stored PDF or image again with an explicit Standard/Enhanced mode."""
document = get_document_metadata(document_id, user_id, group_id, public_workspace_id)
- if get_settings().get("enable_content_screening") is True or (document and SCREENING_FIELD in document):
+ if document_requires_screening(document, get_settings()):
return reprocess_document(
subject_from_document(document), user_id,
normalize_document_intelligence_manual_extraction_mode(target_extraction_mode),
@@ -9378,7 +9379,7 @@ def _resolve_processing_complete_status(total_chunks_saved, file_ext, image_exte
def process_document_upload_background(document_id, user_id, temp_file_path, original_filename, group_id=None, public_workspace_id=None, extraction_mode_override=None):
"""Keep screened intake private until its complete, revision-bound decision."""
document = get_document_metadata(document_id, user_id, group_id, public_workspace_id)
- if get_settings().get("enable_content_screening") is True or (document and SCREENING_FIELD in document):
+ if document_requires_screening(document, get_settings()):
return process_screened_upload(
document_id, user_id, temp_file_path, original_filename,
_process_document_upload_background_impl,
diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py
index ca770b6c4..d1b36e418 100644
--- a/application/single_app/functions_settings.py
+++ b/application/single_app/functions_settings.py
@@ -2135,6 +2135,7 @@ def validate_content_screening_settings(new_settings, current_settings, *, repos
validate_screening_configuration(
merged, repository=repository, check_storage=activating or storage_changed,
proposed_settings=True,
+ allow_missing_policy=new_settings.get('enable_content_screening') is True,
)
except ScreeningError:
raise
@@ -2173,6 +2174,12 @@ def update_settings(new_settings):
with embedding_settings_write_guard(
original, settings_item, force_check=EMBEDDING_SELECTION_KEY in new_settings,
):
+ if new_settings.get('enable_content_screening') is True:
+ # First activation is create-only; a concurrent policy must be revalidated.
+ from content_screening.service import initialize_screening_policy, validate_screening_configuration
+
+ initialize_screening_policy()
+ validate_screening_configuration(settings_item, proposed_settings=True)
persisted = cosmos_settings_container.replace_item(
item="app_settings", body=settings_item, etag=original["_etag"],
match_condition=MatchConditions.IfNotModified,
diff --git a/application/single_app/route_backend_content_screening.py b/application/single_app/route_backend_content_screening.py
index 657329eaf..8de24f1df 100644
--- a/application/single_app/route_backend_content_screening.py
+++ b/application/single_app/route_backend_content_screening.py
@@ -383,7 +383,7 @@ def content_screening_get_policy(scope_type, scope_id):
@login_required
@user_required
def content_screening_save_policy(scope_type, scope_id):
- from content_screening.policies import compose_policy, default_policy, normalize_policy, policy_is_active
+ from content_screening.policies import compose_policy, default_policy, normalize_policy
scope_id = normalize_identifier(scope_id, "scope_id")
_authorize_policy(scope_type, scope_id)
@@ -392,10 +392,7 @@ def content_screening_save_policy(scope_type, scope_id):
raise ScreeningValidationError()
repository = _repository()
policy = normalize_policy(value["policy"], scope_type=scope_type)
- if scope_type == "global":
- if (get_settings() or {}).get("enable_content_screening") is True and not policy_is_active(compose_policy(policy)):
- raise ScreeningConfigurationError()
- else:
+ if scope_type != "global":
baseline = repository.get_policy("global", "global")
compose_policy(baseline["policy"] if baseline else default_policy(), policy)
repository.save_policy(scope_type, scope_id, policy, _actor_id(), etag=value["etag"])
diff --git a/application/single_app/static/js/content-screening-policy.js b/application/single_app/static/js/content-screening-policy.js
index 302b69d4a..b7bf37d6b 100644
--- a/application/single_app/static/js/content-screening-policy.js
+++ b/application/single_app/static/js/content-screening-policy.js
@@ -54,6 +54,20 @@
return left?.endpoint_id === right?.endpoint_id && left?.model_id === right?.model_id;
}
+ function isPolicyInitialization(previous, next) {
+ if (previous.enabled || !next.enabled || previous.rules.length || next.rules.length
+ || previous.ai.enabled || next.ai.enabled) return false;
+ const configuration = policy => JSON.stringify([
+ policy.schema_version,
+ Object.entries(policy.ai).filter(([key]) => key !== "model_selection").sort(([left], [right]) => left.localeCompare(right)),
+ policy.ai.model_selection?.endpoint_id || "",
+ policy.ai.model_selection?.model_id || "",
+ policy.allowed_models || [],
+ Object.entries(policy.limits).sort(([left], [right]) => left.localeCompare(right))
+ ]);
+ return configuration(previous) === configuration(next);
+ }
+
class PolicyEditor {
constructor(root, scope) {
this.root = root;
@@ -61,6 +75,7 @@
this.sequence = 0;
this.stale = false;
this.dirty = false;
+ this.busy = false;
this.message = element("div", "alert d-none");
this.message.setAttribute("role", "alert");
this.panel = element("div");
@@ -70,6 +85,10 @@
async load(scope = this.scope) {
const sequence = ++this.sequence;
this.scope = scope;
+ if (scope.scopeType === "global") {
+ const capability = document.getElementById("enable_content_screening");
+ if (capability) capability.disabled = true;
+ }
this.panel.replaceChildren(element("p", "text-body-secondary", "Loading policy and prerequisites…"));
clearMessage(this.message);
try {
@@ -103,7 +122,9 @@
(baseline?.rules || []).filter(rule => rule.enabled).forEach(rule => {
rules.appendChild(element("li", "", `${rule.name} · ${rule.severity} · ${rule.category}`));
});
- if (!rules.childElementCount) rules.appendChild(element("li", "", "Administrator-required checks are retained by the server. Their private rule values are not exposed here."));
+ if (!rules.childElementCount) rules.appendChild(element("li", "", baseline?.rule_count === 0
+ ? "No administrator deterministic checks are configured. Workspace additions can supply checks while the baseline is enabled."
+ : "Administrator-required checks are retained by the server. Their private rule values are not exposed here."));
region.appendChild(rules);
if (Number.isInteger(baseline?.rule_count)) {
region.appendChild(element("p", "small mb-2", `${baseline.rule_count} mandatory deterministic rule${baseline.rule_count === 1 ? "" : "s"}.`));
@@ -150,6 +171,7 @@
if (!global) this.renderBaseline(this.data.baseline);
this.panel.append(
element("h3", "h5", global ? "Mandatory screening policy" : "Workspace additions"),
+ ...(global ? [element("p", "small text-body-secondary", "Enabling Content Screening creates an enabled empty baseline if none exists. You can save it empty and add checks later.")] : []),
element("p", "small text-body-secondary", "Policies are saved separately from application settings. Pattern checks are indicators, not a guarantee that all PII or instruction manipulation will be detected.")
);
const summary = element("div", "alert alert-secondary");
@@ -186,9 +208,12 @@
(policy.rules || []).forEach(rule => this.addRule(rule));
const addButtons = element("div", "d-flex flex-wrap gap-2 mb-3");
addButtons.append(
- button("Add literal rule", "btn btn-outline-secondary", () => this.addRule({ type: "literal" })),
- button("Add regex rule", "btn btn-outline-secondary", () => this.addRule({ type: "regex" })),
- button("Add PII rule", "btn btn-outline-secondary", () => this.addRule({ type: "pii" }))
+ ...["literal", "regex", "pii"].map(type => button(
+ `Add ${type === "pii" ? "PII" : type} rule`, "btn btn-outline-secondary", () => {
+ this.addRule({ type });
+ this.dirty = true;
+ }
+ ))
);
controls.appendChild(addButtons);
this.renderModels(controls, policy, global);
@@ -216,6 +241,7 @@
controls.addEventListener("change", () => { this.dirty = true; this.updateSummary(); });
this.renderSample(controls);
this.updateSummary();
+ this.setBusy(this.busy);
if (!canEdit) showMessage(this.message, "This policy is read-only. The server has not granted policy editing for this workspace.", "info");
}
@@ -248,6 +274,13 @@
const requiredAi = global ? 0 : inherited.ai_check_count;
const rules = requiredRules + (this.enabledInput.checked ? this.ruleEditors.filter(rule => rule.enabled.checked).length : 0);
const ai = requiredAi + (this.enabledInput.checked && this.aiEnabled?.checked ? 1 : 0);
+ if (!rules && !ai) {
+ this.summaryLabel.textContent = "No active checks configured";
+ this.summaryDetail.textContent = global
+ ? "This policy can stay enabled and empty. New uploads use normal processing unless their workspace adds checks. Existing holds are unchanged."
+ : "New uploads use normal processing until checks are added. Existing holds are unchanged.";
+ return;
+ }
this.summaryLabel.textContent = `${rules} deterministic check${rules === 1 ? "" : "s"} | ${ai ? `${ai} AI check${ai === 1 ? "" : "s"}` : "AI screening off"}`;
this.summaryDetail.textContent = requiredAi
? `Includes ${requiredAi} required administrator AI check${requiredAi === 1 ? "" : "s"}. Disabling workspace AI additions does not disable required checks.`
@@ -450,52 +483,95 @@
if (this.scope.scopeType === "global" && capability) capability.disabled = true;
}
+ setBusy(busy) {
+ this.busy = busy;
+ const canEdit = hasAction(this.data.allowed_actions, "edit_policy");
+ this.controls.disabled = busy || this.stale || !canEdit;
+ this.saveButton.disabled = busy || this.stale || !canEdit;
+ this.reloadButton.disabled = busy;
+ this.testButton.disabled = busy || this.stale || !hasAction(this.data.allowed_actions, "test_policy");
+ const capability = document.getElementById("enable_content_screening");
+ if (this.scope.scopeType === "global" && capability) {
+ capability.disabled = busy || this.stale || !canEdit
+ || (!this.data.prerequisites?.ready && !capability.checked);
+ }
+ }
+
async configure(capability) {
+ if (this.busy || this.stale) return;
const enabled = capability.checked;
- if (enabled && !this.data.policy.enabled) {
- capability.checked = false;
- showMessage(this.message, "Save an enabled mandatory policy with at least one active check before enabling new scans.", "warning");
- return;
- }
- capability.disabled = true;
+ let configurationSaved = false;
+ this.setBusy(true);
clearMessage(this.message);
try {
- await screening.api.configure(enabled);
- await this.load();
+ const configuration = await screening.api.configure(enabled);
+ configurationSaved = true;
+ capability.checked = configuration.enabled === true;
+ const next = await screening.api.getPolicy(this.scope);
+ const initialized = this.data.etag === null && isPolicyInitialization(this.data.policy, next.policy);
+ if (this.dirty) {
+ if (next.etag !== this.data.etag && !initialized) throw new screening.ScreeningError(409);
+ if (initialized && this.enabledInput.checked === this.data.policy.enabled) {
+ this.enabledInput.checked = next.policy.enabled;
+ }
+ this.data = next;
+ this.updateSummary();
+ } else {
+ this.data = next;
+ this.render();
+ }
+ capability.checked = next.configuration.enabled === true;
+ this.root.dispatchEvent(new CustomEvent("screening:policy-loaded", {
+ bubbles: true, detail: { scope: this.scope, data: next }
+ }));
showMessage(this.message, enabled
- ? "New Content Screening is enabled. Required checks run before enrolled knowledge is published."
+ ? "Content Screening is enabled. Empty policies do not screen new uploads. Save policy edits separately to apply checks; existing holds are unchanged."
: "New scans are disabled. Existing holds, review evidence, and approved-with-flags warnings remain in effect.", "success");
} catch (error) {
- capability.checked = !enabled;
- this.freezeOnConflict(error);
- showMessage(this.message, [400, 409, 503].includes(error.status)
- ? "Content Screening could not be changed. Verify the mandatory policy, Enhanced Citations, and its private storage. Existing holds are unchanged."
- : errorMessage(error));
+ if (configurationSaved) {
+ this.stale = true;
+ showMessage(this.message, "Screening settings were saved, but the saved policy changed or could not be refreshed. Your policy draft is retained. Reload the saved policy before saving or testing again.", "warning");
+ } else {
+ capability.checked = !enabled;
+ this.freezeOnConflict(error);
+ showMessage(this.message, [400, 409, 503].includes(error.status)
+ ? "Content Screening could not be changed. Verify Enhanced Citations, its private storage, and any configured scanner models. Existing holds are unchanged."
+ : errorMessage(error));
+ }
} finally {
- capability.disabled = this.stale || (!this.data.prerequisites?.ready && !capability.checked);
+ this.setBusy(false);
}
}
async save() {
- if (this.stale || !hasAction(this.data.allowed_actions, "edit_policy")) return;
- this.saveButton.disabled = true;
+ if (this.busy || this.stale || !hasAction(this.data.allowed_actions, "edit_policy")) return;
+ this.setBusy(true);
clearMessage(this.message);
try {
const policy = this.readPolicy();
- await screening.api.savePolicy(this.scope, policy, this.data);
- await this.load();
+ const saved = await screening.api.savePolicy(this.scope, policy, this.data);
+ this.data = { ...this.data, ...saved, baseline: saved.inherited_summary };
+ this.dirty = false;
+ this.render();
+ this.root.dispatchEvent(new CustomEvent("screening:policy-loaded", {
+ bubbles: true, detail: { scope: this.scope, data: this.data }
+ }));
showMessage(this.message, "Screening policy saved. Existing holds still require explicit review.", "success");
} catch (error) {
this.freezeOnConflict(error);
showMessage(this.message, errorMessage(error));
} finally {
- this.saveButton.disabled = this.stale || !hasAction(this.data.allowed_actions, "edit_policy");
+ this.setBusy(false);
}
}
async test() {
- if (this.stale || !hasAction(this.data.allowed_actions, "test_policy") || !this.sampleText.value.trim()) return;
- this.testButton.disabled = true;
+ if (this.busy || this.stale || !hasAction(this.data.allowed_actions, "test_policy") || !this.sampleText.value.trim()) return;
+ if (!this.ruleEditors.some(rule => rule.enabled.checked) && !this.aiEnabled.checked) {
+ showMessage(this.message, "Add an enabled rule or AI check before testing. An empty policy can still be saved.", "warning");
+ return;
+ }
+ this.setBusy(true);
clearMessage(this.message);
this.sampleResult.replaceChildren(element("p", "text-body-secondary", "Testing required checks…"));
try {
@@ -514,7 +590,7 @@
this.freezeOnConflict(error);
showMessage(this.message, errorMessage(error));
} finally {
- this.testButton.disabled = this.stale || !hasAction(this.data.allowed_actions, "test_policy");
+ this.setBusy(false);
}
}
}
diff --git a/application/single_app/static/js/content-screening.js b/application/single_app/static/js/content-screening.js
index 4fb97c0e9..7bb9a7593 100644
--- a/application/single_app/static/js/content-screening.js
+++ b/application/single_app/static/js/content-screening.js
@@ -66,6 +66,12 @@
}
function errorMessage(error) {
+ if (error?.code === "screening_policy_empty") {
+ return "No active checks are configured for this workspace. Add and save a rule or AI check before starting a scan. An empty policy can stay enabled; existing holds are unchanged.";
+ }
+ if (error?.code === "screening_policy_required") {
+ return "The saved content screening policy is unavailable. Save Content Screening settings again to initialize a missing policy. Existing holds are unchanged.";
+ }
if (error?.status === 409 || error?.status === 412 || error?.status === 428) {
return "This revision or policy has changed. Refresh required; no decision was applied by this request.";
}
diff --git a/application/single_app/templates/admin/_panes/content-screening.html b/application/single_app/templates/admin/_panes/content-screening.html
index 93f04368b..5e786a5f0 100644
--- a/application/single_app/templates/admin/_panes/content-screening.html
+++ b/application/single_app/templates/admin/_panes/content-screening.html
@@ -15,7 +15,8 @@
Off by default. Requires Enhanced Citations and its working private storage configuration under
Chat > Citations > Enhanced.
- Save the mandatory policy below before enabling new scans. Disabling new scans never releases a held document.
+ Enabling creates an enabled empty baseline if none exists. New uploads use normal processing until
+ applicable checks are added. Disabling new scans or emptying a policy never releases a held document.
This switch saves immediately; detailed policies are saved separately below.
diff --git a/application/v2_ui/src/components/screening/ScreeningPolicyEditor.tsx b/application/v2_ui/src/components/screening/ScreeningPolicyEditor.tsx
index b613eb436..ac7ae26fc 100644
--- a/application/v2_ui/src/components/screening/ScreeningPolicyEditor.tsx
+++ b/application/v2_ui/src/components/screening/ScreeningPolicyEditor.tsx
@@ -15,6 +15,7 @@ import {
import {
approvedScreeningChoices,
editableScreeningPolicy,
+ isScreeningPolicyInitialization,
screeningCatalogChoices,
screeningPolicyTemplates,
screeningPolicySummary,
@@ -31,9 +32,13 @@ import { ScreeningPolicyFields } from './ScreeningPolicyFields';
function PolicyEditor({
scope,
onSaved,
+ configurationVersion = 0,
+ disabled: externallyDisabled = false,
}: {
scope: ScreeningScope;
onSaved?: (response: ScreeningPolicyResponse) => void;
+ configurationVersion?: number;
+ disabled?: boolean;
}) {
const catalog = useBootstrapStore((state) => state.data?.catalogs.models);
const [response, setResponse] = useState(null);
@@ -49,6 +54,8 @@ function PolicyEditor({
const [refresh, setRefresh] = useState(0);
const active = useRef(true);
const inFlight = useRef(false);
+ const dirty = useRef(false);
+ const previousConfigurationVersion = useRef(configurationVersion);
const global = scope.scope_type === 'global';
const configuredModels = useMemo(() => screeningCatalogChoices(catalog ?? []), [catalog]);
const models = useMemo(
@@ -67,6 +74,8 @@ function PolicyEditor({
useEffect(() => {
const controller = new AbortController();
+ const preserveDraft = previousConfigurationVersion.current !== configurationVersion;
+ previousConfigurationVersion.current = configurationVersion;
setLoading(true);
setError(null);
setSaved(false);
@@ -74,11 +83,31 @@ function PolicyEditor({
setSampleResult(null);
void fetchScreeningPolicy(scope, controller.signal).then((next) => {
if (controller.signal.aborted) return;
+ if (preserveDraft && dirty.current && policy && response) {
+ const initialized = response.etag === null && isScreeningPolicyInitialization(response.policy, next.policy);
+ if (next.etag !== response.etag && !initialized) {
+ setError('The saved policy changed while screening settings were saved. Your policy draft has been retained.');
+ setStale(true);
+ return;
+ }
+ setResponse(next);
+ if (initialized && policy.enabled === response.policy.enabled) {
+ setPolicy({ ...policy, enabled: next.policy.enabled });
+ }
+ setStale(false);
+ return;
+ }
setResponse(next);
setPolicy(editableScreeningPolicy(next.policy, global));
+ dirty.current = false;
setStale(false);
}).catch((failure) => {
if (controller.signal.aborted) return;
+ if (preserveDraft && !(failure instanceof ApiError && failure.isAuthError)) {
+ setError('Screening settings were saved, but the policy could not be refreshed. Your policy draft has been retained.');
+ setStale(true);
+ return;
+ }
setResponse(null);
setPolicy(null);
setError(screeningErrorMessage(failure));
@@ -86,12 +115,15 @@ function PolicyEditor({
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
- }, [scope.scope_type, scope.scope_id, global, refresh]);
+ }, [scope.scope_type, scope.scope_id, global, refresh, configurationVersion]);
async function submit(kind: 'save' | 'test') {
- if (!policy || !response || inFlight.current || loading || stale) return;
+ if (!policy || !response || inFlight.current || loading || stale || externallyDisabled) return;
const errors = validateScreeningPolicy(policy, models);
if (kind === 'test' && !sample.trim()) errors.push('Enter sample content to inspect.');
+ if (kind === 'test' && !policy.rules.some((rule) => rule.enabled) && !policy.ai.enabled) {
+ errors.push('Add an enabled rule or AI check before testing. An empty policy can still be saved.');
+ }
setValidation(errors);
if (errors.length) return;
inFlight.current = true;
@@ -106,6 +138,7 @@ function PolicyEditor({
if (!active.current) return;
setResponse(next);
setPolicy(editableScreeningPolicy(next.policy, global));
+ dirty.current = false;
setSaved(true);
onSaved?.(next);
} else {
@@ -128,7 +161,7 @@ function PolicyEditor({
}
}
- const disabled = busy || stale || loading;
+ const disabled = externallyDisabled || busy || stale || loading;
const summary = policy && response ? screeningPolicySummary(policy, global, response.inherited_summary) : null;
return (
@@ -137,18 +170,18 @@ function PolicyEditor({
{global ? 'Required screening policy' : 'Workspace policy additions'}
{global
- ? 'Define the mandatory checks here, save the policy, then enable new scans using the separate Admin Settings switch.'
+ ? 'Enabling Content Screening creates an enabled empty baseline if none exists. You can save it empty and add checks later.'
: 'These checks add to the required administrator baseline. They cannot remove or weaken its rules.'}
{' '}Policy saves are independent of the Admin Settings Save button and do not release held documents.
- setRefresh((value) => value + 1)}>
{stale ? 'Reload current policy' : 'Reload saved policy'}
{error ? {error}
: null}
- {stale ? The saved policy changed. Reload it before saving or testing again; your draft has not overwritten it.
: null}
+ {stale ? Reload the saved policy before saving or testing again; your draft has not overwritten it.
: null}
{validation.length ?
{validation.map((message) => - {message}
)}
: null}
@@ -164,6 +197,7 @@ function PolicyEditor({
: null}
{
+ dirty.current = true;
setPolicy(next);
setSaved(false);
setSampleResult(null);
@@ -204,6 +238,8 @@ function PolicyEditor({
export function ScreeningPolicyEditor(props: {
scope: ScreeningScope;
onSaved?: (response: ScreeningPolicyResponse) => void;
+ configurationVersion?: number;
+ disabled?: boolean;
}) {
return ;
}
diff --git a/application/v2_ui/src/components/screening/ScreeningPolicyFields.tsx b/application/v2_ui/src/components/screening/ScreeningPolicyFields.tsx
index 0de85d843..7e2c8ac00 100644
--- a/application/v2_ui/src/components/screening/ScreeningPolicyFields.tsx
+++ b/application/v2_ui/src/components/screening/ScreeningPolicyFields.tsx
@@ -131,7 +131,7 @@ export function ScreeningPolicyFields({
onChange({ ...policy, enabled })} />
diff --git a/application/v2_ui/src/lib/contentScreeningPolicy.ts b/application/v2_ui/src/lib/contentScreeningPolicy.ts
index 3a53a187c..f6c1bcc0b 100644
--- a/application/v2_ui/src/lib/contentScreeningPolicy.ts
+++ b/application/v2_ui/src/lib/contentScreeningPolicy.ts
@@ -106,6 +106,20 @@ export function addScreeningStarterPack(policy: ScreeningPolicy, rules: Screenin
};
}
+export function isScreeningPolicyInitialization(previous: ScreeningPolicy, next: ScreeningPolicy): boolean {
+ if (previous.enabled || !next.enabled || previous.rules.length || next.rules.length
+ || previous.ai.enabled || next.ai.enabled) return false;
+ const configuration = (policy: ScreeningPolicy) => JSON.stringify([
+ policy.schema_version,
+ Object.entries(policy.ai).filter(([key]) => key !== 'model_selection').sort(([left], [right]) => left.localeCompare(right)),
+ policy.ai.model_selection?.endpoint_id ?? '',
+ policy.ai.model_selection?.model_id ?? '',
+ policy.allowed_models ?? [],
+ Object.entries(policy.limits).sort(([left], [right]) => left.localeCompare(right)),
+ ]);
+ return configuration(previous) === configuration(next);
+}
+
export function screeningPolicySummary(
policy: ScreeningPolicy, baseline: boolean, inherited?: ScreeningBaselineSummary | null,
): { label: string; detail: string } {
@@ -122,6 +136,14 @@ export function screeningPolicySummary(
const requiredAi = baseline ? 0 : inherited?.ai_check_count ?? 0;
const rules = requiredRules + (policy.enabled ? policy.rules.filter((rule) => rule.enabled).length : 0);
const ai = requiredAi + (policy.enabled && policy.ai.enabled ? 1 : 0);
+ if (!rules && !ai) {
+ return {
+ label: 'No active checks configured',
+ detail: baseline
+ ? 'This policy can stay enabled and empty. New uploads use normal processing unless their workspace adds checks. Existing holds are unchanged.'
+ : 'New uploads use normal processing until checks are added. Existing holds are unchanged.',
+ };
+ }
return {
label: `${rules} deterministic check${rules === 1 ? '' : 's'} | ${ai ? `${ai} AI check${ai === 1 ? '' : 's'}` : 'AI screening off'}`,
detail: requiredAi
@@ -224,9 +246,6 @@ export function screeningModelCatalog(models: DefaultModelChoice[]): AdminModelC
export function validateScreeningPolicy(policy: ScreeningPolicy, models: DefaultModelChoice[]): string[] {
const errors: string[] = [];
- if (policy.enabled && !policy.rules.some((rule) => rule.enabled) && !policy.ai.enabled) {
- errors.push('Add at least one enabled rule or model check before enabling the policy.');
- }
const ids = new Set();
for (const rule of policy.rules) {
if (!rule.id || ids.has(rule.id)) {
diff --git a/application/v2_ui/src/pages/AdminSettingsPage.tsx b/application/v2_ui/src/pages/AdminSettingsPage.tsx
index 64157ae7c..68ec16647 100644
--- a/application/v2_ui/src/pages/AdminSettingsPage.tsx
+++ b/application/v2_ui/src/pages/AdminSettingsPage.tsx
@@ -221,6 +221,7 @@ export function AdminSettingsPage() {
const [draft, setDraft] = useState({});
const [saving, setSaving] = useState(false);
+ const [screeningConfigurationVersion, setScreeningConfigurationVersion] = useState(0);
const [fieldErrors, setFieldErrors] = useState>({});
const [fieldWarnings, setFieldWarnings] = useState>({});
const [pendingAck, setPendingAck] = useState(null);
@@ -551,6 +552,9 @@ export function AdminSettingsPage() {
);
setFieldWarnings(response.warnings ?? {});
setDraft({});
+ if (response.updated_keys.includes('enable_content_screening')) {
+ setScreeningConfigurationVersion((version) => version + 1);
+ }
void refreshBootstrap();
// Enabling connections carries the classic chat endpoint into the connection
@@ -742,7 +746,8 @@ export function AdminSettingsPage() {
onClick={() => goToSection('enhanced-citations-section')}>
Configure Enhanced Citations
-
+
);
diff --git a/docs/admin/security.md b/docs/admin/security.md
index a4969d597..406deec08 100644
--- a/docs/admin/security.md
+++ b/docs/admin/security.md
@@ -153,7 +153,9 @@ The feature requires Enhanced Citations and reuses its storage account for priva
Open **Admin Settings > Security > Content Screening** in either interface. The tab is visible even when Enhanced Citations is off; only activation is blocked by that prerequisite. It does not depend on **Enable Content Safety**.
-Use **Save screening policy** to persist rules and model criteria independently of the main Admin Settings save. Enable the policy and add at least one active check before enabling new scans. In V2, change **Screen workspace content before publication**, then use **Save changes**. A rejected or failed write is displayed as an error and is not reported as saved.
+You can enable Content Screening before choosing checks. First activation creates an enabled empty baseline if no policy has been saved; it never replaces an existing policy or activates a deliberately disabled baseline. In V2, change **Screen workspace content before publication**, then use **Save changes**. The classic interface saves its new-scan switch immediately.
+
+Use **Save screening policy** to persist rules and model criteria independently of the main Admin Settings save. An enabled policy with no checks is valid and stays enabled after saving. New uploads follow normal processing when their effective policy has no checks; no screening result or hold is created. Enabled workspace additions can supply checks even when the baseline is empty. Adding checks later screens subsequent uploads; use an explicit workspace scan for existing knowledge.
Both editors provide **Add literal rule**, **Add regex rule**, and **Add PII rule**, plus the same four **Starter rule pack** choices. Deterministic rules run in code, not through a model. Adding a pack again leaves existing rules and their edits intact.
@@ -161,13 +163,13 @@ Both editors provide **Add literal rule**, **Add regex rule**, and **Add PII rul
The separate **Models workspaces may use** section is a permission list, not additional scanners to execute. It remains editable while baseline AI checks are off. The baseline's saved scanner is automatically permitted; its marked checkbox does not create an additional explicit permission. Workspace managers must enable their own AI check to use a permitted model.
-The **Configured screening checks** summary describes the current draft. Workspace summaries include required administrator checks even when local additions are off. An inactive baseline makes workspace additions inactive; disabling future scans never releases an existing document hold.
+The **Configured screening checks** summary describes the current draft and explicitly identifies an enabled policy with no active checks. Workspace summaries include required administrator checks even when local additions are off. A disabled baseline makes workspace additions inactive; emptying a policy or disabling future scans never releases an existing document hold. Sample testing requires a check to evaluate, but an empty policy can still be saved.
-Added in **0.261.106**; admin discovery and save feedback corrected in **0.261.107**; classic/V2 policy-editor alignment implemented in **0.261.108**, tracked in `application\single_app\config.py`. See [Screen and review workspace documents]({{ '/guides/review-screened-documents/' | relative_url }}) for policy selection, existing-workspace scans, reviewer roles, and remediation limits.
+Added in **0.261.106**; admin discovery and save feedback corrected in **0.261.107**; classic/V2 policy-editor alignment implemented in **0.261.108**; enabled-empty policy configuration implemented in **0.261.114**, tracked in `application\single_app\config.py`. See [Screen and review workspace documents]({{ '/guides/review-screened-documents/' | relative_url }}) for policy selection, existing-workspace scans, reviewer roles, and remediation limits.
| Setting | What it does | Default | Notes |
| --- | --- | --- | --- |
-| Enable Content Screening | Prevents workspace documents from becoming usable knowledge before inspection and any required review. Existing holds remain enforced if future scanning is disabled. | Off | `enable_content_screening`; requires Enhanced Citations, working storage, and an active policy |
+| Enable Content Screening | Applies configured baseline and workspace checks before new documents become usable knowledge. With no applicable checks, new uploads use normal processing; existing holds remain enforced. | Off | `enable_content_screening`; requires Enhanced Citations and working storage; creates an enabled empty baseline if absent |
## Content Safety {#content-safety}
diff --git a/docs/explanation/features/CONTENT_SCREENING_FRAMEWORK.md b/docs/explanation/features/CONTENT_SCREENING_FRAMEWORK.md
index 82cdefeb9..e75439c80 100644
--- a/docs/explanation/features/CONTENT_SCREENING_FRAMEWORK.md
+++ b/docs/explanation/features/CONTENT_SCREENING_FRAMEWORK.md
@@ -6,7 +6,7 @@ Content screening creates an admission checkpoint between document extraction an
**Implemented in version: 0.261.106.** The application version is managed in `application\single_app\config.py`.
-**Current documentation version: 0.261.113.** Classic/V2 policy-editor alignment was implemented in 0.261.108; the original framework implementation remains 0.261.106.
+**Current documentation version: 0.261.114.** Enabled-empty policy configuration was implemented in 0.261.114; classic/V2 policy-editor alignment was implemented in 0.261.108; the original framework implementation remains 0.261.106.
**Dependencies:** Enhanced Citations and its configured storage account, the existing Cosmos DB and workspace knowledge services, and an approved model connection when a policy includes model evaluation.
@@ -30,6 +30,8 @@ Release also records the exact approved Blob and content-derived metadata finger
Administrators define a required baseline. Authorized workspace managers can add rules and instructions, but a workspace pass cannot cancel a baseline finding.
+An enabled policy may contain no checks. Enabling Content Screening creates an enabled empty baseline only when no policy exists, without selecting detectors or a scanner. New uploads with no effective checks follow ordinary processing without a screening marker, evidence, or a fabricated passing result. Enabled workspace additions may supply checks under an empty enabled baseline. A disabled baseline still disables additions, and persisted document holds retain their release requirements.
+
| Evaluator | Intended use | Important limit |
| --- | --- | --- |
| Structured PII patterns | Identify common structured identifiers, email/phone patterns, and similar recognizable values. | Patterns are not a universal detector of names, addresses, or all regulated data. |
@@ -95,6 +97,8 @@ The optional **Enable AI checks** switch precedes the policy's single scanner an
Configured-check summaries count enabled local and mandatory baseline rules/model checks. Disabling workspace additions does not hide required administrator AI checks; a disabled baseline makes additions inactive. Summaries describe the draft rather than the separate enrollment capability.
+Since **0.261.114**, enabling and saving the feature no longer requires selecting checks first. Enabled empty policies can be saved, and the summary explains that new uploads are not screened until applicable checks are added. The classic toggle persists immediately; V2 uses **Save changes**. Both editors refresh an automatically created baseline without discarding policy drafts or silently overwriting a concurrent administrator's edits. Detailed policy changes still use **Save screening policy**. Sample testing requires checks to evaluate and never describes an empty policy as a clean inspection.
+
The **0.261.113** React V2 integration retains these controls alongside unified embedding/image connections and durable Analyze results. Sequential and isolated concurrent Analyze model calls recheck source availability, final coverage retains screening provenance, and completed checkpoints cannot bypass a later hold. Saved-result responses and exports retain both their source-access rules and screening checks.
Use the [content-review guide]({{ '/guides/review-screened-documents/' | relative_url }}) for baseline selection, existing-workspace scans, and remediation. The capability is distinct from the existing Azure AI Content Safety chat-category feature.
@@ -107,6 +111,8 @@ Functional coverage lives in `functional_tests\test_content_screening_*.py`, wit
`ui_tests\test_content_screening_policy_parity.py` runs the same custom-rule, starter-pack, AI-toggle, permission, and inherited-summary workflows against both real interfaces, including narrow and desktop layouts. Logic/rendering regressions cover the V2 helpers, and the engine tests prove that saved scanner references and permission lists do not invoke disabled AI checks.
+Enabled-empty policy coverage also exercises first activation, save/reload without checks, later starter-pack insertion, clearing the last check, draft preservation, and concurrency. Backend coverage distinguishes unmarked new uploads with no applicable checks from previously enrolled or held documents; empty policies do not bypass review or publication proof.
+
The core cases include a last-page finding, complete window coverage, regex deadlines, strict model responses, sticky review holds, authorization, revision conflicts, safe derivatives, and recovery from partial publication.
This release covers workspace knowledge, including chat files handed off to a workspace. It does not add ordinary message screening, chat-only attachment screening, outbound web-search preflight, or agent-to-agent message inspection.
diff --git a/docs/explanation/fixes/CONTENT_SCREENING_EMPTY_POLICY_FIX.md b/docs/explanation/fixes/CONTENT_SCREENING_EMPTY_POLICY_FIX.md
new file mode 100644
index 000000000..091cd374e
--- /dev/null
+++ b/docs/explanation/fixes/CONTENT_SCREENING_EMPTY_POLICY_FIX.md
@@ -0,0 +1,96 @@
+# Content Screening Empty Policy Fix
+
+**Fixed in version: 0.261.114**, recorded in `application\single_app\config.py`.
+
+## Issue and root cause
+
+Content Screening could not be enabled until an administrator separately saved
+an enabled policy with at least one active check. Classic rejected the new-scan
+switch immediately, while V2 accepted the draft switch and rejected the later
+Admin Settings save. Visible policy edits were not the persisted policy used by
+either activation request.
+
+The policy schema conflated an enabled feature with an executable inspection.
+Upload admission also used the feature flag alone, so simply removing the
+settings error would have held new uploads against an empty policy.
+
+## Behavior
+
+Enabling Content Screening creates an enabled empty global baseline if no
+policy exists. Existing policies are preserved, including deliberately disabled
+baselines. An enabled policy can be saved with no rules, all rules disabled, or
+AI checks off. No starter rules or models are selected automatically.
+
+New uploads whose effective policy has no checks use ordinary processing.
+An enabled workspace addition can supply checks under an empty enabled
+baseline. Adding checks later applies them to subsequent uploads; existing
+knowledge requires an explicit scan.
+
+Persisted holds, evidence, review decisions, and publication requirements do not
+change. A policy becoming empty does not clear an earlier finding or create a
+passing inspection. Enhanced Citations, its private storage, configured model
+validation, scope authorization, and conditional writes remain enforced.
+
+## Interface and settings comparison
+
+| Operation | Classic V1 | React V2 |
+| --- | --- | --- |
+| Enable new scanning | Switch saves immediately | Switch is saved with Admin Settings **Save changes** |
+| Save policy rules and model criteria | **Save screening policy** | **Save screening policy** |
+| Save an enabled policy without checks | Allowed | Allowed |
+| First activation with no saved policy | Creates an enabled empty baseline | Creates an enabled empty baseline |
+| Unsaved policy edits during activation | Retained; initialization revision refreshed safely | Retained; initialization revision refreshed safely |
+| Concurrent policy changes | Draft retained; reload required rather than overwriting | Draft retained; reload required rather than overwriting |
+| Test an empty policy | Explains that testing needs a check; saving remains available | Same |
+
+Both editors retain custom literal, regex, and PII rules; per-rule severity,
+category, and enabled controls; optional AI scanner criteria and window limits;
+independent workspace model permissions; and execution limits. The
+**No active checks configured** summary distinguishes an empty policy from
+actual screening coverage.
+
+The shared starter packs are unchanged:
+
+| Pack | Checks |
+| --- | --- |
+| `structured_pii_v1` | Email addresses, phone numbers, US Social Security numbers, payment cards |
+| `sensitive_text_v1` | Confidentiality markings |
+| `credentials_v1` | Private-key markers and GitHub-token indicators |
+| `prompt_manipulation_v1` | Instruction overrides and source-ranking manipulation |
+
+Both editors also retain the prompt-manipulation and sensitive-information AI
+criteria. Starter-pack and custom-rule parity was already implemented in
+0.261.108; this fix changes activation and empty-policy behavior rather than
+adding another rule catalog.
+
+## Implementation and validation
+
+The backend changes policy normalization, shared settings activation, and
+checks-aware document admission in `content_screening`, `functions_settings.py`,
+and `functions_documents.py`. Empty configuration is distinguished from the
+active policy required to inspect or release previously enrolled content.
+
+The classic `content-screening-policy.js` and V2 `ScreeningPolicyEditor` retain
+drafts across activation and refresh the newly created policy revision. They
+do not advance a dirty draft onto a different administrator's saved policy.
+Help text in the admin pane and field schema describes the empty state.
+
+`ui_tests\test_content_screening_policy_parity.py` covers the same activation,
+empty-save, later-rule, and conflict workflows in both interfaces using their
+real local assets and synthetic API boundaries. The existing Azure/local
+Playwright fixture is reused. `functional_tests\test_v2_content_screening_logic.mjs`
+checks empty-policy validation, summary text, and safe initialization matching.
+Backend functional coverage exercises policy composition, settings persistence,
+new uploads, and retained holds without live Azure services.
+
+At 0.261.114, the content-screening functional suite passed 643 tests and
+688 subtests. The V2 production build, 30 logic checks, 8 rendering checks,
+route-policy coverage, documentation inventory/quality checks, and targeted
+browser/XSS/access guardrails also passed. Shared policy browser coverage
+includes first activation, blank saves, later checks, draft preservation,
+creation-revision updates, and explicit failure feedback.
+
+Before this fix, first-time activation required preconfigured checks and failed
+at different points in each interface. Afterward, administrators can enable
+the capability first and deliberately configure inspection later, without
+treating unscreened uploads as screened or releasing existing holds.
diff --git a/docs/guides/review-screened-documents.md b/docs/guides/review-screened-documents.md
index 43d0eeb88..c272ffed2 100644
--- a/docs/guides/review-screened-documents.md
+++ b/docs/guides/review-screened-documents.md
@@ -4,7 +4,7 @@ title: "Screen and review workspace documents"
description: "Inspect sensitive or manipulative extracted content, keep it out of knowledge use, and release only a reviewed version."
section: "Guides"
audience: user
-version: "0.261.108"
+version: "0.261.114"
---
## What this does
@@ -21,7 +21,11 @@ An administrator must configure Enhanced Citations and its storage account befor
Open **Admin Settings > Security > Content Screening**. This is a separate tab from Content Safety in both the classic and V2 interfaces. You can prepare the policy before configuring Enhanced Citations; an unmet storage prerequisite does not hide the editor.
-Add or edit the rules, enable **Baseline policy enabled**, then select **Save screening policy**. That save is separate from the application's enrollment switch. After Enhanced Citations is configured, enable new scans; V2 calls this **Screen workspace content before publication** and persists it with **Save changes**. You do not need to enable Azure AI Content Safety.
+After Enhanced Citations is configured, enable Content Screening. V2 calls this **Screen workspace content before publication** and persists it with **Save changes**; the classic new-scan switch saves immediately. If no baseline exists, activation creates an enabled empty policy. An existing policy is preserved, including its enabled or disabled state. You do not need to enable Azure AI Content Safety.
+
+You can leave the policy empty and save it without adding rules or choosing a model. New uploads follow normal processing until their effective baseline/workspace policy contains checks. They are not labeled as having passed screening. Existing held documents still require review; clearing every rule is not a way to release them.
+
+When ready, add individual rules, a starter pack, or an optional AI check, leave **Baseline policy enabled** on, and select **Save screening policy**. Policy saves remain separate from application settings saves. The saved checks apply to subsequent uploads; scan existing knowledge explicitly when it also needs inspection.
Choose baseline checks that reflect the information your organization actually needs to control. A policy that treats every email address as unacceptable can create a large review queue for otherwise ordinary public documents. Use representative allowed examples and known matches when testing the rules.
@@ -49,6 +53,8 @@ Read **Configured screening checks** before saving. It shows enabled determinist
**Policy-editor alignment implemented in version: 0.261.108**, tracked in `application\single_app\config.py`.
+**Enabled-empty policy configuration implemented in version: 0.261.114**, tracked in `application\single_app\config.py`. An empty enabled baseline allows enabled workspace additions to supply checks. **No active checks configured** means new uploads use normal processing when that draft is saved and no workspace checks apply. Sample testing needs an enabled rule or AI check; an empty draft is still saveable.
+
## Inspect existing knowledge
Choose the documents or workspaces to inspect. Only administrators can start an all-workspace scan across private and shared scopes.
@@ -92,7 +98,8 @@ Review original files as potentially untrusted content. Do not follow links or i
| Symptom | Meaning and response |
| --- | --- |
| The model did not return a valid result | The scan is incomplete or failed. Retry after correcting the model/configuration; do not treat it as a clean scan. |
-| Enabling screening is rejected | Save an enabled baseline containing a rule or model check, and configure Enhanced Citations storage. The error beside the screening switch identifies a missing prerequisite. |
+| Enabling screening is rejected | Check Enhanced Citations storage and any configured scanner models. A policy with no checks is valid; a failed storage or settings write is not reported as saved. |
+| Screening is enabled but new uploads are not screened | The saved effective policy has no enabled checks. Add a rule, starter pack, or AI check, then save the policy. Workspace additions can supply checks under an enabled empty baseline. |
| Admin Settings reports that the write failed | The change was not saved. Keep the draft or reload the current settings before retrying; do not assume the displayed draft is persisted. |
| A retry looks clean but the document is still held | A previous finding still needs a human decision. |
| The document has a warning after approval | It was approved with flags; the warning is intentional. |
diff --git a/functional_tests/test_content_screening_admin_settings_fix.py b/functional_tests/test_content_screening_admin_settings_fix.py
index 7fdce4be0..b681d3e49 100644
--- a/functional_tests/test_content_screening_admin_settings_fix.py
+++ b/functional_tests/test_content_screening_admin_settings_fix.py
@@ -1,8 +1,9 @@
# test_content_screening_admin_settings_fix.py
"""
Functional regressions for discoverable and persistent screening administration.
-Version: 0.261.113
+Version: 0.261.114
Implemented in: 0.261.107
+Enabled-empty policies implemented in: 0.261.114
Executes the actual V2 settings handler with isolated storage boundaries. Failed
writes cannot report success, and Content Safety is not a screening prerequisite.
@@ -135,7 +136,7 @@ def test_policy_rejection_is_visible_at_the_screening_switch():
payload, status = invoke(namespace, {"enable_content_screening": True})
assert status == 400
assert payload["error_code"] == "screening_policy_required"
- assert "enabled policy" in payload["field_errors"]["enable_content_screening"]
+ assert "saved content screening policy" in payload["field_errors"]["enable_content_screening"]
namespace["update_settings"].assert_not_called()
diff --git a/functional_tests/test_content_screening_empty_policy.py b/functional_tests/test_content_screening_empty_policy.py
new file mode 100644
index 000000000..a16a2ff82
--- /dev/null
+++ b/functional_tests/test_content_screening_empty_policy.py
@@ -0,0 +1,223 @@
+# test_content_screening_empty_policy.py
+"""
+Functional tests for enabled-empty Content Screening configuration.
+Version: 0.261.114
+Implemented in: 0.261.114
+
+Exercise real policy persistence and shared settings writes behind isolated
+Cosmos, Blob, and authenticated route fixtures. No deployed services are used.
+"""
+
+import ast
+import copy
+import sys
+from contextlib import nullcontext
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import Mock, patch
+
+import pytest
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "application" / "single_app"))
+
+# Reuse the existing isolated application loaders instead of starting Azure clients.
+import test_content_screening_admin_settings_fix as admin_settings_tests
+import test_content_screening_settings_api as settings_tests
+from test_content_screening_persistence import FakeBlobService, FakeCosmos
+from content_screening import service
+from content_screening.contracts import (
+ SCREENING_FIELD,
+ ScreeningConfigurationError,
+ ScreeningConflictError,
+ ScreeningPolicyRequiredError,
+ subject_from_document,
+)
+from content_screening.policies import default_policy, normalize_policy
+from content_screening.repository import ScreeningRepository
+from content_screening.storage import ScreeningStorage
+
+
+@pytest.fixture
+def activation(monkeypatch):
+ repository = ScreeningRepository(FakeCosmos())
+ settings = {
+ "id": "app_settings", "_etag": "settings-1",
+ "enable_content_screening": False, "enable_enhanced_citations": True,
+ }
+ container = Mock()
+ container.read_item.side_effect = lambda **kwargs: copy.deepcopy(settings)
+
+ def replace(**kwargs):
+ assert kwargs["etag"] == settings["_etag"]
+ settings.update(copy.deepcopy(kwargs["body"]))
+ settings["_etag"] = f"settings-{container.replace_item.call_count + 1}"
+ return copy.deepcopy(settings)
+
+ container.replace_item.side_effect = replace
+ helpers = settings_tests.settings_functions(
+ get_settings=lambda **kwargs: copy.deepcopy(settings),
+ cosmos_settings_container=container,
+ )
+ monkeypatch.setattr(service, "_repository", lambda value=None: repository if value is None else value)
+ monkeypatch.setattr(service, "_settings", lambda value=None: settings if value is None else value)
+ monkeypatch.setitem(sys.modules, "config", SimpleNamespace(
+ build_enhanced_citations_blob_service_client=lambda values: FakeBlobService(),
+ ))
+ monkeypatch.setitem(sys.modules, "functions_embedding_compatibility", SimpleNamespace(
+ embedding_settings_write_guard=lambda *args, **kwargs: nullcontext(),
+ ))
+ return SimpleNamespace(repository=repository, settings=settings, container=container, helpers=helpers)
+
+
+def enable(activation):
+ return activation.helpers["update_settings"]({"enable_content_screening": True})
+
+
+def test_first_settings_activation_creates_and_reuses_enabled_empty_baseline(activation):
+ assert enable(activation) is True
+ saved = activation.repository.get_policy("global", "global")
+ assert saved["policy"] == normalize_policy({**default_policy(), "enabled": True})
+ assert saved["actor_id"] == "system-content-screening"
+ assert activation.settings["enable_content_screening"] is True
+ assert enable(activation) is True
+ assert activation.repository.get_policy("global", "global") == saved
+
+
+@pytest.mark.parametrize("enabled", [False, True])
+def test_activation_never_replaces_or_activates_an_existing_policy(activation, enabled):
+ policy = settings_tests.activation_policy(ai_enabled=False)
+ policy["enabled"] = enabled
+ saved = activation.repository.save_policy("global", "global", policy, "administrator")
+ assert enable(activation) is True
+ assert activation.repository.get_policy("global", "global") == saved
+
+
+def test_prerequisite_failure_does_not_initialize_policy_or_enable_settings(activation):
+ with patch.object(service, "validate_screening_configuration", side_effect=ScreeningConfigurationError()):
+ assert enable(activation) is False
+ assert activation.repository.get_policy("global", "global") is None
+ activation.container.replace_item.assert_not_called()
+ assert activation.settings["enable_content_screening"] is False
+
+
+def test_policy_creation_failure_cannot_report_settings_success_or_leak_errors(activation):
+ with patch.object(activation.repository, "save_policy", side_effect=RuntimeError("private-provider-canary")):
+ assert enable(activation) is False
+ activation.container.replace_item.assert_not_called()
+ assert activation.settings["enable_content_screening"] is False
+ assert "private-provider-canary" not in str(activation.helpers["log_event"].call_args_list)
+
+
+def test_failed_settings_write_keeps_blank_policy_reusable_without_enabling_scanning(activation):
+ replace = activation.container.replace_item.side_effect
+ activation.container.replace_item.side_effect = RuntimeError("private-provider-canary")
+ assert enable(activation) is False
+ saved = activation.repository.get_policy("global", "global")
+ assert saved["policy"]["enabled"] is True and saved["policy"]["rules"] == []
+ assert activation.settings["enable_content_screening"] is False
+ activation.container.replace_item.side_effect = replace
+ assert enable(activation) is True
+ assert activation.repository.get_policy("global", "global") == saved
+
+
+@pytest.mark.parametrize("invalid_model", [False, True])
+def test_concurrent_policy_creation_is_preserved_and_revalidated(activation, invalid_model):
+ save_policy = activation.repository.save_policy
+ concurrent = settings_tests.activation_policy(ai_enabled=invalid_model)
+
+ def concurrent_create(*args, **kwargs):
+ save_policy("global", "global", concurrent, "another-administrator")
+ raise ScreeningConflictError()
+
+ model_validation = Mock(side_effect=ScreeningConfigurationError() if invalid_model else None)
+ with patch.object(activation.repository, "save_policy", side_effect=concurrent_create), patch.dict(sys.modules, {
+ "content_screening.model": SimpleNamespace(validate_model_bindings=model_validation),
+ }):
+ assert enable(activation) is not invalid_model
+ saved = activation.repository.get_policy("global", "global")
+ assert saved["policy"] == normalize_policy(concurrent)
+ assert saved["actor_id"] == "another-administrator"
+ assert activation.settings["enable_content_screening"] is not invalid_model
+ if invalid_model:
+ model_validation.assert_called_once()
+ activation.container.replace_item.assert_not_called()
+
+
+def test_missing_policy_during_runtime_is_not_a_silent_unscreened_fallback(activation):
+ activation.settings["enable_content_screening"] = True
+ with pytest.raises(ScreeningPolicyRequiredError):
+ service.document_requires_screening({"id": "new-document", "user_id": "owner", "version": 1})
+ service.validate_screening_configuration(
+ activation.settings, proposed_settings=True, allow_missing_policy=True,
+ )
+ assert activation.repository.get_policy("global", "global") is None
+
+
+@pytest.mark.parametrize("marker", [None, {}, {"state": "pending_review"}, {"state": "cleared"}])
+def test_existing_or_malformed_enrollment_never_uses_empty_policy_bypass(activation, marker):
+ assert enable(activation) is True
+ document = {"id": "document", "user_id": "owner", "version": 1, SCREENING_FIELD: marker}
+ assert service.document_requires_screening(document) is True
+ activation.settings["enable_content_screening"] = False
+ assert service.document_requires_screening(document) is True
+
+
+@pytest.mark.parametrize("enrolled", [False, True])
+def test_reprocessing_dispatch_keeps_old_enrollment_but_skips_empty_policy_for_unmarked_content(activation, enrolled):
+ assert enable(activation) is True
+ document = {"id": "document", "user_id": "owner", "version": 1}
+ if enrolled:
+ document[SCREENING_FIELD] = {"state": "pending_review"}
+ source = settings_tests.APP_DIR / "functions_documents.py"
+ tree = ast.parse(source.read_text(encoding="utf-8"))
+ function = next(node for node in tree.body if isinstance(node, ast.FunctionDef)
+ and node.name == "process_document_reprocess_extraction_background")
+ ordinary = Mock(return_value="ordinary")
+ screened = Mock(return_value="screened")
+ namespace = {
+ "get_document_metadata": lambda *args: copy.deepcopy(document),
+ "get_settings": lambda: activation.settings,
+ "document_requires_screening": service.document_requires_screening,
+ "subject_from_document": subject_from_document,
+ "normalize_document_intelligence_manual_extraction_mode": lambda value: value,
+ "reprocess_document": screened,
+ "_process_document_reprocess_extraction_background_impl": ordinary,
+ }
+ exec(compile(ast.Module(body=[function], type_ignores=[]), str(source), "exec"), namespace)
+ result = namespace[function.name]("document", "owner", "enhanced")
+ assert result == ("screened" if enrolled else "ordinary")
+ assert ordinary.call_count == int(not enrolled) and screened.call_count == int(enrolled)
+
+
+def test_v2_settings_handler_uses_shared_empty_policy_persistence(activation):
+ handler = admin_settings_tests.patch_handler(activation.settings)
+ handler["update_settings"] = activation.helpers["update_settings"]
+ handler["validate_content_screening_settings"] = activation.helpers["validate_content_screening_settings"]
+ handler["get_settings"] = lambda **kwargs: copy.deepcopy(activation.settings)
+ payload, status = admin_settings_tests.invoke(handler, {"enable_content_screening": True})
+ assert status == 200 and payload["settings"]["enable_content_screening"] is True
+ assert activation.settings["enable_content_screening"] is True
+ assert activation.repository.get_policy("global", "global")["policy"]["rules"] == []
+
+
+def test_classic_configuration_route_initializes_policy_and_roundtrips_empty_saves(activation):
+ case = settings_tests.ScreeningApiTests()
+ case.setUp()
+ try:
+ case.login("admin", ["Admin"])
+ with patch.object(case.route, "_repository", return_value=activation.repository), \
+ patch.object(case.route, "cosmos_settings_container", activation.container), \
+ patch.object(case.route, "get_settings", side_effect=lambda: copy.deepcopy(activation.settings)), \
+ patch.object(case.route, "update_settings", activation.helpers["update_settings"]), \
+ patch.object(sys.modules["content_screening.storage"], "ScreeningStorage", ScreeningStorage):
+ response = case.client.put("/api/content-screening/configuration", json={"enabled": True})
+ assert response.status_code == 200 and response.json["enabled"] is True
+ path = "/api/content-screening/policies/global/global"
+ loaded = case.client.get(path).json
+ assert loaded["etag"] is not None
+ assert loaded["policy"]["enabled"] is True and loaded["policy"]["rules"] == []
+ saved = case.client.put(path, json={"policy": loaded["policy"], "etag": loaded["etag"]})
+ assert saved.status_code == 200 and saved.json["policy"]["enabled"] is True
+ assert activation.settings["enable_content_screening"] is True
+ finally:
+ case.doCleanups()
diff --git a/functional_tests/test_content_screening_engine.py b/functional_tests/test_content_screening_engine.py
index 2a86cb90f..80bc6ca49 100644
--- a/functional_tests/test_content_screening_engine.py
+++ b/functional_tests/test_content_screening_engine.py
@@ -1,8 +1,9 @@
# test_content_screening_engine.py
"""
Functional tests for fail-closed content-screening orchestration.
-Version: 0.261.108
+Version: 0.261.114
Implemented in: 0.261.106
+Enabled-empty policy coverage implemented in: 0.261.114
Verify independent mandatory checks, injected model adapters, exact required unit
coverage, grounded Unicode evidence, and rejection of silent clean verdicts (#1476).
@@ -74,6 +75,16 @@ def model_finding(units, check, **_kwargs):
class ContentScreeningEngineTests(unittest.TestCase):
+ def test_empty_enabled_configuration_never_produces_a_clean_inspection(self):
+ raw = {**default_policy(), "enabled": True}
+ for policy in (raw, compose_policy(raw)):
+ with self.subTest(effective="ai_checks" in policy):
+ with patch.dict(sys.modules, {"content_screening.model": None}):
+ result = inspect_content(SUBJECT, [ContentUnit("one", "Ordinary content")], policy)
+ self.assertEqual(result.status, "error")
+ self.assertEqual(result.error_code, "screening_policy_empty")
+ self.assertEqual(result.detectors, [])
+
def _model_harness(self):
harness = model_fixtures.ModelScreeningTests()
self.addCleanup(harness.doCleanups)
diff --git a/functional_tests/test_content_screening_pipeline.py b/functional_tests/test_content_screening_pipeline.py
index 6421afc41..e981745a4 100644
--- a/functional_tests/test_content_screening_pipeline.py
+++ b/functional_tests/test_content_screening_pipeline.py
@@ -1,8 +1,9 @@
# test_content_screening_pipeline.py
"""
Functional integration tests for workspace admission and reviewed publication.
-Version: 0.261.113
+Version: 0.261.114
Implemented in: 0.261.106
+Enabled-empty upload admission implemented in: 0.261.114
Runs the real durable job, scanner, repository, private storage, TXT extraction,
and publication services against fake Azure boundaries. No live data is used.
@@ -23,6 +24,7 @@
import pytest
from azure.core import MatchConditions
from azure.core.exceptions import ResourceExistsError, ResourceModifiedError, ResourceNotFoundError
+from flask import Flask, session
APP_ROOT = Path(__file__).resolve().parents[1] / "application" / "single_app"
@@ -34,6 +36,7 @@
SCREENING_FIELD,
ContentUnit,
DocumentHeldError,
+ ScreeningChecksRequiredError,
ScreeningConflictError,
ScreeningError,
Subject,
@@ -41,7 +44,7 @@
hash_payload,
metadata_fingerprint,
)
-from content_screening.extraction import current_extraction
+from content_screening.extraction import current_extraction, is_publication
from content_screening.policies import default_policy
from content_screening.repository import ScreeningRepository
from content_screening.storage import ScreeningStorage
@@ -62,9 +65,9 @@ def _record(self):
except FakeSdkError as error:
raise ResourceNotFoundError("Missing test blob") from error
- def upload_blob(self, *args, **kwargs):
+ def upload_blob(self, payload, **kwargs):
try:
- return super().upload_blob(*args, **kwargs)
+ return super().upload_blob(payload.read() if hasattr(payload, "read") else payload, **kwargs)
except FakeSdkError as error:
if error.status_code == 409:
raise ResourceExistsError("Existing test blob") from error
@@ -145,8 +148,16 @@ def pipeline(monkeypatch):
reviews.ensure_review = lambda scan, actor_id, **kwargs: review_requests.append(scan["id"])
monkeypatch.setitem(sys.modules, "content_screening.reviews", reviews)
monkeypatch.setitem(sys.modules, "functions_activity_logging", types.SimpleNamespace(
- log_document_creation_transaction=lambda **kwargs: {"id": kwargs["idempotency_key"]},
- log_token_usage=lambda **kwargs: {"id": kwargs["idempotency_key"]},
+ log_document_creation_transaction=lambda **kwargs: {"id": kwargs.get("idempotency_key", "upload-log")},
+ log_token_usage=lambda **kwargs: {"id": kwargs.get("idempotency_key", "token-log")},
+ ))
+ monkeypatch.setitem(sys.modules, "functions_notifications", types.SimpleNamespace(
+ create_notification=lambda **kwargs: None,
+ create_group_notification=lambda **kwargs: None,
+ create_public_workspace_notification=lambda **kwargs: None,
+ ))
+ monkeypatch.setitem(sys.modules, "functions_group", types.SimpleNamespace(
+ find_group_by_id=lambda group_id: {"id": group_id, "name": "Synthetic group"},
))
def get_metadata(document_id, user_id, group_id=None, public_workspace_id=None):
@@ -169,14 +180,19 @@ def update_document(**updates):
capture.heartbeat()
def generate_embedding(text):
- for item in containers["personal"].documents.values():
+ documents = [item for container in containers.values() for item in container.documents.values()]
+ for item in documents:
marker = item.get(SCREENING_FIELD) or {}
if marker.get("state") == "publishing":
scan = repository.get_scan(marker["scan_id"])
assert scan["coverage_complete"] is True
break
else:
- raise AssertionError("Embedding ran before complete screening and publication.")
+ if not documents or any(
+ SCREENING_FIELD in item or service.document_requires_screening(item, settings)
+ for item in documents
+ ):
+ raise AssertionError("Embedding ran before complete screening and publication.")
return EmbeddingVector([0.25, 0.75], embedding_profile), {
"total_tokens": len(text), "model_deployment_name": "test-embedding",
}
@@ -185,6 +201,16 @@ def search_write_slot(container, *, embedding_profile_id):
write_profiles.append(embedding_profile_id)
return nullcontext()
+ def upsert_document(container, document, **kwargs):
+ current = container.read_item(item=document["id"], partition_key=document["id"])
+ return container.replace_item(
+ item=document["id"], body=document, etag=current["_etag"],
+ match_condition=MatchConditions.IfNotModified,
+ )
+
+ for container in containers.values():
+ container.upsert_item = lambda body, target=container: upsert_document(target, body)
+
def delete_chunks(document_id, **kwargs):
search.documents = {key: value for key, value in search.documents.items() if value["document_id"] != document_id}
@@ -214,6 +240,30 @@ def delete_chunks(document_id, **kwargs):
"ScreeningError": ScreeningError, "get_settings": lambda: settings,
"get_chunk_size_config": lambda value=None: {"txt": {"value": 3}},
"get_document_metadata": get_metadata, "update_document": update_document,
+ "document_requires_screening": service.document_requires_screening,
+ "process_screened_upload": service.process_screened_upload,
+ "SCREENING_FIELD": SCREENING_FIELD, "DocumentHeldError": DocumentHeldError,
+ "is_publication": is_publication, "subject_from_document": service.subject_from_document,
+ "cosmos_user_documents_container": containers["personal"],
+ "cosmos_group_documents_container": containers["group"],
+ "cosmos_public_documents_container": containers["public"],
+ "CLIENTS": {key: search for key in ("search_client_user", "search_client_group", "search_client_public")},
+ "get_embedding_safe_chunk_characters": helpers.get_embedding_safe_chunk_characters,
+ "generate_embedding": generate_embedding,
+ "ensure_list": lambda value: value if isinstance(value, list) else [value] if value else [],
+ "debug_print": lambda *args: None,
+ "add_file_task_to_file_processing_log": lambda **kwargs: None,
+ "sync_chat_upload_workspace_attachment_status": lambda *args: None,
+ "_get_documents_container": lambda group_id=None, public_workspace_id=None: containers[
+ "public" if public_workspace_id else "group" if group_id else "personal"
+ ],
+ "_get_document_family_items_from_document": lambda document, **kwargs: [document],
+ "_get_blob_container_name": helpers._get_blob_container_name,
+ "_get_blob_service_client": helpers._get_blob_service_client,
+ "_ensure_blob_container_ready": helpers._ensure_blob_container_ready,
+ "build_current_blob_path": lambda filename, **kwargs: f"current/{filename}",
+ "CURRENT_ALIAS_BLOB_PATH_MODE": "current_alias",
+ "_upsert_document_and_sync_access_index": upsert_document,
"allowed_file": lambda *args: True,
"log_event": lambda *args, **kwargs: None,
"TABULAR_EXTENSIONS": {"csv"}, "IMAGE_EXTENSIONS": {"png"},
@@ -226,6 +276,8 @@ def delete_chunks(document_id, **kwargs):
}
functions = {
"save_chunks", "upload_to_blob", "process_txt", "_process_document_upload_background_impl",
+ "process_document_upload_background",
+ "_require_screening_chunk_write", "_run_final_metadata_extraction", "_resolve_processing_complete_status",
"_search_indexing_results_succeeded", "_execute_document_search_write",
}
tree = ast.parse((APP_ROOT / "functions_documents.py").read_text(encoding="utf-8"))
@@ -233,6 +285,7 @@ def delete_chunks(document_id, **kwargs):
assert len(nodes) == len(functions)
exec(compile(ast.Module(body=nodes, type_ignores=[]), str(APP_ROOT / "functions_documents.py"), "exec"), namespace)
helpers._process_document_upload_background_impl = namespace["_process_document_upload_background_impl"]
+ helpers.process_document_upload_background = namespace["process_document_upload_background"]
helpers._execute_document_search_write = namespace["_execute_document_search_write"]
monkeypatch.setitem(sys.modules, "functions_documents", helpers)
config = types.ModuleType("config")
@@ -291,6 +344,133 @@ def drain_candidate(pipeline, candidate):
return pipeline.repository.get_scan(candidate["id"])
+def save_empty_baseline(pipeline, *, disabled_rule=False):
+ baseline = pipeline.repository.get_policy("global", "global")
+ policy = {**default_policy(), "enabled": True}
+ if disabled_rule:
+ policy["rules"] = [{**baseline["policy"]["rules"][0], "enabled": False}]
+ return pipeline.repository.save_policy(
+ "global", "global", policy, "administrator", etag=baseline["_etag"],
+ )
+
+
+@pytest.mark.parametrize("scope_type", ["personal", "group", "public"])
+@pytest.mark.parametrize("disabled_rule", [False, True])
+def test_no_effective_checks_use_normal_uploads_without_markers_or_screening_artifacts(
+ pipeline, tmp_path, scope_type, disabled_rule,
+):
+ save_empty_baseline(pipeline, disabled_rule=disabled_rule)
+ field = {"personal": "user_id", "group": "group_id", "public": "public_workspace_id"}[scope_type]
+ document = {
+ "id": "document", field: "owner", "version": 1, "file_name": "document.txt",
+ "is_current_version": True, "num_chunks": 0, "number_of_pages": 0,
+ }
+ assert service.initial_document_marker(document) is None
+ pipeline.repository.document_container(scope_type).create_item(document)
+ source = tmp_path / "document.txt"
+ text = "Ordinary source including PRIVATE_CANARY remains unscreened."
+ source.write_text(text, encoding="utf-8")
+ arguments = {
+ "document_id": "document", "user_id": "owner",
+ "temp_file_path": str(source), "original_filename": source.name,
+ "group_id": "owner" if scope_type == "group" else None,
+ "public_workspace_id": "owner" if scope_type == "public" else None,
+ }
+ assert service.prepare_document_upload(**arguments) is None
+ assert not pipeline.blobs.containers
+ pipeline.helpers.process_document_upload_background(**arguments)
+ persisted = pipeline.repository.read_document(Subject(scope_type, "owner", "document", "1"))
+ assert SCREENING_FIELD not in persisted and document_is_available(persisted)
+ assert persisted["percentage_complete"] == 100
+ assert " ".join(item["chunk_text"] for item in pipeline.search.documents.values()) == text
+ for kind in ("scan", "job", "work_item", "finding"):
+ assert pipeline.repository.query(kind)["items"] == []
+ assert not pipeline.reviews
+
+
+@pytest.mark.parametrize("scope_type", ["personal", "group", "public"])
+def test_empty_baseline_enforces_workspace_checks_on_new_uploads(pipeline, scope_type):
+ baseline = pipeline.repository.get_policy("global", "global")
+ workspace = copy.deepcopy(baseline["policy"])
+ save_empty_baseline(pipeline)
+ pipeline.repository.save_policy(scope_type, "owner", workspace, "owner")
+ field = {"personal": "user_id", "group": "group_id", "public": "public_workspace_id"}[scope_type]
+ document = {"id": "document", field: "owner", "version": 1}
+ marker = service.initial_document_marker(document)
+ assert marker["state"] == "pending_scan"
+ effective = service.get_effective_policy(Subject(scope_type, "owner", "document", "1"))
+ assert {rule["origin"] for rule in effective["rules"]} == {"workspace"}
+ result = engine.inspect_content(
+ Subject(scope_type, "owner", "document", "1"),
+ [ContentUnit("unit", "PRIVATE_CANARY")], effective,
+ )
+ assert result.status == "findings" and result.findings
+
+
+def test_adding_checks_after_empty_activation_enrolls_subsequent_uploads(pipeline):
+ configured = copy.deepcopy(pipeline.repository.get_policy("global", "global")["policy"])
+ blank = save_empty_baseline(pipeline)
+ document = {"id": "earlier", "user_id": "owner", "version": 1}
+ assert service.initial_document_marker(document) is None
+ pipeline.repository.document_container("personal").create_item(document)
+ pipeline.repository.save_policy("global", "global", configured, "administrator", etag=blank["_etag"])
+ assert service.initial_document_marker({**document, "id": "later"})["state"] == "pending_scan"
+ earlier = pipeline.repository.read_document(Subject("personal", "owner", "earlier", "1"))
+ assert SCREENING_FIELD not in earlier
+
+
+def test_clearing_policy_never_bypasses_an_existing_upload_hold_or_release(pipeline, tmp_path):
+ seed(pipeline)
+ result = upload(pipeline, tmp_path, "PRIVATE_CANARY")
+ assert result["state"] == "pending_review"
+ save_empty_baseline(pipeline)
+ subject = Subject("personal", "owner", "document", "1")
+ document = pipeline.repository.read_document(subject)
+ assert service.document_requires_screening(document) is True
+ with pytest.raises(ScreeningChecksRequiredError):
+ service.prepare_document_upload("document", "owner", "unused-path", "document.txt")
+ with pytest.raises(ScreeningChecksRequiredError):
+ service.publish_scan(result["id"], "owner")
+ persisted = pipeline.repository.read_document(subject)
+ assert persisted[SCREENING_FIELD]["scan_id"] == result["id"]
+ assert not document_is_available(persisted) and not pipeline.search.documents
+
+
+def test_manual_workspace_scan_requires_checks_without_creating_a_job_or_hold(pipeline):
+ save_empty_baseline(pipeline)
+ with pytest.raises(ScreeningChecksRequiredError):
+ jobs.create_scan_job("owner", {"scope_type": "personal", "scope_id": "owner"}, repository=pipeline.repository)
+ assert pipeline.repository.query("job")["items"] == []
+ assert pipeline.repository.query("scan")["items"] == []
+
+
+def test_all_workspace_scan_skips_empty_policies_without_holding_documents(pipeline):
+ save_empty_baseline(pipeline)
+ for scope, field in (("personal", "user_id"), ("group", "group_id"), ("public", "public_workspace_id")):
+ pipeline.repository.document_container(scope).create_item({
+ "id": f"document-{scope}", field: "owner", "version": 1, "file_name": "document.txt",
+ })
+ app = Flask(__name__)
+ app.secret_key = "screening-functional-test-only"
+ with app.test_request_context():
+ session["user"] = {"oid": "administrator", "roles": ["Admin"]}
+ job = jobs.create_scan_job(
+ "administrator", {"all_workspaces": True}, is_admin=True, repository=pipeline.repository,
+ )
+ for _ in range(3):
+ result = jobs.run_scan_job(job["id"], repository=pipeline.repository)
+ if result["enumeration"]["complete"]:
+ break
+ assert result["counts"]["skipped"] == 3
+ assert result["counts"]["completed"] == 0
+ assert result["counts"]["retry"] == 0
+ assert all(item["error_code"] == "screening_policy_empty" for item in pipeline.repository.query("work_item")["items"])
+ assert pipeline.repository.query("scan")["items"] == []
+ for scope in ("personal", "group", "public"):
+ document = pipeline.repository.read_document(Subject(scope, "owner", f"document-{scope}", "1"))
+ assert SCREENING_FIELD not in document and document_is_available(document)
+
+
def test_real_txt_intake_scans_before_embedding_and_releases_exact_source(pipeline, tmp_path):
seed(pipeline)
text = "First line of evidence\nSecond line with a complete tail"
diff --git a/functional_tests/test_content_screening_policy.py b/functional_tests/test_content_screening_policy.py
index 5326b2868..f5af3d5a7 100644
--- a/functional_tests/test_content_screening_policy.py
+++ b/functional_tests/test_content_screening_policy.py
@@ -1,8 +1,9 @@
# test_content_screening_policy.py
"""
Functional tests for mandatory content-screening policy composition.
-Version: 0.261.106
+Version: 0.261.114
Implemented in: 0.261.106
+Enabled-empty policies implemented in: 0.261.114
Validate explicit opt-in, strict configuration bounds, approved model selection,
stable policy fingerprints, and non-sensitive baseline summaries for issue #1476.
@@ -67,14 +68,26 @@ def test_defaults_require_explicit_admin_selection(self):
first["limits"]["max_units"] = 1
self.assertEqual(default_policy()["limits"], DEFAULT_LIMITS)
first["enabled"] = True
- with self.assertRaises(ScreeningValidationError):
- normalize_policy(first)
+ self.assertTrue(normalize_policy(first)["enabled"])
+ self.assertFalse(policy_is_active(first))
+ self.assertEqual(normalize_effective_policy(compose_policy(first)), compose_policy(first))
- def test_disabling_all_checks_cannot_enable_an_empty_gate(self):
+ def test_disabling_all_checks_retains_an_enabled_policy_without_active_screening(self):
policy = enabled_policy()
policy["rules"][0]["enabled"] = False
- with self.assertRaises(ScreeningValidationError):
- normalize_policy(policy)
+ self.assertTrue(normalize_policy(policy)["enabled"])
+ self.assertFalse(policy_is_active(policy))
+ self.assertFalse(policy_is_active(compose_policy(policy)))
+
+ def test_empty_enabled_baseline_can_inherit_workspace_only_checks(self):
+ baseline = {**default_policy(), "enabled": True}
+ workspace = enabled_policy()
+ effective = compose_policy(baseline, workspace)
+ self.assertEqual(normalize_effective_policy(effective), effective)
+ self.assertTrue(policy_is_active(effective))
+ self.assertEqual([rule["origin"] for rule in effective["rules"]], ["workspace"])
+ baseline["enabled"] = False
+ self.assertFalse(policy_is_active(compose_policy(baseline, workspace)))
def test_malformed_objects_never_become_disabled_defaults(self):
for value in (None, False, "", [], {"rules": None}, {"ai": None}, {"limits": None}, {"unknown": True}):
diff --git a/functional_tests/test_content_screening_settings_api.py b/functional_tests/test_content_screening_settings_api.py
index 9183c9f0b..8886f5bfc 100644
--- a/functional_tests/test_content_screening_settings_api.py
+++ b/functional_tests/test_content_screening_settings_api.py
@@ -1,9 +1,10 @@
# test_content_screening_settings_api.py
"""
Functional tests for content screening settings and authenticated API contracts.
-Version: 0.261.113
+Version: 0.261.114
Implemented in: 0.261.106
Embedding settings concurrency and sanitization merge coverage: 0.261.113
+Enabled-empty policies implemented in: 0.261.114
Uses the existing unittest/Flask runners with isolated application service mocks.
No Azure configuration, credentials, model requests or storage accounts are used.
@@ -132,6 +133,7 @@ def setUp(self):
self.addCleanup(self.stack.close)
self.original_validation = service.validate_screening_configuration
self.validate = self.stack.enter_context(patch.object(service, "validate_screening_configuration"))
+ self.initialize = self.stack.enter_context(patch.object(service, "initialize_screening_policy"))
self.embedding_guard = Mock(side_effect=lambda *args, **kwargs: nullcontext())
self.stack.enter_context(patch.dict(sys.modules, {
"functions_embedding_compatibility": module(
@@ -174,6 +176,7 @@ def test_partial_schema_updates_cannot_disable_required_citations(self):
def test_enable_validates_policy_and_working_storage_before_any_write(self):
self.assertFalse(self.functions["update_settings"]({"enable_content_screening": True}))
self.validate.assert_not_called()
+ self.initialize.assert_not_called()
self.container.replace_item.assert_not_called()
self.current["enable_enhanced_citations"] = True
self.validate.side_effect = ScreeningConfigurationError()
@@ -181,6 +184,7 @@ def test_enable_validates_policy_and_working_storage_before_any_write(self):
self.assertIs(self.validate.call_args.kwargs["check_storage"], True)
self.container.replace_item.assert_not_called()
self.assertIs(self.current["enable_content_screening"], False)
+ self.initialize.assert_not_called()
def test_generic_settings_activation_cannot_bypass_current_model_binding_validation(self):
self.current["enable_enhanced_citations"] = True
@@ -227,7 +231,7 @@ def test_retry_revalidates_screening_dependencies_against_the_new_revision(self)
self.container.replace_item.side_effect = CosmosAccessConditionFailedError()
self.assertFalse(self.functions["update_settings"]({"enable_content_screening": True}))
self.assertEqual(self.container.read_item.call_count, 2)
- self.validate.assert_called_once()
+ self.assertEqual(self.validate.call_count, 2)
self.embedding_guard.assert_called_once()
self.container.replace_item.assert_called_once()
self.container.upsert_item.assert_not_called()
diff --git a/functional_tests/test_v2_content_screening_logic.mjs b/functional_tests/test_v2_content_screening_logic.mjs
index d50a98ce0..d59dbd5df 100644
--- a/functional_tests/test_v2_content_screening_logic.mjs
+++ b/functional_tests/test_v2_content_screening_logic.mjs
@@ -1,6 +1,7 @@
// test_v2_content_screening_logic.mjs
-// Version: 0.261.108
+// Version: 0.261.114
// Implemented in: 0.261.106
+// Empty-policy activation implemented in: 0.261.114
// Exercises real V2 screening availability and Unicode edit boundaries without a browser.
import assert from 'node:assert/strict';
@@ -22,7 +23,7 @@ const {
} = await import('../application/v2_ui/src/lib/contentScreeningReview.ts');
const {
addScreeningStarterPack, approvedScreeningChoices, editableScreeningPolicy,
- newCustomScreeningRule, screeningCatalogChoices, screeningModelCatalog,
+ isScreeningPolicyInitialization, newCustomScreeningRule, screeningCatalogChoices, screeningModelCatalog,
screeningModelIndex, screeningPolicySummary, screeningPolicyTemplates, validateScreeningPolicy,
} = await import('../application/v2_ui/src/lib/contentScreeningPolicy.ts');
const screeningApi = await import('../application/v2_ui/src/lib/contentScreeningApi.ts');
@@ -276,6 +277,40 @@ check('workspace summaries never hide inherited AI when local additions are disa
assert.equal(screeningPolicySummary(draft, false, null).label, 'Required baseline unavailable');
});
+check('enabled empty and disabled-check policies can be saved without claiming screening coverage', () => {
+ const empty = { ...policy(), rules: [], ai: { ...policy().ai, enabled: false } };
+ assert.deepEqual(validateScreeningPolicy(empty, []), []);
+ const summary = screeningPolicySummary(empty, true);
+ assert.equal(summary.label, 'No active checks configured');
+ assert.match(summary.detail, /normal processing.*workspace adds checks/);
+ assert.match(summary.detail, /Existing holds are unchanged/);
+ const disabledChecks = { ...empty, rules: [{ ...policy().rules[0], enabled: false }] };
+ assert.deepEqual(validateScreeningPolicy(disabledChecks, []), []);
+ assert.equal(screeningPolicySummary(disabledChecks, true).label, summary.label);
+ const workspace = screeningPolicySummary(policy(), false, { enabled: true, rule_count: 0, ai_check_count: 0 });
+ assert.equal(workspace.label, '1 deterministic check | 1 AI check');
+});
+
+check('activation refresh only rebases the same blank baseline, not concurrent configuration edits', () => {
+ const before = {
+ ...policy(), enabled: false, rules: [], fingerprint: 'before',
+ ai: { ...policy().ai, enabled: false, model_selection: { endpoint_id: '', model_id: '' } },
+ allowed_models: [],
+ };
+ const initialized = { ...structuredClone(before), enabled: true, fingerprint: 'initialized' };
+ assert.equal(isScreeningPolicyInitialization(before, initialized), true);
+ assert.equal(isScreeningPolicyInitialization(before, { ...initialized, rules: policy().rules }), false);
+ assert.equal(isScreeningPolicyInitialization(before, {
+ ...initialized, ai: { ...initialized.ai, instructions: 'Another administrator changed the criteria.' },
+ }), false);
+ assert.equal(isScreeningPolicyInitialization(before, {
+ ...initialized, allowed_models: [{ endpoint_id: 'approved', model_id: 'scanner' }],
+ }), false);
+ assert.equal(isScreeningPolicyInitialization(before, {
+ ...initialized, limits: { ...initialized.limits, max_units: 42 },
+ }), false);
+});
+
check('configured scanner identity includes endpoint and excludes raw endpoint display', () => {
assert.equal(screeningModelIndex(choices, { endpoint_id: 'connection-2', model_id: 'model' }), '1');
assert.equal(screeningModelIndex(choices, { endpoint_id: 'missing', model_id: 'model' }), 'unavailable');
diff --git a/ui_tests/fixtures/content_screening_classic.py b/ui_tests/fixtures/content_screening_classic.py
index bacf35594..801d5223b 100644
--- a/ui_tests/fixtures/content_screening_classic.py
+++ b/ui_tests/fixtures/content_screening_classic.py
@@ -1,8 +1,9 @@
# content_screening_classic.py
"""
Closed, synthetic API boundary for classic Content Screening browser tests.
-Version: 0.261.108
+Version: 0.261.114
Implemented in: 0.261.106
+Empty-policy activation coverage: 0.261.114
The real Jinja partials and local browser assets run without application startup,
real documents, authentication tokens, storage, or inference requests.
@@ -111,6 +112,7 @@ def __init__(self, page):
self.unexpected = []
self.dialogs = []
self.policy_writes = []
+ self.policy_samples = []
self.decisions = []
self.previews = []
self.remediations = []
@@ -119,6 +121,7 @@ def __init__(self, page):
self.configuration_writes = []
self.generic_approval_writes = []
self.fail_policy = 0
+ self.fail_configuration = 0
self.fail_review = 0
self.fail_decision = 0
self.fail_evidence = 0
@@ -294,9 +297,15 @@ def _route(self, route):
elif path == "/api/content-screening/configuration":
if request.method == "PUT":
self.configuration_writes.append(request.post_data_json)
+ if self.fail_configuration:
+ self._fail(route, self.fail_configuration)
+ return
if not self.config["enhanced_citations_enabled"] and request.post_data_json["enabled"]:
self._fail(route, 503)
return
+ if request.post_data_json["enabled"] and self.policy_etag is None:
+ self.global_policy = normalize_policy({**self.global_policy, "enabled": True})
+ self.policy_etag = '"policy-initialized"'
self.config["enabled"] = request.post_data_json["enabled"]
route.fulfill(json=self.config)
return
@@ -312,6 +321,7 @@ def _route(self, route):
elif path.startswith("/api/content-screening/policies/"):
global_policy = "/global/global" in path
if path.endswith("/test"):
+ self.policy_samples.append(request.post_data_json)
route.fulfill(json={"status": "findings", "complete": True, "finding_count": 1, "findings": self.findings})
return
if request.method == "PUT":
@@ -327,6 +337,7 @@ def _route(self, route):
self.global_policy = policy
else:
self.workspace_policy = policy
+ self.policy_etag = f'"policy-etag-{len(self.policy_writes) + 1}"'
route.fulfill(json={
"scope_type": "global" if global_policy else "personal",
"scope_id": "global" if global_policy else USER_ID,
diff --git a/ui_tests/test_content_screening_classic.py b/ui_tests/test_content_screening_classic.py
index 31b85f55c..9b4d368f1 100644
--- a/ui_tests/test_content_screening_classic.py
+++ b/ui_tests/test_content_screening_classic.py
@@ -1,8 +1,9 @@
# test_content_screening_classic.py
"""
Classic Content Screening policy, hold, review, and remediation workflows.
-Version: 0.261.108
+Version: 0.261.114
Implemented in: 0.261.106
+Empty-policy scan feedback implemented in: 0.261.114
Uses the existing local/Azure Playwright connection fixture and a closed,
synthetic API boundary. No application accounts, real documents, secrets,
@@ -496,11 +497,26 @@ def test_classic_single_and_workspace_scan_targets(classic_screening, single_doc
assert classic_screening.scan_starts == [target]
-def test_classic_disable_new_scans_preserves_review_holds(classic_screening):
+@pytest.mark.parametrize("dirty_policy", [False, True])
+def test_classic_disable_new_scans_preserves_review_holds(classic_screening, dirty_policy):
classic_screening.open_admin()
page = classic_screening.page
- page.locator("#enable_content_screening").uncheck()
+ if dirty_policy:
+ page.get_by_label("Rule name", exact=True).fill("Keep my policy edits")
+ with page.expect_response(
+ lambda response: response.request.method == "PUT"
+ and response.url.endswith("/api/content-screening/configuration")
+ ) as configuration, page.expect_response(
+ lambda response: response.request.method == "GET"
+ and response.url.endswith("/api/content-screening/policies/global/global")
+ ) as refreshed_policy:
+ page.locator("#enable_content_screening").uncheck()
+ assert configuration.value.status == 200 and configuration.value.json()["enabled"] is False
+ assert refreshed_policy.value.status == 200
expect(page.locator("#screening-admin-policy")).to_contain_text("New scans are disabled")
+ if dirty_policy:
+ expect(page.get_by_label("Rule name", exact=True)).to_have_value("Keep my policy edits")
+ assert not classic_screening.policy_writes
assert classic_screening.configuration_writes == [{"enabled": False}]
assert classic_screening.review["state"] == "pending_review"
assert classic_screening.decisions == []
@@ -525,6 +541,26 @@ def test_classic_global_scan_requires_server_capability(classic_screening):
assert classic_screening.scan_starts == []
+def test_classic_empty_policy_scan_error_explains_checks_without_blocking_settings(classic_screening):
+ ui = classic_screening
+ ui.open_workspace()
+
+ def reject_empty_scan(route):
+ if route.request.method == "POST":
+ route.fulfill(status=400, json={"error": "No active checks.", "code": "screening_policy_empty"})
+ else:
+ route.fallback()
+
+ ui.page.route("**/api/content-screening/scans", reject_empty_scan)
+ ui.page.locator("[data-screening-scan-workspace]").click()
+ confirm_action(ui.page)
+ message = ui.page.locator("[data-screening-workspace-message]")
+ expect(message).to_contain_text("No active checks are configured for this workspace.")
+ expect(message).to_contain_text("An empty policy can stay enabled")
+ expect(message).to_contain_text("existing holds are unchanged")
+ assert not ui.scan_starts and not ui.configuration_writes
+
+
def test_classic_admin_scan_capability_does_not_grant_private_review(classic_screening):
classic_screening.can_review = False
classic_screening.open_workspace()
diff --git a/ui_tests/test_content_screening_policy_parity.py b/ui_tests/test_content_screening_policy_parity.py
index 16ffd664d..8932fd150 100644
--- a/ui_tests/test_content_screening_policy_parity.py
+++ b/ui_tests/test_content_screening_policy_parity.py
@@ -1,8 +1,9 @@
# test_content_screening_policy_parity.py
"""
Classic and V2 screening policy editor parity, using their real browser assets.
-Version: 0.261.108
+Version: 0.261.114
Implemented in: 0.261.108
+Empty-policy activation coverage: 0.261.114
Validate custom rules, shared packs, disabled AI settings, independent model
permissions, and mandatory baseline summaries for #1476. The existing closed
@@ -21,6 +22,7 @@
from ui_tests.test_v2_content_screening import (
STARTER_PACKS,
STARTER_RULE_TEMPLATES,
+ default_policy,
open_screening_admin,
screening_ui, # noqa: F401
)
@@ -102,6 +104,28 @@ def save(self):
self.editor.get_by_role("button", name="Save screening policy", exact=True).click()
expect(self.editor.get_by_text("Screening policy saved.", exact=False)).to_be_visible()
+ @property
+ def screening_enabled(self):
+ return self.app.config["enabled"] if self.classic else self.app.scan_enabled
+
+ def configure_screening(self, enabled):
+ if self.classic:
+ self.app.page.locator("#enable_content_screening").set_checked(enabled)
+ else:
+ set_toggle(self.app.page, "Screen workspace content before publication", enabled)
+ self.app.page.get_by_role("button", name="Save changes", exact=True).click()
+ expect(self.app.page.get_by_text("Saved 1 setting.", exact=True)).to_be_visible()
+ expect(self.editor.get_by_role("button", name="Reload saved policy", exact=True)).to_be_enabled()
+
+ def prepare_first_activation(self):
+ self.policy.clear()
+ self.policy.update(default_policy())
+ self.app.policy_etag = None
+ if self.classic:
+ self.app.config["enabled"] = False
+ else:
+ self.app.scan_enabled = False
+
@pytest.fixture(params=["classic", "v2"])
def policy_ui(request):
@@ -118,6 +142,153 @@ def set_toggle(editor, label, checked):
return toggle
+def test_first_activation_saves_an_enabled_empty_policy_and_allows_later_rules(policy_ui):
+ ui = policy_ui
+ ui.prepare_first_activation()
+ ui.open()
+ ui.configure_screening(True)
+ expect(ui.editor.get_by_label("Baseline policy enabled", exact=False)).to_be_checked()
+ expect(ui.summary).to_contain_text("No active checks configured")
+ expect(ui.summary).to_contain_text("New uploads use normal processing")
+ assert ui.screening_enabled is True
+ assert ui.policy["enabled"] is True
+ assert ui.policy["rules"] == [] and ui.policy["ai"]["enabled"] is False
+ initialized_etag = ui.app.policy_etag
+ assert initialized_etag is not None
+ assert not ui.app.policy_writes
+ ui.save()
+ assert ui.app.policy_writes[-1]["etag"] == initialized_etag
+ ui.open()
+ expect(ui.summary).to_contain_text("No active checks configured")
+ assert ui.screening_enabled is True
+ ui.editor.get_by_label("Starter rule pack", exact=True).select_option(label="prompt manipulation v1")
+ ui.editor.get_by_role("button", name="Add starter pack", exact=True).click()
+ ui.save()
+ assert {rule["id"] for rule in ui.policy["rules"]} == {"instruction-override", "source-ranking"}
+ assert ui.policy["ai"]["enabled"] is False and ui.screening_enabled is True
+
+
+def test_first_activation_preserves_policy_draft_and_updates_its_creation_revision(policy_ui):
+ ui = policy_ui
+ ui.prepare_first_activation()
+ ui.open()
+ ui.editor.get_by_role("button", name="Add literal rule", exact=True).click()
+ ui.rules.get_by_label("Rule name", exact=True).fill("Unsaved restriction")
+ ui.rules.get_by_label("Literal values or phrases", exact=True).fill("PRIVATE_DRAFT_VALUE")
+ ui.configure_screening(True)
+ expect(ui.rules.get_by_label("Rule name", exact=True)).to_have_value("Unsaved restriction")
+ expect(ui.editor.get_by_label("Baseline policy enabled", exact=False)).to_be_checked()
+ assert ui.policy["rules"] == [] and not ui.app.policy_writes
+ initialized_etag = ui.app.policy_etag
+ ui.save()
+ assert ui.app.policy_writes[-1]["etag"] == initialized_etag
+ assert ui.policy["rules"][0]["values"] == ["PRIVATE_DRAFT_VALUE"]
+ assert ui.policy["enabled"] is True and ui.screening_enabled is True
+
+
+def test_activation_keeps_a_new_unfilled_rule_in_the_policy_draft(policy_ui):
+ ui = policy_ui
+ ui.prepare_first_activation()
+ ui.open()
+ ui.editor.get_by_role("button", name="Add regex rule", exact=True).click()
+ ui.configure_screening(True)
+ expect(ui.rules).to_have_count(1)
+ expect(ui.rules.get_by_label("Rule name", exact=True)).to_have_value("")
+ expect(ui.rules.get_by_label("Regular expression", exact=True)).to_have_value("")
+ assert ui.policy["rules"] == [] and not ui.app.policy_writes
+
+
+def test_policy_draft_survives_an_activation_storage_failure(policy_ui):
+ ui = policy_ui
+ ui.prepare_first_activation()
+ if ui.classic:
+ ui.app.fail_configuration = 503
+ else:
+ ui.app.reject_settings_save = True
+ ui.open()
+ ui.editor.get_by_role("button", name="Add literal rule", exact=True).click()
+ ui.rules.get_by_label("Rule name", exact=True).fill("Keep my draft")
+ ui.rules.get_by_label("Literal values or phrases", exact=True).fill("DRAFT_VALUE")
+ if ui.classic:
+ ui.app.page.locator("#enable_content_screening").check()
+ expect(ui.editor.get_by_text("Content Screening could not be changed.", exact=False)).to_be_visible()
+ else:
+ set_toggle(ui.app.page, "Screen workspace content before publication", True)
+ ui.app.page.get_by_role("button", name="Save changes", exact=True).click()
+ expect(ui.app.page.get_by_text("Settings were not saved.", exact=False)).to_be_visible()
+ expect(ui.rules.get_by_label("Rule name", exact=True)).to_have_value("Keep my draft")
+ expect(ui.editor.get_by_role("button", name="Save screening policy", exact=True)).to_be_enabled()
+ assert ui.screening_enabled is False and ui.app.policy_etag is None
+ assert not ui.app.policy_writes
+
+
+@pytest.mark.parametrize("remove", [False, True])
+def test_disabling_or_removing_last_check_can_be_saved_while_feature_stays_enabled(policy_ui, remove):
+ ui = policy_ui
+ if ui.classic:
+ ui.app.config["enabled"] = True
+ else:
+ ui.app.scan_enabled = True
+ ui.open()
+ if remove:
+ ui.rules.get_by_role("button", name=re.compile("^Remove rule")).click()
+ else:
+ set_toggle(ui.rules, "Rule enabled", False)
+ expect(ui.summary).to_contain_text("No active checks configured")
+ ui.save()
+ assert ui.screening_enabled is True and ui.policy["enabled"] is True
+ assert not any(rule["enabled"] for rule in ui.policy["rules"])
+ ui.open()
+ expect(ui.summary).to_contain_text("No active checks configured")
+ assert ui.screening_enabled is True
+
+
+def test_enabling_preserves_existing_disabled_policy_without_activating_its_checks(policy_ui):
+ ui = policy_ui
+ ui.policy["enabled"] = False
+ original = copy.deepcopy(ui.policy)
+ if ui.classic:
+ ui.app.config["enabled"] = False
+ ui.open()
+ ui.configure_screening(True)
+ expect(ui.editor.get_by_label("Baseline policy enabled", exact=False)).not_to_be_checked()
+ expect(ui.summary).to_contain_text("Policy disabled")
+ assert ui.policy == original and not ui.app.policy_writes
+ assert ui.screening_enabled is True
+
+
+def test_settings_refresh_does_not_overwrite_a_concurrent_policy_change(policy_ui):
+ ui = policy_ui
+ if ui.classic:
+ ui.app.config["enabled"] = False
+ ui.open()
+ ui.rules.get_by_label("Rule name", exact=True).fill("My unsaved name")
+ ui.policy["rules"][0]["name"] = "Another administrator's saved name"
+ ui.app.policy_etag = '"concurrent-policy"'
+ if ui.classic:
+ ui.app.page.locator("#enable_content_screening").check()
+ else:
+ set_toggle(ui.app.page, "Screen workspace content before publication", True)
+ ui.app.page.get_by_role("button", name="Save changes", exact=True).click()
+ expect(ui.editor.get_by_text("policy draft", exact=False).filter(has_text="retained")).to_be_visible()
+ expect(ui.rules.get_by_label("Rule name", exact=True)).to_have_value("My unsaved name")
+ expect(ui.editor.get_by_role("button", name="Save screening policy", exact=True)).to_be_disabled()
+ assert ui.policy["rules"][0]["name"] == "Another administrator's saved name"
+ assert not ui.app.policy_writes
+
+
+def test_empty_policy_can_save_but_sample_test_explains_no_checks(policy_ui):
+ ui = policy_ui
+ ui.policy.update({**default_policy(), "enabled": True})
+ ui.open()
+ ui.editor.get_by_label("Synthetic sample text" if ui.classic else "Sample content", exact=True).fill("Ordinary example")
+ ui.editor.get_by_role("button", name="Test policy draft" if ui.classic else "Test screening policy", exact=True).click()
+ expect(ui.editor.get_by_text("Add an enabled rule or AI check before testing.", exact=False)).to_be_visible()
+ assert not ui.app.policy_samples
+ ui.save()
+ assert ui.policy["enabled"] is True and ui.policy["rules"] == []
+
+
@pytest.mark.parametrize("rule_type", ["literal", "regex", "pii"])
def test_custom_rules_start_blank_and_persist_without_model_checks(policy_ui, rule_type):
ui = policy_ui
diff --git a/ui_tests/test_v2_content_screening.py b/ui_tests/test_v2_content_screening.py
index e53ac093e..e06c7e45a 100644
--- a/ui_tests/test_v2_content_screening.py
+++ b/ui_tests/test_v2_content_screening.py
@@ -1,8 +1,9 @@
# test_v2_content_screening.py
"""
Production V2 browser regressions for screening-controlled documents and review.
-Version: 0.261.108
+Version: 0.261.114
Implemented in: 0.261.106
+Empty-policy activation coverage: 0.261.114
Runs the real SPA with the existing closed workspace fixture and its Azure
Playwright/DefaultAzureCredential connection support or explicit local fallback.
@@ -210,12 +211,10 @@ def _dispatch(self, route, entry):
updates = entry.body["settings"]
if self.reject_settings_save:
self._json(route, {"error": "Settings were not saved. Reload the current settings and try again.", "success": False}, 503)
- elif updates.get("enable_content_screening") is True and not self.policy["enabled"]:
- self._json(route, {
- "error": "Save an enabled screening policy first.",
- "field_errors": {"enable_content_screening": "Save an enabled screening policy first."},
- }, 400)
else:
+ if updates.get("enable_content_screening") is True and self.policy_etag is None:
+ self.policy = normalize_policy({**default_policy(), "enabled": True})
+ self.policy_etag = '"policy-initialized"'
self.scan_enabled = updates.get("enable_content_screening", self.scan_enabled)
self._json(route, {
"success": True, "updated_keys": list(updates), "settings": updates, "warnings": {},
@@ -979,7 +978,7 @@ def test_admin_policy_conflict_requires_reload_before_another_save(screening_ui)
editor = open_screening_admin(ui)
editor.get_by_label("Rule name", exact=True).fill("Unsaved local name")
editor.get_by_role("button", name="Save screening policy", exact=True).click()
- expect(editor.get_by_text("The saved policy changed.", exact=False)).to_be_visible()
+ expect(editor.get_by_text("Reload the saved policy before saving or testing again;", exact=False)).to_be_visible()
expect(editor.get_by_role("button", name="Save screening policy", exact=True)).to_be_disabled()
assert len(ui.policy_writes) == 1
ui.reject_policy_save = None