Edictum
Reference

Postcondition Findings

When a postcondition contract detects an issue in tool output (PII, secrets, contract violations),

AI Assistance

Right page if: you need to handle postcondition results programmatically -- redacting PII, routing findings by type, or understanding the `on_postcondition_warn` callback and `PostCallResult` fields. Wrong page if: you need the YAML syntax for writing postcondition contracts -- see https://docs.edictum.ai/docs/contracts/postconditions or https://docs.edictum.ai/docs/contracts/yaml-reference. Gotcha: finding `type` classification uses substring matching on contract ID and message -- a contract ID containing "secret" maps to `secret_detected` even if unintended. Wrap-around adapters (LangChain, Agno, Semantic Kernel) can replace tool results; hook-based adapters (CrewAI, Claude SDK, OpenAI Agents) cannot.

When a postcondition contract detects an issue in tool output (PII, secrets, contract violations), Edictum produces structured findings that your application can act on.

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 your on_postcondition_warn callback remediates
  • effect: 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:

FieldTypeDescription
typestrCategory: 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 no-secret-exposure would match secret_detected because "secret" appears as a substring. Choose contract IDs carefully to avoid misclassification.
contract_idstrWhich contract produced this finding
fieldstrWhich selector triggered it. Defaults to "output" for postconditions; contracts can provide a more specific value via Verdict.fail("msg", field="output.text")
messagestrHuman-readable description
metadatadictExtra 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:

FieldTypeDefaultDescription
resultAny--The tool output, potentially modified by redact or deny effects
postconditions_passedbool--True if all postconditions passed
findingslist[Finding][]Structured findings from failing postconditions
output_suppressedboolFalseTrue when a postcondition with effect: deny fired (READ/PURE tools only). The original output is replaced with [OUTPUT SUPPRESSED] {verdict.message}.
PostCallResult(
    result="raw tool output with SSN 123-45-6789",
    postconditions_passed=False,
    findings=[Finding(type="pii_detected", ...)],
    output_suppressed=False,
)

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

ModePostcondition warnsCallback invokedResult transformed
observeWarning prepended with [observe]; audit event is CALL_EXECUTED or CALL_FAILEDYes (if provided)Yes
enforceLogged as postcondition_warningYes (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:

AdapterPatternCallback return value
LangChainWrap-aroundReplaces tool result — the LLM sees the callback return value
AgnoWrap-aroundReplaces tool result
Semantic KernelFilterReplaces context.function_result
Google ADKCallbackSide-effect only — return value ignored
CrewAIHookSide-effect only — return value ignored
Claude Agent SDKHookSide-effect only — return value ignored
OpenAI Agents SDKGuardrailSide-effect only — return value ignored

For wrap-around adapters, write callbacks that return the transformed result:

def redact(result, findings):
    return mask_pii(result)  # returned value replaces the original

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:

Frameworkresult typeTransformation respectedPII interception
LangChainToolMessageYes — mutate .contentFull
AgnostrYes — return new stringFull
Semantic Kernelstr (wrapped in FunctionResult)YesFull
Google ADKAnyNo — warn callback is side-effect onlyFull (redact/deny via pipeline)
OpenAI AgentsstrNo — allow/reject onlyLogged only
CrewAIstrNo — side-effect onlyLogged only
Claude Agent SDKAnyNo — side-effect onlyLogged only

For regulated environments requiring PII interception, use LangChain, Agno, Semantic Kernel, or Google ADK.

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 warn effects (code, testable, framework-specific)
  • redact and deny effects require no application code -- the pipeline handles enforcement

Last updated on

On this page