Skip to main content
Each guard verifies a specific aspect of legal output. Guards are labeled DETERMINISTIC, MIXED, or PARTIAL / HEURISTIC to indicate the strength of the underlying check.
  • DETERMINISTIC guards return reproducible, provable results for supported, structured inputs.
  • MIXED guards run a deterministic computation (date arithmetic, Z3 SAT/UNSAT) over parsed inputs. The computation is provable; the parsed lookup that feeds it is not authority proof.
  • PARTIAL / HEURISTIC guards apply structural or rule-based checks. A passing result does not prove that the underlying legal claim is correct — only that it matched a supported pattern.
When a claim falls outside a guard’s supported boundary, the guard fails closed: it rejects or marks the claim unverified rather than accepting it.

Verification traces and evidence types

As of v0.4.0, every guard returns a verification_trace — an ordered list of VerificationStep records. Each step is tagged with an evidence_type, and VerificationStep.is_proven() returns True only for DETERMINISTIC steps.
evidence_typeMeaningis_proven()
DETERMINISTICProven by math/logic (Z3, date arithmetic, exact compare)True
PARSEDRead/matched from structure or lookup — not authority proofFalse
INFERREDPattern/keyword derived — may be wrong on edge casesFalse
HEURISTICApproximate/statistical signalFalse
UNSUPPORTEDGuard cannot model this input — fail-closedFalse
from qwed_legal import trace_to_dict

# Any guard result exposes .verification_trace
serialized = trace_to_dict(result.verification_trace)  # JSON-safe list of dicts
# each entry carries an explicit "is_proven" flag
Use trace_to_dict() to export a trace into audit logs. Non-serializable input values are stringified (no silent data loss).

1. DeadlineGuard

Status: DETERMINISTIC Purpose: Verify date calculations in contracts for structured, unambiguous inputs.

The problem

LLMs frequently miscalculate deadlines:
  • Confuse business days vs calendar days
  • Ignore leap years
  • Forget jurisdiction-specific holidays

The solution

from qwed_legal import DeadlineGuard

guard = DeadlineGuard(country="US", state="CA")

result = guard.verify(
    signing_date="2026-01-15",
    term="30 business days",
    claimed_deadline="2026-02-14"
)

print(result.verified)           # False
print(result.computed_deadline)  # 2026-02-27
print(result.difference_days)    # 13

Parameters

signing_date
str
required
The date the contract was signed (ISO format or natural language).
term
str
required
The term description (e.g., “30 days”, “30 business days”, “2 weeks”, “3 months”, “1 year”).
claimed_deadline
str
required
The deadline claimed by the LLM.
tolerance_days
int
default:"0"
Allow +/- this many days when verifying the deadline. Useful for accommodating minor rounding differences.

Response fields

FieldTypeDescription
verifiedboolWhether the claimed deadline matches the computed deadline. Always False when is_computable is False.
signing_datedatetimeParsed signing date
claimed_deadlinedatetimeThe deadline claimed by the LLM
computed_deadlineOptional[datetime]The correct deadline computed by the guard. None when the term is ambiguous and the guard fails closed.
term_parsedstrThe original term string, or "ERROR" when date parsing fails
difference_daysOptional[int]Absolute difference in days between claimed and computed. None when no deadline could be computed.
messagestrHuman-readable verification message. Starts with ⚠️ UNVERIFIABLE when the term is ambiguous.
is_computableboolTrue when the term parsed into an explicit quantity and unit. False when the input was ambiguous, unparseable, or the dates were invalid.
verification_modestrAlways "SYMBOLIC" for legal verification

Fail-closed behavior on ambiguous terms

DeadlineGuard does not invent deadlines from vague legal language. If the term cannot be parsed into a deterministic (quantity, unit) pair, the guard returns a fail-closed result with verified=False, is_computable=False, and computed_deadline=None. A term is treated as UNVERIFIABLE when either:
  • It contains no numeric quantity (e.g., "forthwith", "promptly after notice", "within a reasonable period", "as soon as practicable", "without undue delay").
  • It contains a number but no recognized time unit (e.g., "30" alone, "15 intervals").
Recognized time units are day/calendar, week, month, and year, with optional business/working/work qualifiers for business-day arithmetic.
from qwed_legal import DeadlineGuard

guard = DeadlineGuard()

# Ambiguous term — no numeric quantity
result = guard.verify(
    signing_date="2026-01-01",
    term="within a reasonable period",
    claimed_deadline="2026-01-31",
)

print(result.verified)          # False
print(result.is_computable)     # False
print(result.computed_deadline) # None
print(result.difference_days)   # None
print(result.message)
# ⚠️ UNVERIFIABLE: Term 'within a reasonable period' does not contain a
# provable time quantity and unit. Cannot compute a deterministic deadline...
Always check result.is_computable before relying on computed_deadline or difference_days. Ambiguous legal language requires human legal interpretation and is never silently coerced into a 30-day default.
Date parsing failures are also fail-closed: if signing_date or claimed_deadline cannot be parsed, the guard returns verified=False and is_computable=False.

