Skip to content

Commit 46d08d3

Browse files
placerdaCopilot
andcommitted
fix: preserve confirmed ingestion failure outcomes
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent bbe5292 commit 46d08d3

24 files changed

Lines changed: 846 additions & 233 deletions

.github/scripts/check-quality.py

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,9 @@ def policy_findings(root: Path, base: str, current: list[dict], protected: list[
164164
for field in ("runtime_roots", "contracts", "toolchain", "required_checks", "dynamic_imports", "review"):
165165
if policy[field] != base_policy[field]:
166166
findings.append(q.finding("protected-policy-change", ".quality/policy.json", reason=field))
167+
for field in ("coverage_stage", "planned_expansion", "review"):
168+
if scope.get(field) != base_scope.get(field):
169+
findings.append(q.finding("protected-policy-change", ".quality/typing-scope.json", reason=field))
167170
old_surfaces = {module["id"]: module for module in base_policy["modules"]}
168171
for surface in policy["modules"]:
169172
old = old_surfaces.get(surface["id"])
@@ -196,7 +199,8 @@ def policy_findings(root: Path, base: str, current: list[dict], protected: list[
196199
return findings
197200

198201

199-
def lint(root: Path, config: Path, sources: dict) -> tuple[list[dict], dict]:
202+
def lint(root: Path, config: Path, sources: dict, entries: list[dict],
203+
modules: dict[str, str], review_stage: str) -> tuple[list[dict], dict]:
200204
result = q.run_tool([sys.executable, "-m", "ruff", "check", "--config", str(config),
201205
"--output-format", "json", "--no-cache", *sources], root)
202206
try:
@@ -205,13 +209,31 @@ def lint(root: Path, config: Path, sources: dict) -> tuple[list[dict], dict]:
205209
raise q.QualityError("Ruff did not return JSON") from exc
206210
if not isinstance(diagnostics, list) or (result.returncode == 1 and not diagnostics):
207211
raise q.QualityError("Incomplete Ruff execution")
212+
approved_sites = []
213+
path_ids = {path: key for key, path in modules.items()}
214+
for site in q.handler_sites(sources):
215+
site["module_id"] = path_ids[site["file"]]
216+
matching = [record for record in q.matching_exception_records(site, entries)
217+
if record["review"].get("status") == "active"
218+
and q.valid_exception_metadata(record, review_stage)]
219+
if len(matching) == 1:
220+
approved_sites.append((site, matching[0]["id"]))
208221
findings = []
222+
used_ids = set()
209223
for item in diagnostics:
210224
if not all(key in item for key in ("code", "filename", "location", "message")):
211225
raise q.QualityError("Unsupported Ruff diagnostic")
212226
file = Path(item["filename"]).resolve().relative_to(root).as_posix()
227+
if item["code"] == "BLE001":
228+
approved = [record_id for site, record_id in approved_sites
229+
if site["file"] == file
230+
and site["line"] <= item["location"]["row"] <= site["header_end_line"]]
231+
if len(approved) == 1:
232+
# The separate required exceptions job proves same-run behavior evidence.
233+
used_ids.update(approved)
234+
continue
213235
findings.append(q.finding(item["code"], file, item["location"]["row"], item["message"]))
214-
return findings, {}
236+
return findings, {"exception_ids_used": sorted(used_ids)}
215237

216238

217239
def typing(root: Path, config: Path, sources: dict, modules: dict, covered: set,
@@ -357,6 +379,9 @@ def main() -> int:
357379
if any(modules.get(module_id) not in sources for module_id in covered):
358380
raise q.QualityError("Covered module is missing; coverage cannot disappear")
359381
selected = q.REQUIRED_CHECKS[:-1] if args.check == "all" else (args.check,)
382+
entries = current[3]["entries"] if bootstrap else [
383+
entry for entry in current[3]["entries"] if entry in protected[3]["entries"]
384+
]
360385
with tempfile.TemporaryDirectory(prefix="ingestion-quality-") as temporary:
361386
config = Path(temporary) / "pyproject.toml"
362387
config.write_text(config_text, encoding="utf-8")
@@ -365,7 +390,9 @@ def main() -> int:
365390
findings = policy_findings(root, base, current, protected, sources, declared_modules, base_modules, bootstrap)
366391
details = {"bootstrap": bootstrap}
367392
elif check == "lint":
368-
findings, details = lint(root, config, sources)
393+
findings, details = lint(root, config, sources, entries, modules,
394+
protected[1].get("coverage_stage", "initial"))
395+
report["exception_ids_used"].extend(details["exception_ids_used"])
369396
elif check == "typing":
370397
baseline = current[2] if bootstrap else {"entries": [
371398
entry for entry in current[2]["entries"] if entry in protected[2]["entries"]
@@ -376,11 +403,10 @@ def main() -> int:
376403
else:
377404
evidence = load_evidence(args.test_evidence, base, head)
378405
# Candidate additions cannot grant exemptions to base-policy code.
379-
entries = current[3]["entries"] if bootstrap else [
380-
entry for entry in current[3]["entries"] if entry in protected[3]["entries"]
381-
]
382406
findings = q.exception_findings(sources, entries, evidence,
383-
{path: key for key, path in modules.items()})
407+
{path: key for key, path in modules.items()},
408+
accepted_ids=report["exception_ids_used"],
409+
review_stage=protected[1].get("coverage_stage", "initial"))
384410
for record in policy["dynamic_imports"]:
385411
if (record.get("review", {}).get("status") != "active"
386412
or not record.get("evidence_tests")
@@ -394,6 +420,7 @@ def main() -> int:
394420
except (q.QualityError, OSError, UnicodeError, KeyError, TypeError, subprocess.TimeoutExpired) as exc:
395421
report["findings"].append(q.finding("execution-error", reason=str(exc)))
396422
LOG.error("Quality execution incomplete: %s", exc)
423+
report["exception_ids_used"] = sorted(set(report["exception_ids_used"]))
397424
report["duration_seconds"] = round(time.monotonic() - started, 3)
398425
report["artifact_integrity"] = q.digest(report)
399426
args.report.parent.mkdir(parents=True, exist_ok=True)

.github/scripts/quality_policy.py

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
REQUIRED_CHECKS = ("lint", "typing", "architecture", "exceptions", "policy", "unit-tests")
2424
NON_RUNTIME = (".github/", "tests/", "scripts/", "samples/", "frontend/", ".artifacts/")
2525
DIAGNOSTIC_KEY = ("module_id", "symbol", "source_fingerprint", "rule", "message_fingerprint")
26+
EXCEPTION_KEY = ("module_id", "symbol", "handler_fingerprint", "caught_types")
2627

2728

2829
class QualityError(ValueError):
@@ -338,6 +339,7 @@ def handler_sites(sources: dict[str, str]) -> list[dict]:
338339
symbol, _ = source_context(tree, handler.lineno)
339340
sites.append({
340341
"module_id": module_name(path), "file": path, "line": handler.lineno,
342+
"header_end_line": handler.type.end_lineno if handler.type else handler.lineno,
341343
"symbol": symbol, "caught_types": names,
342344
"handler_fingerprint": digest({
343345
"try": ast.dump(node, include_attributes=False),
@@ -347,18 +349,32 @@ def handler_sites(sources: dict[str, str]) -> list[dict]:
347349
return sites
348350

349351

352+
def matching_exception_records(site: dict, records: list[dict]) -> list[dict]:
353+
return [record for record in records if all(record.get(key) == site[key] for key in EXCEPTION_KEY)]
354+
355+
356+
def valid_exception_metadata(record: dict, review_stage: str) -> bool:
357+
required = ("boundary", "reason", "failure_outcome", "diagnostic_path")
358+
return (all(record.get(key) for key in required)
359+
and bool(record["review"].get("reference"))
360+
and record.get("review_by_stage") == review_stage)
361+
362+
350363
def exception_findings(sources: dict[str, str], records: list[dict], evidence: dict,
351-
module_ids: dict[str, str] | None = None) -> list[dict]:
364+
module_ids: dict[str, str] | None = None,
365+
accepted_ids: list[str] | None = None,
366+
review_stage: str = "initial") -> list[dict]:
352367
findings = []
353368
used = set()
369+
proposed = set()
354370
identities = Counter()
355371
for site in handler_sites(sources):
356372
site["module_id"] = (module_ids or {}).get(site["file"], site["module_id"])
357373
identity = tuple(str(site[key]) for key in ("module_id", "symbol", "handler_fingerprint", "caught_types"))
358374
identities[identity] += 1
359-
matches = [record for record in records if all(record.get(key) == site[key]
360-
for key in ("module_id", "symbol", "handler_fingerprint", "caught_types"))
361-
and record.get("review", {}).get("status") == "active"]
375+
matching = matching_exception_records(site, records)
376+
proposed.update(record["id"] for record in matching if record.get("review", {}).get("status") == "proposed")
377+
matches = [record for record in matching if record.get("review", {}).get("status") == "active"]
362378
if len(matches) != 1 or identities[identity] > 1:
363379
findings.append(finding("unapproved-handler", site["file"], site["line"],
364380
"Requires one exact reviewed boundary record", **{
@@ -367,14 +383,17 @@ def exception_findings(sources: dict[str, str], records: list[dict], evidence: d
367383
continue
368384
record = matches[0]
369385
used.add(record["id"])
370-
required = ("boundary", "reason", "failure_outcome", "diagnostic_path", "review_by_stage")
371-
if not all(record.get(key) for key in required) or not record["review"].get("reference"):
386+
errors_before = len(findings)
387+
if not valid_exception_metadata(record, review_stage):
372388
findings.append(finding("invalid-exception", site["file"], site["line"], record["id"]))
373389
if not record.get("evidence_tests") or any(evidence.get(test) != "passed" for test in record["evidence_tests"]):
374390
findings.append(finding("exception-evidence", site["file"], site["line"], record["id"]))
391+
if len(findings) == errors_before and accepted_ids is not None:
392+
accepted_ids.append(record["id"])
375393
for record in records:
376394
if record["id"] not in used:
377-
findings.append(finding("stale-exception", reason=record["id"]))
395+
rule = "exception-review-pending" if record["id"] in proposed else "stale-exception"
396+
findings.append(finding(rule, reason=record["id"]))
378397
return findings
379398

380399

.quality/exceptions.json

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,75 @@
11
{
22
"schema_version": 1,
3-
"entries": [],
3+
"entries": [
4+
{
5+
"id": "audit-unexpected-sanitizer-failure",
6+
"module_id": "telemetry.audit",
7+
"symbol": "_emit",
8+
"handler_fingerprint": "62bd9ada079c3a129b34b1d3ef5cf20037e5b840ca44b73bd9b46e0d365deb51",
9+
"caught_types": ["Exception"],
10+
"boundary": "Optional audit sanitization before export",
11+
"reason": "A sanitizer defect must drop an unsafe audit event, not fail an already-running ingestion operation. This record covers only the unexpected sanitizer catch, not source/configuration failures.",
12+
"failure_outcome": "contractual-best-effort-side-effect",
13+
"diagnostic_path": "gptrag.audit_warning: dropped event type and exception class, without exception payload",
14+
"evidence_tests": ["tests/test_audit_emitter.py::test_audit_side_effect_failure_is_nonblocking_and_does_not_log_payload[sanitizer]"],
15+
"review": {"status": "proposed", "reference": "https://github.com/Azure/gpt-rag-ingestion/pull/296"},
16+
"review_by_stage": "initial"
17+
},
18+
{
19+
"id": "audit-exporter-failure",
20+
"module_id": "telemetry.audit",
21+
"symbol": "_emit",
22+
"handler_fingerprint": "0dbfe922bf286e9bdd01421b045fa7add8315b201f11a3c421425d7c0b83307f",
23+
"caught_types": ["Exception"],
24+
"boundary": "Optional audit logger/exporter invocation",
25+
"reason": "A failed logger/exporter must not reverse a confirmed Search outcome or turn a successful worker into a failure. Only the audit side effect is dropped; the primary result is unchanged.",
26+
"failure_outcome": "contractual-best-effort-side-effect",
27+
"diagnostic_path": "gptrag.audit_warning: failed export stage, event type and exception class only",
28+
"evidence_tests": [
29+
"tests/test_audit_emitter.py::test_broken_exporter_does_not_raise_and_does_not_fail_the_run",
30+
"tests/test_audit_emitter.py::test_audit_side_effect_failure_is_nonblocking_and_does_not_log_payload[exporter]"
31+
],
32+
"review": {"status": "proposed", "reference": "https://github.com/Azure/gpt-rag-ingestion/pull/296"},
33+
"review_by_stage": "initial"
34+
},
35+
{
36+
"id": "audit-run-failure-propagation",
37+
"module_id": "telemetry.audit",
38+
"symbol": "audit_run",
39+
"handler_fingerprint": "978bd4cd83a4cd6092d208c117da9f6be5eda81fe689a1df1b39335df0f064e4",
40+
"caught_types": ["Exception"],
41+
"boundary": "Run context manager observing the primary worker failure",
42+
"reason": "Record exactly one failed terminal event and re-raise the same primary exception. The context-variable cleanup must still run; this catch never converts an indexing/purge failure to success.",
43+
"failure_outcome": "propagation",
44+
"diagnostic_path": "Correlated ingestion.run.failed event; original exception propagates to the worker caller",
45+
"evidence_tests": [
46+
"tests/test_audit_emitter.py::test_unhandled_exception_emits_failed_and_reraises",
47+
"tests/test_audit_emitter.py::test_cancelled_error_is_preserved_and_emits_cancelled_event"
48+
],
49+
"review": {"status": "proposed", "reference": "https://github.com/Azure/gpt-rag-ingestion/pull/296"},
50+
"review_by_stage": "initial"
51+
},
52+
{
53+
"id": "document-audit-construction-failure",
54+
"module_id": "telemetry.audit",
55+
"symbol": "record_search_batch_result",
56+
"handler_fingerprint": "6a1ad3870993d274922017fa77170b067eb97be1e74e885c99c3397854acee1f",
57+
"caught_types": ["Exception"],
58+
"boundary": "Optional document audit projection of an existing Search response",
59+
"reason": "Malformed audit projection must not alter the caller's primary SDK result. The write adapter separately validates confirmation; this catch does not approve or synthesize a successful upload/delete.",
60+
"failure_outcome": "contractual-best-effort-side-effect",
61+
"diagnostic_path": "gptrag.audit_warning: document emission failed, with exception class but no source or exception payload",
62+
"evidence_tests": [
63+
"tests/test_audit_emitter.py::test_document_audit_never_raises_even_with_a_malformed_result",
64+
"tests/test_audit_emitter.py::test_audit_side_effect_failure_is_nonblocking_and_does_not_log_payload[document]"
65+
],
66+
"review": {"status": "proposed", "reference": "https://github.com/Azure/gpt-rag-ingestion/pull/296"},
67+
"review_by_stage": "initial"
68+
}
69+
],
470
"review": {
571
"status": "proposed",
672
"reference": "https://github.com/Azure/GPT-RAG/pull/689",
7-
"rationale": "No blanket exemptions. Unreviewed inherited handlers remain blocking findings; audit best-effort behavior is not rewritten to satisfy a gate."
73+
"rationale": "Four exact audit-boundary proposals with failure evidence, not active approvals. All unreviewed handlers remain blocking; no blanket exemptions."
874
}
975
}

.quality/policy.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"Depends",
4545
"Dict",
4646
"HTTPException",
47+
"JobLookupError",
4748
"List",
4849
"Literal",
4950
"ManagedIdentityCredential",
@@ -1250,6 +1251,7 @@
12501251
"AzureCliCredential",
12511252
"AzureError",
12521253
"ChainedTokenCredential",
1254+
"Counter",
12531255
"Dict",
12541256
"List",
12551257
"ManagedIdentityCredential",

AGENTS.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,9 @@ and handler checks include flat roots, late/type-only imports and package
114114
facades. `main` compatibility re-exports are not another owner of mutable state.
115115
Use the jobs-owned test seam when patching scheduling.
116116

117-
The initial exception ledger is empty and inherited handler/lint findings
118-
remain blocking. This is not an active required-merge claim. Protected policy
117+
Four audit exception records are proposed with exact failure evidence; none
118+
is active. Other inherited handler/lint findings remain blocking. This is not
119+
an active required-merge claim. Protected policy
119120
review, a clean reference PR and administrative rule activation are separate
120121
acceptance requirements; no agent may change GitHub settings to bypass them.
121122

CHANGELOG.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,23 @@
1919
main re-exports, manual reservation behavior and scheduler lifecycle. Lazy
2020
worker exports keep state-only imports from initializing Azure configuration
2121
before startup authentication. Remove two overwritten, unused SharePoint
22-
wrappers; keep the registered audit-wrapped functions unchanged.
22+
wrappers while retaining the shared registry/lock and startup ordering.
23+
24+
### Fixed
25+
26+
- **Primary failures no longer masquerade as confirmed work.** Search deletion
27+
uses the SDK delete operation and counts matching confirmed outcomes rather
28+
than submissions; NL2SQL purge propagates failed scans and partial deletion.
29+
Missing, malformed or duplicate Search responses cannot emit positive audit
30+
outcomes. Cron/manual failures propagate through the existing audit context,
31+
while startup jobs retain independent failure isolation.
32+
33+
- **Configuration and diagnostic failures remain explicit and safe.** Governance
34+
reads no longer silently disable governance after provider failure.
35+
Configuration apply returns an error on failed scheduling, and writes with a
36+
failed local refresh return the existing partial-failure shape. Audit
37+
sanitization/export/projection remains non-blocking, with payload-free warning
38+
diagnostics and four exact, unapproved boundary proposals for review.
2339

2440
## [v2.7.3] - 2026-09-03
2541

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,11 @@ Python 3.12 contributor commands, the jobs-owned scheduler interface,
2727
incremental typing scope, import/error policy and recovery guidance are in
2828
[Python quality gates](docs/python-quality.md). The bootstrap reports inherited
2929
violations without blanket exemptions; required-check activation and remaining
30-
failure-boundary review are explicitly pending. Runtime contracts and operator
31-
startup/configuration remain unchanged.
30+
failure-boundary review are explicitly pending. Existing successful response
31+
shapes, schema bytes and authentication remain unchanged. Search and NL2SQL
32+
deletion counts now require confirmed results; worker and governance failures
33+
propagate, and configuration apply/refresh failures are reported explicitly
34+
rather than as success. Startup jobs retain independent failure isolation.
3235

3336
## Governance and audit events
3437

0 commit comments

Comments
 (0)