Postconditions
Postconditions inspect tool output after execution -- detecting PII, secrets, and sensitive content with three enforcement effects: warn, redact, and deny.
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
| Effect | What happens to the output | When to use |
|---|---|---|
warn | Unchanged -- your callback remediates | Custom remediation, conditional logic, framework-specific handling |
redact | Matched patterns replaced with [REDACTED] | Structured tokens (API keys, SSNs) in otherwise useful output |
deny | Entire 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 effect | redact behavior | deny behavior |
|---|---|---|
pure | Enforced -- patterns replaced | Enforced -- output suppressed |
read | Enforced -- patterns replaced | Enforced -- output suppressed |
write | Falls back to warn | Falls back to warn |
irreversible | Falls back to warn | Falls 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: irreversibleSee 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.
| Field | Type | Description |
|---|---|---|
type | str | Category: pii_detected, secret_detected, limit_exceeded, policy_violation |
contract_id | str | Which contract produced this finding |
field | str | Which selector triggered it (defaults to "output" for postconditions) |
message | str | Human-readable description from the contract's message field |
metadata | dict | Optional 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:
| Field | Type | Description |
|---|---|---|
result | Any | The tool output, potentially modified by redact or deny |
postconditions_passed | bool | True if all postconditions passed |
findings | list[Finding] | Findings from all failing postconditions |
output_suppressed | bool | True 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:
| Adapter | Callback replaces result |
|---|---|
| LangChain | Yes |
| Agno | Yes |
| Semantic Kernel | Yes |
| CrewAI | Side-effect only |
| Claude Agent SDK | Side-effect only |
| OpenAI Agents SDK | Side-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.
| Mode | warn behavior | redact behavior | deny behavior |
|---|---|---|---|
| enforce | Finding produced, output unchanged | Patterns replaced with [REDACTED] | Output suppressed |
| observe | Finding produced, output unchanged | Finding produced, output unchanged | Finding 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
- Preconditions -- contracts that check before execution
- Session contracts -- rate limits and cumulative state across turns
- Sandbox contracts -- allowlist-based enforcement for file paths, commands, and domains
- YAML reference -- full contract syntax and schema
- Postcondition effects -- effect behavior reference
- Findings -- the structured output from postconditions
- Postcondition design -- choosing effects and the detect-remediate pattern
- Lifecycle callbacks --
on_postcondition_warnand other callbacks
Last updated on
Preconditions
Preconditions evaluate before a tool executes. If the condition matches, the call is denied and the tool never runs.
Session Contracts
Stateful contracts that cap tool calls, attempts, and per-tool executions across an agent session -- catching runaway loops and resource exhaustion before they escalate.