Features

FeatureDescription
Business vs CalendarAutomatically detects “business days” vs “days”
Holiday Support200+ countries via python-holidays
Leap YearsHandles Feb 29 correctly
Natural LanguageParses “2 weeks”, “3 months”, “1 year”
Fail-closed parsingRejects ambiguous legal language (“reasonable period”, “promptly”) instead of inventing a deadline

Fail-closed behavior on ambiguous terms

DeadlineGuard only computes a deadline when the term contains both an explicit numeric quantity and a recognized time unit (day, business day, calendar day, week, month, year). When either is missing, the guard fails closed: it returns verified=False, is_computable=False, and a computed_deadline of None instead of guessing a default. This protects against silent acceptance of subjective legal language such as "within a reasonable period", "promptly", "as soon as practicable", "without undue delay", or "forthwith". Resolving these terms requires human legal interpretation.
from qwed_legal import DeadlineGuard

guard = DeadlineGuard(country="US")

result = guard.verify(
    signing_date="2026-01-01",
    term="within a reasonable period",
    claimed_deadline="2026-01-31",
)

print(result.verified)           # False
print(result.is_computable)      # False
print(result.computed_deadline)  # None
print(result.difference_days)    # None
print(result.message)
# ⚠️ UNVERIFIABLE: Term 'within a reasonable period' does not contain a
# provable time quantity and unit. Cannot compute a deterministic deadline.
# Ambiguous legal language (e.g., 'reasonable period', 'promptly') requires
# human legal interpretation.
Always check is_computable before relying on computed_deadline or difference_days. When is_computable is False, route the contract clause to a human reviewer.

Calculate business days between dates

guard = DeadlineGuard(country="US")

business_days = guard.calculate_business_days_between(
    start_date="2026-01-15",
    end_date="2026-02-14"
)

print(business_days)  # Number of business days excluding weekends and holidays

2. LiabilityGuard

Status: DETERMINISTIC Purpose: Verify liability cap and indemnity calculations for supported numeric inputs.

The problem

LLMs get percentage math wrong:
  • “200% of 5M=5M = 15M” ❌ (Should be $10M)
  • Float precision errors on large amounts
  • Tiered liability miscalculations

Constructor parameters

tolerance_percent
float
default:"0.01"
Tolerance for floating-point comparison as a percentage. For example, 0.01 means 0.01% tolerance. Adjust for stricter or more lenient verification.

The solution

from qwed_legal import LiabilityGuard

guard = LiabilityGuard()

result = guard.verify_cap(
    contract_value=5_000_000,
    cap_percentage=200,
    claimed_cap=15_000_000
)

print(result.verified)      # False
print(result.computed_cap)  # 10,000,000
print(result.difference)    # 5,000,000

verify_cap parameters

contract_value
float
required
Total value of the contract.
cap_percentage
float
required
Liability cap as a percentage (e.g., 200 for 200%).
claimed_cap
float
required
The cap amount claimed by the LLM.

Response fields

FieldTypeDescription
verifiedboolWhether the claimed cap matches the computed cap
contract_valueDecimalThe contract value used
cap_percentageDecimalThe percentage used
claimed_capDecimalThe cap claimed by the LLM
computed_capDecimalThe correct cap computed by the guard
differenceDecimalAbsolute difference between claimed and computed
messagestrHuman-readable verification message

Additional methods

# Tiered liability
result = guard.verify_tiered_liability(
    tiers=[
        {"base": 1_000_000, "percentage": 100},
        {"base": 500_000, "percentage": 50},
    ],
    claimed_total=1_250_000  # ✅ Correct: 1M + 250K
)

# Indemnity limit (3x annual fee)
result = guard.verify_indemnity_limit(
    annual_fee=100_000,
    multiplier=3,
    claimed_limit=300_000  # ✅ Correct
)

3. ClauseGuard

Status: PARTIAL / HEURISTIC Purpose: Detect a limited set of contradictory clauses using text heuristics, with optional Z3-based satisfiability checks. A “consistent” result is not a proof of full contractual consistency.

The problem

LLMs miss logical contradictions:
  • “Seller may terminate with 30 days notice”
  • “Neither party may terminate before 90 days”
These clauses conflict for days 30-90!

The solution

The primary check_consistency() method uses text heuristics to detect conflicts. For formal logic verification, use verify_using_z3().
from qwed_legal import ClauseGuard

guard = ClauseGuard()

