Postcondition Findings¶
When a postcondition contract detects an issue in tool output (PII, secrets, contract violations), Edictum produces structured findings that your application can act on.
When to use this¶
Read this page when you need to act on postcondition results programmatically -- redacting PII, routing findings to different handlers, or building compliance dashboards grouped by finding type. Findings are the structured output that postconditions produce, and your on_postcondition_warn callback receives them. For the contract types that produce findings, see contracts. For postcondition effect behavior (warn/redact/deny), see YAML reference.
The Pattern: Detect -> Remediate¶
Postconditions detect issues in tool output. What happens next depends on the declared effect:
effect: warn(default) -- the contract produces findings and youron_postcondition_warncallback remediateseffect: redact-- the pipeline automatically replaces matched patterns with[REDACTED](READ/PURE tools only)effect: deny-- the pipeline suppresses the entire output (READ/PURE tools only)
For warn, your code handles remediation. For redact and deny, the pipeline handles it automatically. In all cases, findings are still produced and the callback is still invoked if provided. Claude SDK and OpenAI Agents native hooks cannot substitute results -- see adapter limitations.
from edictum import Edictum
from edictum.adapters.langchain import LangChainAdapter
guard = Edictum.from_yaml("contracts.yaml")
adapter = LangChainAdapter(guard)
# Without remediation -- findings are logged, result unchanged
wrapper = adapter.as_tool_wrapper()
# With remediation -- callback transforms result when postconditions warn
wrapper = adapter.as_tool_wrapper(
on_postcondition_warn=lambda result, findings: redact_pii(result, findings)
)
Finding Object¶
Each finding contains:
| Field | Type | Description |
|---|---|---|
type |
str | Category: pii_detected, secret_detected, limit_exceeded, policy_violation. Assigned by classify_finding(), which uses substring matching on the contract ID and message -- for example, a contract ID containing "secret" maps to secret_detected. Be aware that this is a heuristic: a contract ID like require-authentication would match secret_detected because "secret" appears as a substring. Choose contract IDs carefully to avoid misclassification. |
contract_id |
str | Which contract produced this finding |
field |
str | Which selector triggered it. Defaults to "output" for postconditions; contracts can provide a more specific value via Verdict.fail("msg", field="output.text") |
message |
str | Human-readable description |
metadata |
dict | Extra context (optional) |
Finding(
type="pii_detected",
contract_id="pii-in-any-output",
field="output.text",
message="SSN pattern detected in tool output",
metadata={"match_count": 2},
)
Findings are frozen (immutable) -- they cannot be modified after creation.
PostCallResult¶
The adapter's post-tool-call returns a PostCallResult:
PostCallResult(
result="raw tool output with SSN 123-45-6789",
postconditions_passed=False,
findings=[Finding(type="pii_detected", ...)],
)
When postconditions_passed is True, the findings list is empty and the callback is not invoked.
Remediation Examples¶
Surgical PII redaction¶
import re
def redact_pii(result, findings):
"""Replace PII patterns while keeping useful data intact."""
text = str(result)
for f in findings:
if f.type == "pii_detected":
text = re.sub(r'\b\d{3}-\d{2}-\d{4}\b', '***-**-****', text)
text = re.sub(r'Name:\s*\w+\s+\w+', 'Name: [REDACTED]', text)
return text
wrapper = adapter.as_tool_wrapper(on_postcondition_warn=redact_pii)
Full replacement¶
def replace_on_warn(result, findings):
"""Replace entire result with warning message."""
messages = [f.message for f in findings]
return f"[REDACTED] Postcondition warnings: {'; '.join(messages)}"
wrapper = adapter.as_tool_wrapper(on_postcondition_warn=replace_on_warn)
Log and pass through¶
import logging
logger = logging.getLogger("my_agent")
def log_findings(result, findings):
"""Log findings but return result unchanged."""
for f in findings:
logger.warning(f"[{f.contract_id}] {f.type}: {f.message}")
return result # unchanged
wrapper = adapter.as_tool_wrapper(on_postcondition_warn=log_findings)
Route by finding type¶
def route_by_type(result, findings):
"""Different remediation per finding type."""
text = str(result)
for f in findings:
if f.type == "pii_detected":
text = redact_pii_patterns(text)
elif f.type == "secret_detected":
text = "[DENIED] Secret detected in tool output"
break # full block on secrets
return text
wrapper = adapter.as_tool_wrapper(on_postcondition_warn=route_by_type)
How It Works With Observe / Enforce¶
| Mode | Postcondition warns | Callback invoked | Result transformed |
|---|---|---|---|
| observe | Warning prepended with [observe]; audit event is CALL_EXECUTED or CALL_FAILED |
Yes (if provided) | Yes |
| enforce | Logged as postcondition_warning |
Yes (if provided) | Yes |
The callback fires in both modes when postconditions produce findings.
Postconditions with effect: warn always allow the tool call to complete --
the callback controls what the LLM sees in the result.
Callback Semantics by Adapter¶
The callback behavior differs depending on whether the adapter controls tool execution:
| Adapter | Pattern | Callback return value |
|---|---|---|
| LangChain | Wrap-around | Replaces tool result — the LLM sees the callback return value |
| Agno | Wrap-around | Replaces tool result |
| Semantic Kernel | Filter | Replaces context.function_result |
| CrewAI | Hook | Side-effect only — return value ignored |
| Claude Agent SDK | Hook | Side-effect only — return value ignored |
| OpenAI Agents SDK | Guardrail | Side-effect only — return value ignored |
For wrap-around adapters, write callbacks that return the transformed result:
For hook-based adapters, write callbacks that perform side effects (logging, alerting):
def log_and_alert(result, findings):
logger.warning("PII detected: %s", findings)
alert_service.notify(findings)
# return value is ignored
If the callback raises an exception, it is caught and logged. The original tool result is returned unchanged to avoid breaking execution.
Framework-Specific Callback Behavior¶
The on_postcondition_warn callback signature is consistent across all adapters:
(result, findings) -> result. However, what result is and whether the
transformed result reaches the LLM depends on the framework:
| Framework | result type |
Transformation respected | PII interception |
|---|---|---|---|
| LangChain | ToolMessage |
Yes — mutate .content |
Full |
| Agno | str |
Yes — return new string | Full |
| Semantic Kernel | str (wrapped in FunctionResult) |
Yes | Full |
| OpenAI Agents | str |
No — allow/reject only | Logged only |
| CrewAI | str |
No — side-effect only | Logged only |
| Claude Agent SDK | Any |
No — side-effect only | Logged only |
For regulated environments requiring PII interception, use LangChain, Agno, or Semantic Kernel.
Relationship to Contracts¶
Contracts are declarative. With effect: warn, they detect and your code remediates. With effect: redact or effect: deny, the pipeline handles common remediation automatically.
# Detect and warn -- your callback remediates
- id: pii-in-any-output
type: post
tool: "*"
when:
output.text:
matches_any: ["\\b\\d{3}-\\d{2}-\\d{4}\\b", "\\bUSR-\\d+\\b"]
then:
effect: warn
message: "PII pattern detected in tool output"
# Detect and redact -- pipeline handles it
- id: secrets-in-output
type: post
tool: "*"
when:
output.text:
matches_any: ['sk-prod-[a-z0-9]{8}', 'AKIA-PROD-[A-Z]{12}']
then:
effect: redact
message: "Secrets detected and redacted."
For warn, the contract says "this output contains PII" and your on_postcondition_warn callback decides what to do. For redact, the pipeline removes the matched patterns automatically. For deny, the pipeline suppresses the entire output.
This separation means:
- Compliance teams write contracts (YAML, auditable, versioned)
- Engineering teams write remediation for
warneffects (code, testable, framework-specific) redactanddenyeffects require no application code -- the pipeline handles enforcement