Edictum
Contracts Reference

Postconditions

Postconditions inspect tool output after execution -- detecting PII, secrets, and sensitive content with three enforcement effects: warn, redact, and deny.

AI Assistance

Right page if: you need to scan tool output for PII, secrets, or sensitive content after execution using postconditions. Wrong page if: the danger is in the input arguments, not the output -- use preconditions at https://docs.edictum.ai/docs/contracts/preconditions. Gotcha: `effect: redact` and `effect: deny` only work on tools classified as `pure` or `read`. For `write` or `irreversible` tools, postconditions silently fall back to `warn` because the side effect already happened.

Preconditions check tool calls before execution. But some threats only appear in the output -- an SQL query that returns Social Security numbers, a file read that exposes API keys, a search result containing medical records. Postconditions scan tool output after execution and enforce three effects: warn, redact, or deny.

apiVersion: edictum/v1
kind: ContractBundle
metadata:
  name: output-safety
defaults:
  mode: enforce

tools:
  sql_query:
    side_effect: read
  read_file:
    side_effect: read
  search_records:
    side_effect: pure
  update_record:
    side_effect: write

contracts:
  - id: ssn-in-output
    type: post
    tool: "*"
    when:
      output.text:
        matches: '\b\d{3}-\d{2}-\d{4}\b'
    then:
      effect: redact
      message: "SSN pattern detected and redacted."
      tags: [pii, compliance]

This contract scans every tool's output for US Social Security Number patterns. When sql_query returns a result containing 123-45-6789, the pipeline replaces it with [REDACTED]. The agent sees the rest of the output intact.

The Three Effects

Postconditions support three effects. Each answers a different question about what to do when sensitive content appears in tool output.

warn -- Detect and Delegate

The tool result is unchanged. The contract produces a finding and invokes your on_postcondition_warn callback, which decides what to do.

- id: pii-in-output
  type: post
  tool: "*"
  when:
    output.text:
      matches_any:
        - '\b\d{3}-\d{2}-\d{4}\b'
        - '\b[A-Z]{2}\d{2}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{0,2}\b'
  then:
    effect: warn
    message: "PII pattern detected in output. Redact before using."
    tags: [pii, compliance]

Use warn when you need custom remediation logic -- different handling per finding type, conditional redaction, or framework-specific result transformation. Your callback receives the raw result and the findings list, and returns whatever the agent should see.

import re

def redact_pii(result, findings):
    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)
    return text

wrapper = adapter.as_tool_wrapper(on_postcondition_warn=redact_pii)

redact -- Targeted Pattern Replacement

The pipeline replaces matched patterns with [REDACTED]. The rest of the output is preserved. This uses the same regex patterns from the when clause -- no new syntax.

- id: api-keys-in-output
  type: post
  tool: "*"
  when:
    output.text:
      matches_any:
        - 'sk-prod-[a-z0-9]{8}'
        - 'AKIA-PROD-[A-Z]{12}'
        - 'ghp_[A-Za-z0-9]{36}'
  then:
    effect: redact
    message: "Secrets detected and redacted."
    tags: [secrets, dlp]

If a read_file call returns Config: sk-prod-a1b2c3d4 and ghp_abcdefghijklmnopqrstuvwxyz0123456789, the agent sees Config: [REDACTED] and [REDACTED]. The surrounding text is untouched.

Use redact when the sensitive content is structured tokens embedded in otherwise useful data. API keys, patient IDs, credit card numbers -- patterns that can be surgically removed without destroying the output's meaning.

deny -- Full Output Suppression

The entire output is replaced with [OUTPUT SUPPRESSED]. The PostCallResult.output_suppressed field is set to true. The agent sees nothing from this tool call.

- id: accommodation-records
  type: post
  tool: "*"
  when:
    output.text:
      matches: '\b(504\s*Plan|IEP|accommodation)\b'
  then:
    effect: deny
    message: "Accommodation info cannot be returned."
    tags: [ferpa, confidential]

Use deny when partial redaction still leaks sensitive information. Accommodation records, privileged legal documents, medical reports -- content where the context around the sensitive tokens is itself sensitive.

Effect Summary

EffectWhat happens to the outputWhen to use
warnUnchanged -- your callback remediatesCustom remediation, conditional logic, framework-specific handling
redactMatched patterns replaced with [REDACTED]Structured tokens (API keys, SSNs) in otherwise useful output
denyEntire output replaced with [OUTPUT SUPPRESSED]Entire output is sensitive (legal docs, medical records)