result = guard.check_consistency([
    "Seller may terminate with 30 days notice",
    "Neither party may terminate before 90 days",
    "Seller may terminate immediately upon breach"
])

print(result.consistent)  # False
print(result.conflicts)
# [(0, 1, "Termination notice (30 days) conflicts with minimum term (90 days)")]

Detection types

Conflict TypeDescription
TerminationNotice period vs minimum term
Permission/Prohibition”May” vs “May not”
ExclusivityMultiple exclusive rights

Z3-based verification

When you need to define precise logical constraints, verify_using_z3() only accepts explicit Z3 BoolRef expressions — it does not parse free-form text. You must model the legal meaning yourself.
from z3 import Bool, Implies, Not
from qwed_legal import ClauseGuard

guard = ClauseGuard()

can_terminate_early = Bool("can_terminate_early")
min_term_satisfied = Bool("min_term_satisfied")

result = guard.verify_using_z3([
    Implies(can_terminate_early, Not(min_term_satisfied)),
    can_terminate_early,
    min_term_satisfied,
])

print(result.consistent)  # False - constraints are unsatisfiable
print(result.message)     # "CONTRADICTION: Provided Z3 constraints are unsatisfiable..."

Fail-closed behavior

verify_using_z3() is fail-closed: it returns consistent=False for any input it cannot prove satisfiable. The following inputs are rejected as UNVERIFIABLE rather than silently passing:
InputResultMessage prefix
Empty list []consistent=FalseUNVERIFIABLE: verify_using_z3 requires explicit Z3 constraint expressions...
Non-BoolRef values (e.g. strings, ints)consistent=FalseUNVERIFIABLE: verify_using_z3 only accepts explicit Z3 Boolean expressions...
Z3 returns unknownconsistent=FalseUNVERIFIABLE: Z3 returned unknown for the provided constraints.
Satisfiable (sat)consistent=TrueVERIFIED: Provided Z3 constraints are satisfiable.
Unsatisfiable (unsat)consistent=FalseCONTRADICTION: Provided Z3 constraints are unsatisfiable...
Passing raw strings or other non-Z3 values is rejected. The guard reports the 1-based position of each invalid constraint so callers can identify the offending entries.

4. CitationGuard

Status: PARTIAL / HEURISTIC Purpose: Validate that legal citations match a supported format. CitationGuard does not prove that a cited authority exists or is controlling — it only checks structural shape against supported reporters.

The problem

The Mata v. Avianca scandal: Lawyers used ChatGPT, which cited 6 fake court cases. They were fined $5,000 and sanctioned.

The solution

from qwed_legal import CitationGuard

guard = CitationGuard()

# Valid format
result = guard.verify("Brown v. Board of Education, 347 U.S. 483 (1954)")
print(result.format_valid)  # True
print(result.status)        # "unverifiable_authority" - format ok, authority unknown
print(result.verified)      # False - authority is never proven by format
print(result.parsed_components)
# {'volume': 347, 'reporter': 'U.S.', 'page': '483'}

# Invalid format (fake reporter)
result = guard.verify("Smith v. Jones, 999 FAKE 123 (2020)")
print(result.format_valid)  # False
print(result.status)        # "format_invalid"
print(result.issues)        # ["Unknown reporter"]
CitationGuard checks format only. result.verified is always False, and a format-valid citation has status="unverifiable_authority". A well-formatted citation can still refer to a case that does not exist — confirming authority requires an external legal database, which this guard does not have. Use format_valid to check shape; never treat it as proof of authority.

Supported citation patterns

PatternFormatExample
US Supreme Courtvolume U.S. page347 U.S. 483
US Federalvolume F./F.2d/F.3d page500 F.3d 120
UK Neutral[year] court number[2023] UKSC 10
India AIRAIR year court pageAIR 2020 SC 100

Batch verification

result = guard.verify_batch([
    "Brown v. Board, 347 U.S. 483 (1954)",
    "Fake v. Case, 999 X.Y.Z. 123",
])

print(result.total)    # 2
print(result.valid)    # 1
print(result.invalid)  # 1

Statute citations

result = guard.check_statute_citation("42 U.S.C. § 1983")
print(result.format_valid)  # True (format only — not proof the statute exists or applies)
print(result.parsed_components)
# {'title': 42, 'code': 'U.S.C.', 'section': '1983'}

5. JurisdictionGuard

Status: PARTIAL / HEURISTIC Purpose: Apply structured checks around governing law and forum selection clauses for modeled combinations. Results should not be treated as authoritative legal opinions on choice-of-law conflicts.

The problem

LLMs miss jurisdiction conflicts:
  • Governing law in one country, forum in another
  • Missing CISG applicability warnings
  • Cross-border legal system mismatches

The solution

from qwed_legal import JurisdictionGuard

guard = JurisdictionGuard()

result = guard.verify_choice_of_law(
    parties_countries=["US", "UK"],
    governing_law="Delaware",
    forum="London"
)

print(result.verified)   # False - mismatch detected
print(result.conflicts)  # ["Governing law 'Delaware' (US state) but forum 'London' is non-US..."]

Parameters

parties_countries
list[str]
required
List of ISO country codes for contract parties (e.g., ["US", "UK"]).
governing_law
str
required
The stated governing law — can be a country code or US state name/abbreviation (e.g., "Delaware", "DE", "UK").
forum
str
The stated forum or venue for dispute resolution.
jurisdiction_type
JurisdictionType
default:"JurisdictionType.EXCLUSIVE"
Type of jurisdiction clause. Accepts JurisdictionType.EXCLUSIVE, JurisdictionType.NON_EXCLUSIVE, or JurisdictionType.HYBRID.

Features

FeatureDescription
Choice of LawValidates governing law makes sense for parties
Forum SelectionChecks forum vs governing law alignment
CISG DetectionWarns about international sale of goods conventions
Convention CheckVerifies Hague, NY Convention applicability
Legal System MismatchDetects cross-border Common Law vs Civil Law conflicts

Verify forum selection

Use verify_forum_selection to validate a forum independently, with optional contract value threshold checks for US federal court diversity jurisdiction:
result = guard.verify_forum_selection(
    forum="Delaware",
    contract_value=50_000,
    parties_countries=["US", "DE"]
)

print(result.verified)   # True
print(result.warnings)   # ["Contract value $50,000 may not meet diversity jurisdiction threshold..."]

Convention check

result = guard.check_convention_applicability(
    parties_countries=["US", "DE"],
    convention="CISG"
)
print(result.verified)  # True - both are CISG members

6. StatuteOfLimitationsGuard

Status: MIXED Purpose: Compute claim limitation periods for supported jurisdictions and claim types using rule tables. The limitation-period lookup is PARSED; the date arithmetic over it (expiration, days remaining) is DETERMINISTIC. Coverage is limited to the modeled jurisdictions and claim types listed below.

The problem

LLMs don’t track jurisdiction-specific limitation periods:
  • California breach of contract: 4 years
  • New York breach of contract: 6 years
  • Different periods for negligence, fraud, etc.

The solution

from qwed_legal import StatuteOfLimitationsGuard

guard = StatuteOfLimitationsGuard()

result = guard.verify(
    claim_type="breach_of_contract",
    jurisdiction="California",
    incident_date="2020-01-15",
    filing_date="2026-06-01"
)

print(result.verified)          # False - 4 year limit exceeded!
print(result.expiration_date)   # 2024-01-15
print(result.days_remaining)    # -867 (negative = expired)

Fail-closed behavior

StatuteOfLimitationsGuard is fail-closed: it never fabricates a limitation period for jurisdictions or claim types that are not in its rule tables.
  • Exact match only. Jurisdiction lookup uses exact string equality (case-insensitive, trimmed). Partial matches such as "CALIF" for "CALIFORNIA" or "NEW" for "NEW YORK" are rejected.
  • Unknown jurisdiction → unverifiable. If the jurisdiction is not in the supported list, verify() returns verified=False with jurisdiction_matched=False, all date and period fields set to None, and a message listing the supported jurisdictions.
  • Unknown claim type → unverifiable. If the jurisdiction is supported but the claim type is not modeled for it, verify() returns verified=False with claim_type_matched=False, and a message listing the supported claim types for that jurisdiction.
result = guard.verify(
    claim_type="breach_of_contract",
    jurisdiction="Mars",
    incident_date="2020-01-15",
    filing_date="2026-06-01"
)

print(result.verified)              # False
print(result.jurisdiction_matched)  # False
print(result.limitation_period_years)  # None
print(result.message)
# ⚠️ UNVERIFIABLE: Jurisdiction 'Mars' is not in the supported jurisdiction list.
# Cannot determine applicable statute of limitations. Supported: AUSTRALIA, CALIFORNIA, ...

Parameters

claim_type
str
required
Type of legal claim (e.g., "breach_of_contract", "negligence", "fraud"). Must exactly match one of the supported claim types for the given jurisdiction; unknown values produce an unverifiable result.
jurisdiction
str
required
State or country name (e.g., "California", "New York", "UK"). Matched case-insensitively against the supported jurisdictions list — partial or substring matches are not accepted.
incident_date
str
required
Date the incident occurred (ISO format).
filing_date
str
required
Date the claim was or will be filed (ISO format).
claimed_within_period
bool
Optional LLM claim to verify. When provided, the guard checks whether the LLM’s assertion (within/outside period) matches the computed result.

StatuteResult fields