All three effects produce findings. All three invoke on_postcondition_warn if provided. The difference is what happens to the tool result before the callback runs.

Side-Effect Classification

The redact and deny effects only apply to tools classified as READ or PURE. For WRITE and IRREVERSIBLE tools, both effects fall back to warn.

This is a deliberate design decision. When a read-only tool returns sensitive data, the output can be safely modified because nothing happened in the real world. But when a write tool has already executed -- the database row was inserted, the API was called, the file was written -- hiding the result only removes context the agent needs. The action already happened.

Side effectredact behaviordeny behavior
pureEnforced -- patterns replacedEnforced -- output suppressed
readEnforced -- patterns replacedEnforced -- output suppressed
writeFalls back to warnFalls back to warn
irreversibleFalls back to warnFalls back to warn

Tools not listed in the tools: section of your contract bundle default to irreversible. This is the conservative default -- if Edictum does not know a tool's side effects, it assumes the worst. This means redact and deny effects will fall back to warn for any unclassified tool.

To enable redact and deny enforcement, declare your tools:

tools:
  sql_query:
    side_effect: read
  read_file:
    side_effect: read
  get_weather:
    side_effect: pure
    idempotent: true
  update_record:
    side_effect: write
  deploy_service:
    side_effect: irreversible

See the YAML reference tool classifications for the full schema.

The output.text Selector

The output.text selector is only available in postconditions. It contains the stringified tool response -- whatever the tool returned, converted to a string.

Using output.text in a precondition is a validation error at load time. The tool has not run yet, so there is no output to inspect.

output.text works with all string operators:

# Substring match
output.text: { contains: "password" }

# Multiple substrings (any match)
output.text: { contains_any: ["password", "secret", "token"] }

# Regex
output.text: { matches: '\bpassword\s*=\s*\S+' }

# Multiple regex patterns (any match)
output.text:
  matches_any:
    - '\b\d{3}-\d{2}-\d{4}\b'
    - '\b[A-Z]{2}\d{2}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{0,2}\b'

# Starts with / ends with
output.text: { starts_with: "ERROR" }
output.text: { ends_with: "CONFIDENTIAL" }

For effect: redact, only matches and matches_any patterns are used for replacement. Other operators (contains, starts_with, etc.) trigger the finding but cannot drive targeted redaction -- the pipeline would not know what text to replace.

Postconditions can also use selectors beyond output.text. You can combine output inspection with argument or principal checks:

- id: pii-from-customer-queries
  type: post
  tool: sql_query
  when:
    all:
      - output.text:
          matches_any:
            - '\b\d{3}-\d{2}-\d{4}\b'
            - '\bName:\s+\w+\s+\w+\b'
      - principal.role: { not_in: [admin, compliance_officer] }
  then:
    effect: redact
    message: "PII in query results. Redacted for non-admin role."
    tags: [pii, rbac]

This contract only fires when the output contains PII and the principal is not an admin or compliance officer.

Findings

When a postcondition fires, it produces a finding -- a structured object describing what was detected.

FieldTypeDescription
typestrCategory: pii_detected, secret_detected, limit_exceeded, policy_violation
contract_idstrWhich contract produced this finding
fieldstrWhich selector triggered it (defaults to "output" for postconditions)
messagestrHuman-readable description from the contract's message field
metadatadictOptional extra context
Finding(
    type="pii_detected",
    contract_id="ssn-in-output",
    field="output.text",
    message="SSN pattern detected and redacted.",
    metadata={},
)

The type field is assigned by classify_finding(), which uses substring matching on the contract ID and message. A contract ID containing "pii" maps to pii_detected; one containing "secret" maps to secret_detected. Choose contract IDs carefully to get accurate classification.

Findings are frozen (immutable) after creation. The adapter wraps them in a PostCallResult:

FieldTypeDescription
resultAnyThe tool output, potentially modified by redact or deny
postconditions_passedboolTrue if all postconditions passed
findingslist[Finding]Findings from all failing postconditions
output_suppressedboolTrue when effect: deny fired on a READ/PURE tool

For the full finding API and remediation patterns, see findings.

Lifecycle Callback: on_postcondition_warn

The on_postcondition_warn callback fires whenever postconditions produce findings -- regardless of the effect. It receives the tool result (already modified by redact or deny if applicable) and the list of findings.