verified
bool
True only when the jurisdiction and claim type are recognized and the filing falls within the limitation period (and matches claimed_within_period, if supplied).
claim_type
str
The claim type passed in (echoed back).
jurisdiction
str
The jurisdiction passed in (echoed back).
incident_date
Optional[datetime]
Parsed incident date, or None if date parsing failed.
filing_date
Optional[datetime]
Parsed filing date, or None if date parsing failed.
limitation_period_years
Optional[float]
Limitation period applied, or None if the jurisdiction or claim type is unknown.
expiration_date
Optional[datetime]
Computed expiration date, or None if no limitation period could be determined.
days_remaining
Optional[int]
Days between filing date and expiration (negative if expired), or None if no limitation period could be determined.
message
str
Human-readable result. Unverifiable results are prefixed with ⚠️ UNVERIFIABLE: and list the supported jurisdictions or claim types.
jurisdiction_matched
bool
default:"True"
False when the jurisdiction is not in the supported list.
claim_type_matched
bool
default:"True"
False when the claim type is not modeled for the given jurisdiction.

Supported jurisdictions

12 jurisdictions are supported with periods for 10 claim types.
JurisdictionBreach of ContractNegligenceFraud
California4 years2 years3 years
New York6 years3 years6 years
Texas4 years2 years4 years
Delaware3 years2 years3 years
Florida5 years4 years4 years
Illinois5 years2 years5 years
UK/England6 years6 years6 years
Germany3 years3 years10 years
France5 years5 years5 years
Australia6 years6 years6 years
India3 years3 years3 years
Canada2 years2 years6 years

Supported claim types

breach_of_contract, breach_of_warranty, negligence, professional_malpractice, fraud, personal_injury, property_damage, employment, product_liability, defamation

Fail-closed on unknown jurisdictions and claim types

StatuteOfLimitationsGuard only computes limitation periods for the jurisdictions and claim types it has explicit rules for. Anything outside that table fails closed instead of returning a fabricated period.
  • Exact jurisdiction match only. Inputs are uppercased and trimmed before lookup. Substring matches like "CALIF" no longer resolve to "CALIFORNIA", and a misspelled or unsupported jurisdiction never falls back to a generic default rule table.
  • Exact claim type match only. Claim types are lowercased with spaces converted to underscores before lookup. Unknown claim types no longer silently default to a 3-year period.
  • UNVERIFIABLE result. When either lookup fails, verify() returns a StatuteResult with verified=False, all date and period fields set to None, and a message that begins with ⚠️ UNVERIFIABLE and lists the supported values.
  • New flags. StatuteResult exposes jurisdiction_matched: bool and claim_type_matched: bool so callers can distinguish “claim is time-barred” from “we cannot determine the limit”.
result = guard.verify(
    claim_type="breach_of_contract",
    jurisdiction="Atlantis",
    incident_date="2023-01-15",
    filing_date="2026-06-01",
)

print(result.verified)              # False
print(result.jurisdiction_matched)  # False
print(result.claim_type_matched)    # False
print(result.limitation_period_years)  # None
print(result.expiration_date)       # None
print(result.message)
# ⚠️ UNVERIFIABLE: Jurisdiction 'Atlantis' is not in the supported
# jurisdiction list. Cannot determine applicable statute of limitations.
# Supported: AUSTRALIA, CALIFORNIA, CANADA, DELAWARE, FLORIDA, FRANCE,
# GERMANY, ILLINOIS, INDIA, NEW YORK, TEXAS, UK.
result = guard.verify(
    claim_type="cybersquatting",
    jurisdiction="California",
    incident_date="2023-01-15",
    filing_date="2026-06-01",
)

print(result.jurisdiction_matched)  # True
print(result.claim_type_matched)    # False
# Message lists the supported claim types for California.
This is a breaking change. get_limitation_period() now returns Optional[float] and StatuteResult date and period fields are Optional. Callers that previously assumed a numeric result must handle None and treat unverifiable inputs as a hard failure rather than a passing check. See the changelog entry for migration notes.

Get limitation period

Look up the limitation period for a specific claim type and jurisdiction without performing a full verification. Returns None when the jurisdiction or claim type is not supported:
years = guard.get_limitation_period("fraud", "Germany")
print(years)  # 10.0

unknown = guard.get_limitation_period("fraud", "Atlantis")
print(unknown)  # None

unsupported_claim = guard.get_limitation_period("cybersquatting", "California")
print(unsupported_claim)  # None

Compare jurisdictions

compare_jurisdictions() returns Dict[str, Optional[float]]. Unsupported jurisdictions map to None so you can surface them in the UI rather than mixing them with real periods:
comparison = guard.compare_jurisdictions(
    "breach_of_contract",
    ["California", "New York", "Delaware", "Atlantis"]
)
# {'California': 4.0, 'New York': 6.0, 'Delaware': 3.0, 'Atlantis': None}
Unsupported jurisdictions map to None rather than a default value.

7. IRACGuard

Status: PARTIAL / HEURISTIC Purpose: Check that legal reasoning follows the IRAC framework (Issue, Rule, Application, Conclusion). IRACGuard verifies structure and surface-level consistency only — it is not a proof of correct legal reasoning.

The problem

LLMs produce legal advice that lacks structured reasoning:
  • Missing clear identification of the legal issue
  • No citation of applicable rules or statutes
  • Conclusions without proper application of law to facts

The solution

from qwed_legal import IRACGuard

guard = IRACGuard()

llm_output = """
Issue: Whether the defendant breached the employment contract.
Rule: Under California Labor Code § 2922, employment is presumed at-will.
Application: The defendant terminated employment without the 30-day notice 
required by the contract, which modified the at-will presumption.
Conclusion: The defendant breached the employment contract.
"""

result = guard.verify_structure(llm_output)

print(result["structure_valid"])  # True - all four IRAC sections present and coherent
print(result["status"])           # "unverifiable_reasoning"
print(result["verified"])         # False - reasoning correctness is never proven
print(result["components"])       # {'issue': '...', 'rule': '...', 'application': '...', 'conclusion': '...'}
result["verified"] is always False for IRACGuard — structural validity is not proof of correct legal reasoning. Branch on structure_valid and status instead, and route unverifiable_reasoning results to a human reviewer.

Detection types

CheckDescription
StructureVerifies all 4 IRAC components are present
Logical DisconnectDetects when Application doesn’t reference the Rule
Missing StepsIdentifies which IRAC components are missing

Error response

result = guard.verify_structure("The defendant should pay damages.")

print(result["verified"])         # False
print(result["status"])           # "structure_invalid"
print(result["structure_valid"])  # False
print(result["error"])
# "STRUCTURE INVALID: Missing IRAC section(s): issue, rule, application,
#  conclusion. Legal analysis must contain Issue, Rule, Application, and Conclusion."
print(result["missing"])          # ['issue', 'rule', 'application', 'conclusion']
IRACGuard checks structure and surface-level coherence only. A passing result has status="unverifiable_reasoning" — it confirms the four IRAC sections are present and structurally coherent, not that the cited rule exists or that the reasoning is legally sound. In the verification_trace, structure steps are INFERRED and the reasoning conclusion is UNSUPPORTED — never DETERMINISTIC.

8. FairnessGuard