guard = Edictum.from_yaml("contracts.yaml")
adapter = LangChainAdapter(guard)

wrapper = adapter.as_tool_wrapper(
    on_postcondition_warn=lambda result, findings: handle(result, findings)
)

The callback signature is (result, findings) -> result. Whether the return value replaces the tool result depends on the adapter:

AdapterCallback replaces result
LangChainYes
AgnoYes
Semantic KernelYes
CrewAISide-effect only
Claude Agent SDKSide-effect only
OpenAI Agents SDKSide-effect only

For adapters where the callback is side-effect only, use it for logging, alerting, or metrics. The framework controls the result flow and the callback cannot substitute it.

If the callback raises an exception, it is caught and logged. The tool result is returned unchanged.

For the full callback API, see lifecycle callbacks. For adapter-specific behavior, see adapter comparison.

Enforce vs. Observe

Postconditions respect the mode setting like all contract types. In observe mode, the effect is always downgraded to a warning -- redact and deny produce findings but do not modify the output.

Modewarn behaviorredact behaviordeny behavior
enforceFinding produced, output unchangedPatterns replaced with [REDACTED]Output suppressed
observeFinding produced, output unchangedFinding produced, output unchangedFinding produced, output unchanged

Set observe mode per-contract to test a new postcondition against live traffic without affecting results:

- id: experimental-credit-card-scan
  type: post
  mode: observe
  tool: "*"
  when:
    output.text:
      matches: '\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b'
  then:
    effect: redact
    message: "Credit card pattern detected (observe mode)."
    tags: [pii, experimental]

This contract logs findings and emits audit events but does not redact anything. Promote it to enforce mode once you have confidence in the pattern.

For the full observe-to-enforce workflow, see observe mode.

YAML Examples

PII Detection with Multiple Patterns

- id: pii-comprehensive
  type: post
  tool: "*"
  when:
    output.text:
      matches_any:
        - '\b\d{3}-\d{2}-\d{4}\b'                                          # US SSN
        - '\b[A-Z]{2}\d{2}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{0,2}\b'   # IBAN
        - '\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b'                     # Credit card
        - '\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b'                      # Email (loose)
  then:
    effect: redact
    message: "PII pattern detected and redacted."
    tags: [pii, compliance]

API Key and Secret Scanning

- id: secrets-in-output
  type: post
  tool: "*"
  when:
    output.text:
      matches_any:
        - 'sk-[a-zA-Z0-9]{32,}'            # OpenAI-style keys
        - 'AKIA[0-9A-Z]{16}'               # AWS access key IDs
        - 'ghp_[A-Za-z0-9]{36}'            # GitHub personal access tokens
        - 'xoxb-[0-9]+-[0-9]+-[a-zA-Z0-9]+' # Slack bot tokens
        - '-----BEGIN (RSA |EC )?PRIVATE KEY-----'  # Private keys
  then:
    effect: redact
    message: "Secret or credential detected and redacted."
    tags: [secrets, dlp]

FERPA Compliance -- Full Suppression

- id: student-records
  type: post
  tool: search_records
  when:
    output.text:
      matches_any:
        - '\b(504\s*Plan|IEP|accommodation)\b'
        - '\bstudent\s+id\s*:\s*\d+'
        - '\bgrade\s*:\s*[A-F][+-]?\b'
  then:
    effect: deny
    message: "Student records cannot be returned to this agent."
    tags: [ferpa, confidential]

Conditional Postcondition with Principal Check

- id: pii-non-admin
  type: post
  tool: sql_query
  when:
    all:
      - output.text:
          matches: '\b\d{3}-\d{2}-\d{4}\b'
      - principal.role: { not_in: [admin, dba, compliance_officer] }
  then:
    effect: deny
    message: "PII in query results denied for role '{principal.role}'."
    tags: [pii, rbac]

Warn with Custom Remediation

- id: internal-urls
  type: post
  tool: "*"
  when:
    output.text:
      matches: 'https?://internal\.[a-z]+\.corp/'
  then:
    effect: warn
    message: "Internal URL detected in output."
    tags: [data-leak]
def scrub_internal_urls(result, findings):
    import re
    text = str(result)
    return re.sub(r'https?://internal\.[a-z]+\.corp/\S*', '[INTERNAL URL REMOVED]', text)

wrapper = adapter.as_tool_wrapper(on_postcondition_warn=scrub_internal_urls)

Next Steps

Last updated on

On this page