Status: HEURISTIC / FAIL-CLOSED Purpose: Apply a counterfactual consistency check that flags when output changes after protected attributes are swapped. This is a heuristic signal, not a fairness proof. Requires an external LLM client.
As of v0.4.0, FairnessGuard never returns verified=True (issue #18). Legal fairness cannot be proven by text substitution and string equality, so the guard does not claim it. A consistent outcome is reported as UNVERIFIABLE_FAIRNESS; a differing outcome is a HEURISTIC_BIAS_SIGNAL that warrants human review. This is a breaking change from earlier versions that returned FAIRNESS_VERIFIED.

The problem

AI legal systems can exhibit bias based on protected attributes:
  • Different sentencing recommendations based on gender
  • Inconsistent contract assessments based on party names
  • Discriminatory loan approval reasoning
A single counterfactual swap with string-equality comparison cannot prove fairness — equivalent outcomes may differ in wording, and a single swap does not cover all relevant dimensions. So the result is always treated as a signal, never a pass.

The solution

from qwed_legal import FairnessGuard

# Requires an LLM client for counterfactual generation
guard = FairnessGuard(llm_client=my_llm)

result = guard.verify_decision_fairness(
    original_prompt="Should John Smith receive parole given his rehabilitation record?",
    original_decision="Parole recommended based on positive rehabilitation.",
    protected_attribute_swap={"John": "Jane", "his": "her"},
)

print(result["verified"])  # Always False — fairness is never "proven"
print(result["status"])    # "UNVERIFIABLE_FAIRNESS"
print(result.get("risk"))  # "HEURISTIC_BIAS_SIGNAL" when outcomes differ

How it works

  1. Input validation — Rejects an empty swap, non-string values, and keys that collide when lowercased (fail-closed ValueError or UNVERIFIABLE_FAIRNESS).
  2. Counterfactual generation — Swaps protected attributes (names, pronouns) in a single pass while preserving case.
  3. Re-evaluation — Runs the modified prompt through the LLM.
  4. Heuristic comparison — Compares outcomes by string equality. Consistency is reported as UNVERIFIABLE_FAIRNESS (not proof); a difference is a HEURISTIC_BIAS_SIGNAL.

Response fields

FieldTypeDescription
verifiedboolAlways False. Fairness is never proven by this guard.
statusstrUNVERIFIABLE_FAIRNESS or LLM_GENERATION_FAILED
riskstrHEURISTIC_BIAS_SIGNAL (outcomes differed) or LLM_GENERATION_FAILED. Present only when applicable.
messagestrExplanation of the result
variancedictPresent only when outcomes differ — contains original and counterfactual decisions
verification_tracelistVerificationStep records; steps are HEURISTIC/UNSUPPORTED, never DETERMINISTIC

Outcomes

status / riskMeaning
UNVERIFIABLE_FAIRNESS (consistent)Outcomes matched under one swap — not proof of fairness
HEURISTIC_BIAS_SIGNAL (differing)Outcome changed under swap — route to human review
UNVERIFIABLE_FAIRNESS (empty swap)No protected attributes provided — fail-closed
LLM_GENERATION_FAILEDThe LLM client returned None for the counterfactual prompt
FairnessGuard requires an LLM client at initialization. Without it, verify_decision_fairness() raises a ValueError. Malformed protected_attribute_swap input (non-string values, case-colliding keys) also raises a ValueError — the guard never silently processes ambiguous input.
Migration from earlier versions: if your code branched on result["verified"] == True or status == "FAIRNESS_VERIFIED", update it to treat the output as a signal. Consume status / risk and route HEURISTIC_BIAS_SIGNAL (and consistent-but-unverifiable) results to a human reviewer.

9. ContradictionGuard

Status: MIXED Purpose: Detect logical contradictions between modeled clauses using a Z3 constraint solver. The SAT/UNSAT result is DETERMINISTIC; clause categorization from text is PARSED. Coverage is limited to the supported clause categories below — a “consistent” result is not a proof of full contract consistency, and unmodeled clauses fail closed.

The problem

Contracts can contain mathematically impossible combinations:
  • “Liability capped at 10,000"+"Minimumpenaltyof10,000" + "Minimum penalty of 50,000”
  • “Term is exactly 12 months” + “Minimum duration of 24 months”
Text-based heuristics (ClauseGuard) miss these formal logic conflicts.

The solution

from qwed_legal import ContradictionGuard, Clause

guard = ContradictionGuard()

clauses = [
    Clause(id="1", text="Liability capped at 10000", category="LIABILITY", value=10000),
    Clause(id="2", text="Penalty shall be 50000", category="LIABILITY", value=50000),
]

result = guard.verify_consistency(clauses)

print(result["verified"])  # False
print(result["message"])   # "❌ LOGIC CONTRADICTION: Clauses are mutually exclusive..."

Clause structure

The Clause dataclass requires:
FieldTypeDescription
idstrUnique clause identifier
textstrHuman-readable clause text
categorystrDURATION, LIABILITY, or TERMINATION
valueintNormalized numeric value (days, dollars, etc.)

Supported categories

CategoryDetects
DURATIONConflicting term lengths (exact vs min/max)
LIABILITYCap vs penalty contradictions

Z3 vs ClauseGuard

FeatureClauseGuardContradictionGuard
InputRaw text stringsStructured Clause objects
MethodText heuristicsZ3 SMT Solver
DetectsPermission conflictsMathematical impossibilities
Use CaseQuick checksFormal verification

10. ProvenanceGuard

Status: DETERMINISTIC Purpose: Verify AI-generated content carries proper provenance metadata and disclosure markers. All checks are deterministic (SHA-256 hashing, regex pattern matching, datetime validation).

The problem

AI transparency regulations (California CAITA 2026, EU AI Act Article 50) require AI-generated legal content to carry proper attribution. Without verification:
  • Content may lack required AI-generation disclosures
  • Provenance metadata can be incomplete or tampered with
  • Unauthorized models may generate legal documents without audit trails

The solution

from qwed_legal import ProvenanceGuard

guard = ProvenanceGuard(
    require_disclosure=True,
    require_human_review=False,
    allowed_models=["gpt-4", "claude-3-opus"]
)

content = "This AI-generated document reviews the contract terms..."
provenance = {
    "content_hash": "a1b2c3...",  # SHA-256 of content
    "model_id": "gpt-4",
    "generation_timestamp": "2026-03-24T12:00:00+00:00",
}

result = guard.verify_provenance(content, provenance)

print(result["verified"])        # True or False
print(result["checks_passed"])   # ["metadata_completeness", "hash_integrity", ...]
print(result["checks_failed"])   # []
print(result["risk"])            # "" if verified, e.g. "CONTENT_TAMPERED" if not

Verification checks

ProvenanceGuard runs up to six checks. The first three always run; the last three are configurable.
CheckDescriptionAlways runs
Metadata completenesscontent_hash, model_id, and generation_timestamp are present and non-emptyYes
Hash integritySHA-256 of the content matches content_hash in provenanceYes
Timestamp validityISO-8601 format, not in the futureYes
Disclosure complianceContent includes an AI-generation disclosure statementIf require_disclosure=True
Model allowlistmodel_id is in the approved listIf allowed_models is set
Human reviewhuman_reviewed is True in provenanceIf require_human_review=True

Constructor parameters

require_disclosure
bool
default:"True"
Require AI disclosure text in the content (e.g., “AI-generated”, “produced by AI”).
require_human_review
bool
default:"False"
Require human_reviewed=True in provenance metadata.
allowed_models
list[str] | None
default:"None"
Allowlist of model IDs. None allows all models; an empty list denies all.

Generating provenance records

You can also use ProvenanceGuard to generate provenance metadata:
from qwed_legal import ProvenanceGuard

guard = ProvenanceGuard()

record = guard.generate_provenance(
    content="This AI-generated contract summary...",
    model_id="gpt-4",
    disclosure_text="This document was generated by AI.",
    human_reviewed=True,
    reviewer_id="lawyer-42"
)

print(record.content_hash)           # SHA-256 hash
print(record.generation_timestamp)   # ISO-8601 UTC timestamp
print(record.human_reviewed)         # True

ProvenanceRecord fields

FieldTypeDescription
content_hashstrSHA-256 hash of the AI-generated content
model_idstrIdentifier of the model that generated the content
generation_timestampstrISO-8601 timestamp of generation
disclosure_textstrHuman-readable AI disclosure statement
human_reviewedboolWhether a human has reviewed the content
reviewer_idstr | NoneIdentifier of the human reviewer

Risk classifications

When verification fails, the risk field indicates the type of failure:
RiskTrigger
CONTENT_TAMPEREDHash mismatch between content and content_hash
INCOMPLETE_PROVENANCERequired metadata fields missing or empty
MISSING_DISCLOSURENo AI-generation disclosure found in content
UNAUTHORIZED_MODELmodel_id not in the allowed models list
UNREVIEWED_CONTENThuman_reviewed is not True
INVALID_TIMESTAMPTimestamp is malformed or in the future
ProvenanceGuard is fully deterministic — no LLM calls required. All checks use SHA-256 hashing, regex pattern matching, and datetime validation.

SACProcessor (RAG helper) 📄

Purpose: Prevent Document-Level Retrieval Mismatch (DRM) in legal RAG systems.

The problem

Standard RAG chunking causes >95% retrieval mismatch in legal databases because:
  • Legal documents share nearly identical boilerplate
  • Chunk-level embeddings lose document context
  • NDAs, contracts, and agreements look alike at the chunk level

The solution

from qwed_legal import SACProcessor

sac = SACProcessor(llm_client=my_llm)

# Your existing chunks
chunks = naive_split(contract_text)

# Augment with document fingerprint
augmented = sac.generate_sac_chunks(
    document_text=contract_text,
    chunks=chunks,
    document_id="NDA-2026-001"
)

# Each chunk now includes global context
print(augmented[0])
# DOCUMENT CONTEXT [NDA-2026-001]: NDA between Acme Corp and Beta Inc...
# CHUNK CONTENT [1/10]: Original chunk text here...

Configuration

ParameterDefaultDescription
target_summary_length150Character limit for document fingerprint
preview_chars5000Max chars sent to LLM for summarization

Methods

MethodDescription
generate_sac_chunks()Augment all chunks with document fingerprint
generate_fingerprint_only()Get just the fingerprint for caching
SACProcessor requires an LLM client. Generic (automated) summaries outperform expert-guided ones for retrieval.

All-in-one: LegalGuard

For convenience, use the unified LegalGuard class:
from qwed_legal import LegalGuard

# Optional: provide llm_client for FairnessGuard
guard = LegalGuard(
    llm_client=my_llm,
    provenance_config={
        "require_disclosure": True,
        "require_human_review": False,
        "allowed_models": ["gpt-4", "claude-3-opus"],
    }
)

# All 10 guards available
guard.verify_deadline(...)
guard.verify_liability_cap(...)
guard.check_clause_consistency(...)          # ClauseGuard (text heuristics)
guard.verify_citation(...)
guard.verify_jurisdiction(...)
guard.verify_statute_of_limitations(...)
guard.verify_irac_structure(...)             # v0.3.0
guard.verify_fairness(...)                   # v0.3.0 (requires llm_client)
guard.verify_contradiction(...)              # v0.3.0 (Z3 SMT Solver)
guard.verify_provenance(content, provenance) # NEW in v0.4.0
LegalGuard is a convenience wrapper. It does not change the verification boundaries of the underlying guards. DeadlineGuard, LiabilityGuard, and ProvenanceGuard are deterministic for supported inputs; the remaining guards are partial or heuristic. Only verify_fairness() requires an LLM client.

Next steps