Files
Marius CiepluchandClaude Opus 4.8 39a1873e5b docs: plain-English pass over the active specs
Ran the humanizer rules over 79 prose files in the active specs (brief,
design, requirements, research and narrative docs). Removed em dashes,
significance inflation, forced rule-of-three, bold-label list items and
filler. Skipped tasks.md checklists and the retired specs (harnessx-*,
openrouter-sast-harness, unseen-repo-evaluation).

Every number, path, command, hash and identifier was preserved: checked
each file against HEAD for drift in numbers, inline code and code fences,
zero drift across all 79 files.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-10-12 18:16:41 +02:00

31 KiB
Raw Permalink Blame History

Design Document

Overview

Purpose. Give OpenUltraSAST a second detection regime beside data flow: an operation that carries an obligation (a protected read or write, a privileged action, a security-relevant setting) is reported when an entry point reaches it and nothing on the way discharges the obligation (a path guard, an identity constraint bound from the authenticated context, an ownership check, a non-permissive value, validated input). The application's own policy says which obligations exist: the consistency of sibling handlers, and optionally a declared policy file.

Users. AppSec engineers get absence findings that name the missing discharger, the evidence for the obligation, and the known fix. Maintainers get a leave-one-out number per obligation kind and an improve lever that admits obligation shapes under the existing gate. Project owners may declare their policy in a small versioned file.

Impact. New semantic/obligations/ package (facts, shapes, siblings, declared policy, dominance protocol, checker). One more MAP proposer. Obligation rows in the existing mechanism store (second shape family). New harvest mode and one additive label field in the corpus. Reports, manifest and ranking gain obligation fields. No change to dispositions, the evidence ladder, taint, facts for flows, the call graph, or any gate.

Goals

  • Absence pairs in vibe-py and agent-vfc move from 0 detected to a measured leave-one-out baseline per obligation kind.
  • Every obligation finding states obligation, missing discharger, evidence (siblings or policy clause) and known fix.
  • Works today in function-local mode; becomes path-aware when reachability lands; becomes nesting-aware when guard dominance lands, without caller changes.
  • Policy is never authored by a model.

Non-Goals

  • Flow taint, sink facts, walker, dispositions, ladder rungs.
  • Call graph, summaries, path records, dominance IR (owned upstream).
  • Sandbox proof of absence bugs.
  • LLM-authored policy or shapes; any merge gate; vendoring unlicensed code.

Boundary Commitments

This Spec Owns

  • Obligation facts data and loader (obligations.toml, OperationFact, DischargerFact, closed kinds).
  • Obligation shape derivation, family tag, dedupe key, export into the mechanism store.
  • Sibling sets, discharge detection per handler, consistency anomalies.
  • Declared policy schema, loader, version hash, pending clauses.
  • The Dominance protocol and its order-based default implementation.
  • The obligation checker (path-aware and function-local), its findings, tags and labels.
  • Obligation fields in markdown/SARIF, the manifest obligations block, ranking weight for obligation findings.
  • Corpus: handler_context harvest mode, obligation label field, vocabulary additions, leave-one-out per obligation kind.
  • The obligations lever (shape admit/retract, pending policy clauses) reusing the mechanisms lever machinery.

Out of Boundary

  • semantic/taint.py, semantic/facts.py (flow facts), semantic/cst.py, semantic/ir.py, overlay.py dispositions and ALLOWED_EVIDENCE.
  • semantic/callgraph.py, summaries.py, paths.py (reachability-flow-model); nesting-aware dominance (guard-dominance-regime).
  • Pair scorer rules beyond one additive detection rule for obligation-labeled rows; catalog schema beyond the obligation field.
  • Sink-shape variant search and its lever family (corpus-seeded-mechanisms), reused unchanged.
  • Rule and CWE policy levers, ledger format.

Allowed Dependencies

  • FileIR, parse_file, named_ranges_from_ir, variants.source_kind_of_name (read), SemanticFacts sources (read, for request-input provenance).
  • EntryPointRecord from mapping.analyze_entry_points (read).
  • PathRecord from semantic/paths.py when present (read, optional).
  • MechanismStore, append_from_pair-style writer, MechanismEdit, apply_mechanism_edits, StoreSnapshot, evaluate_mechanism_profiles, profile_regressions, journal.
  • PairCase, load_pair_catalog, selectors, loo.score_pair_with_store pattern, harvest library.
  • Reports, manifest writer, StaticFinding, rank.

Revalidation Triggers

  • Any obligation finding above suspicion without sandbox agreement.
  • A shape or policy clause authored by a model or written by the tool.
  • Obligation kinds or discharger kinds outside the closed sets appearing in facts, shapes or policy.
  • Dominance protocol signature change (both upstream engine specs implement it).
  • PathRecord shape change upstream (the checker's path consumer must be re-checked).
  • Consistency findings that demote or suppress anything.

Architecture

Existing Architecture Analysis

MAP already runs three proposers over one parse: inventory findings adjudicated by the overlay, the tool hunter, and variant search. Each produces StaticFindings at or below static_corroboration, merges with overlay records where a call site coincides, and is counted in the manifest. The entry-point mapper runs before MAP and yields routes with an access level. The mechanism store is append-only with deterministic ids and a folding loader; the improve loop has a bounded lever contract with byte-for-byte revert. This design adds a fourth proposer and a second shape family, following those patterns exactly; the only new concept is the Dominance protocol, introduced because the spec that owns real dominance is not written yet.

Architecture Pattern & Boundary Map

flowchart TB
  subgraph facts[Closed data]
    OF[obligations.toml: operation + discharger facts]
    DP[declared policy file: resources, routes, identity source, roles]
  end
  subgraph corpus[Corpus]
    PAIRS[trusted absence pairs, handler_context excerpts]
  end
  PAIRS --> DERIVE[derive_obligation: shape family obligation]
  DERIVE --> STORE[mechanisms.jsonl rows with family=obligation]
  OF --> OPS[find_operations over FileIR]
  EP[EntryPointRecord: routes, access level] --> SIB[sibling sets]
  OPS --> SIB
  OF --> DIS[discharges per handler]
  SIB --> CONS[consistency anomalies]
  DP --> CHECK[obligation checker]
  CONS --> CHECK
  PATHS[PathRecord optional] --> DOM[Dominance protocol: OrderDominance default]
  DOM --> CHECK
  STORE --> CHECK
  CHECK --> FIND[StaticFinding suspicion + obligation labels]
  FIND --> REP[reports, manifest, rank]
  PAIRS --> LOO[leave-one-out per obligation kind]
  STORE -.-> LOO
  LOO --> LEVER[obligations lever: admit/retract shapes, pending policy clauses]
  LEVER --> STORE

Architecture Integration

  • Selected pattern: closed facts plus learned shapes plus recovered policy, evaluated by a proposer that never claims proof.
  • Domain boundaries: facts/shapes (semantic data), siblings/policy (application intent), checker (evaluation), reports/rank/lever (consumers). No component writes another's data except the lever writing the store through the existing contract.
  • Existing patterns preserved: append-only store with deterministic ids; MAP proposer at suspicion; lever with validator, snapshot, per-profile clause; leave-one-out as the objective.
  • New component rationale: Dominance protocol decouples the checker from the two upstream engine specs so obligations are useful in function-local mode today.
  • Dependency direction: obligation facts → shapes → siblings/policy → checker → cli/reports/rank → loo → lever. obligations/ never imports the overlay, the scorer, or improve.

Technology Stack

Layer Choice Role Notes
Facts TOML data next to the flow facts operation and discharger kinds closed loader, separate file so facts.py stays untouched
Shapes stdlib dataclasses over FileIR derive, key, dict round-trip second family in the mechanism store
Policy TOML file in the scanned project's calibration dir declared resources and routes closed schema, version hash in manifest
Evaluation MAP proposer findings at suspicion function-local without paths
Corpus harvest mode + label field teach and score absence pairs additive
Improve existing lever machinery admit/retract, pending clauses same gate

File Structure Plan

src/openultrasast/
├── semantic/obligations/
│   ├── __init__.py       # public surface: load_obligation_facts, derive_obligation, check_obligations, ObligationFinding
│   ├── facts.py          # OperationFact, DischargerFact, OPERATION_KINDS, DISCHARGER_KINDS, PROVENANCE_KINDS, load_obligation_facts(dir)
│   ├── shapes.py         # ObligationShape (family="obligation"), derive_obligation(vuln_ir, fixed_ir, label, facts, texts), classify_discharger
│   ├── operations.py     # find_operations(ir, facts) -> Operation sites; find_discharges(ir, facts, ranges) -> Discharge witnesses
│   ├── siblings.py       # SiblingSet grouping from EntryPointRecord + operations; consistency_anomalies(sets, min_siblings)
│   ├── policy.py         # DeclaredPolicy schema + load_declared_policy(path) (closed; rejects unknown fields); version hash
│   ├── dominance.py      # Dominance protocol; OrderDominance default (decorators, router middleware, statement order, hop order)
│   └── check.py          # check_obligations(...) -> ObligationResult(findings, discharges, sibling_sets, degradations); findings_to_static
├── ruleset/obligations/{python,javascript,typescript}.toml   # closed operation/discharger facts, beside (not inside) the flow facts dir that load_facts globs
├── cli.py                # MAP: obligation check after variant search; manifest `obligations`; `mechanisms export` also writes obligation shapes
├── config.py             # [obligations] enabled=true, min_siblings=3, policy_path=".openultrasast/obligations.toml"
├── reports.py            # obligation lines in markdown, `obligation_*` SARIF properties
├── rank.py               # obligation ranking weight: sensitivity x label, below any sandbox-proven finding
├── semantic/seed.py      # pair_lessons yields obligation shapes for obligation-labeled rows (same exporter)
├── semantic/loo.py       # score_pair_with_store handles family=obligation via check_obligations; per obligation kind grouping
├── improve/validator.py  # validate_mechanism dispatches on shape family; PolicyClauseEdit (pending)
├── improve/evolve.py     # propose_mechanism_edits covers obligation rows; pending clause journaling
├── pairs.py              # ExpectedFinding.obligation (additive); obligation-labeled rows count an obligation finding inside the function as detection
benchmarks/pairs/
├── harvest.py            # mode "handler_context": function + decorators + registration lines
├── mechanisms.toml       # vocabulary additions: unconstrained_protected_read, unconstrained_protected_write, unguarded_privileged_action
tests/
├── test_obligation_facts.py, test_obligation_shapes.py, test_obligation_siblings.py, test_obligation_policy.py,
├── test_obligation_check.py (function-local + path-aware with a stub PathRecord), test_obligation_reports.py,
├── test_obligation_corpus.py (harvest mode, label, LOO per kind), test_obligation_lever.py

Modified Files

  • src/openultrasast/cli.py: _check_obligations after _search_variants in MAP (standard/deep); manifest obligations block; mechanisms export writes obligation rows too.
  • src/openultrasast/pairs.py: ExpectedFinding.obligation: str | None; _detections-side additive rule: for a row with obligation, a finding with tag obligation:<kind> inside the labeled function counts.
  • src/openultrasast/semantic/mechanisms.py: no schema change; corpus_mechanisms() unchanged; a helper obligation_mechanisms(records) selects shape["family"] == "obligation" (added in obligations/shapes.py, not in the store module).
  • src/openultrasast/reports.py, rank.py, config.py, improve/*, semantic/seed.py, semantic/loo.py, benchmarks/pairs/harvest.py, benchmarks/pairs/mechanisms.toml as listed above.

System Flows

Evaluation in MAP

flowchart LR
  A[parsed files + EntryPointRecords] --> B[find_operations per handler]
  B --> C[find_discharges per handler]
  A --> D[sibling sets by resource x router]
  C --> D
  D --> E[consistency anomalies >= min_siblings]
  P[PathRecords if present] --> F[Dominance.dominates witness, operation]
  C --> F
  G[declared policy if present] --> H[obligation checker]
  E --> H
  F --> H
  H --> I[ObligationFinding: kind, discharger, provenance, evidence label, siblings/clause, known fix]
  I --> J[StaticFinding suspicion; tags obligation:*, discharger:*, obligation_evidence:*]

Gating: the checker emits a finding only when (1) the operation is reached (from an entry point along a PathRecord, or, without paths, when the operation sits in a handler that is itself an entry point) and (2) no discharger of the required kind dominates, and (3) the obligation is justified by a sibling set (consistency_violation), a policy clause (declared_policy_violation) or, in degraded mode without either, by the operation fact alone (function_local). A declared public route suppresses path-guard obligations for that route only.

Teaching and measuring

flowchart LR
  T[trusted absence pair] --> V[vuln + fixed FileIR, handler_context texts]
  V --> W[operation in labeled function]
  V --> X[discharger added on fixed side + provenance of bound value]
  W --> Y[ObligationShape: operation kind, discharger kind, provenance, resource token class]
  X --> Y
  Y --> Z[store row family=obligation, deterministic id]
  Z --> L[leave-one-out: check_obligations on held-out excerpts with the other pairs' shapes]
  L --> M[per obligation kind: recall, silence, Youden; found_by]

Lever

Admit/retract of obligation shape rows follows the mechanisms lever exactly (validator dispatches on family; same snapshot, tombstone, per-profile clause via evaluate_mechanism_profiles extended to run the checker for obligation rows). A policy clause proposal (PolicyClauseEdit) is journaled as pending with its sibling evidence and is never read by a scan until a human copies it into the policy file; accepting is a human edit of the file, not a loop action.

Requirements Traceability

Requirement Summary Components Flows
1.1–1.5 closed operation/discharger facts, loader rejects unknown, no detection outside facts ObligationFacts —
2.1–2.5 shapes from trusted pairs, tiers, text-free, dedupe with provenance, skip reasons ObligationShapes, Export teaching
3.1–3.6 sibling sets, min count, route access evidence, never demote, fixed-twin leak rate Siblings, Checker evaluation
4.1–4.5 declared policy: load, reject unknown, declared violation, public routes, never written by the tool DeclaredPolicy, Checker, Lever evaluation, lever
5.1–5.5 path-aware, discharge recorded, function_local degraded mode, identity provenance, standard/deep only Dominance, Checker, cli evaluation
6.1–6.5 one label, reports, hunter intent, ranking weight, manifest block Checker, reports, rank, IntentAdjudication —
7.1–7.5 handler_context harvest, obligation label, vocabulary, LOO per kind, roadmap harvest, pairs, mechanisms.toml, loo teaching
8.1–8.5 obligations lever, closed validator, gate unchanged, pending clauses, journal Lever lever
9.1–9.3 offline tests, gates unchanged, extra-free green tests —

Components and Interfaces

Component Domain Intent Req Dependencies Contract
ObligationFacts semantic data closed kinds and their call/attribute matches 1 TOML State
ObligationShapes semantic derive, key, round-trip, export 2, 7 FileIR, facts, variants helpers, store Service
Operations semantic operation sites and discharge witnesses in a file 3, 5 FileIR, facts Service
Siblings intent group handlers, find anomalies 3 EntryPointRecord, Operations Service
DeclaredPolicy intent load and validate policy, version 4 TOML State
Dominance evaluation does a witness dominate an operation 5 PathRecord optional Protocol
Checker evaluation findings with labels and evidence 3–6 all above Service
IntentAdjudication hunter record intent on a finding 6.3 hunter client (optional) Service
Corpus corpus harvest mode, label, LOO per kind 7 harvest lib, pairs, loo Batch
Lever improve admit/retract shapes, pending clauses 8 validator, evolve, store Batch

ObligationFacts

OPERATION_KINDS = ("protected_read", "protected_write", "privileged_action", "security_setting")
DISCHARGER_KINDS = ("path_guard", "identity_constraint", "ownership_check", "non_permissive_value", "validated_input")
PROVENANCE_KINDS = ("authenticated_context", "request_input", "constant", "unknown")

@dataclass(frozen=True)
class OperationFact:
    id: str
    kind: str                     # OPERATION_KINDS
    language: str
    calls: tuple[str, ...]        # trailing-name matches, same rule as sink facts
    resource_arg: int | None      # argument or receiver position that names the resource (model, table, path)
    requires: tuple[str, ...]     # DISCHARGER_KINDS that discharge this operation
    sensitivity: str              # low | medium | high (default weight for ranking)

@dataclass(frozen=True)
class DischargerFact:
    id: str
    kind: str                     # DISCHARGER_KINDS
    language: str
    calls: tuple[str, ...] = ()   # guard calls / decorators / setters
    decorators: tuple[str, ...] = ()
    identity_sources: tuple[str, ...] = ()   # text patterns bound from the authenticated context (request.user, g.user, token payload)
    constraint_params: tuple[str, ...] = ()  # keyword or field names that constrain by identity (user, owner, user_id, tenant)

def load_obligation_facts(directory: Path | None = None) -> ObligationFacts: ...

Invariants: any kind outside the closed sets raises ObligationFactsError naming the entry; for_language(language) scoping like SemanticFacts.

ObligationShapes

@dataclass(frozen=True)
class ObligationShape:
    language: str
    operation_kind: str
    discharger_kind: str
    provenance: str              # PROVENANCE_KINDS: what the fix binds the discharger to
    resource_class: str          # "owned" | "global" | "setting" (from the operation fact's resource_arg and the added constraint)
    mechanism: str               # closed vocabulary id from the label
    family: str = "obligation"

    def key(self) -> str: ...    # text-free; family prefix so it never collides with sink shapes

def derive_obligation(vuln: FileIR, fixed: FileIR, *, function: str, obligation: str, mechanism: str, facts: ObligationFacts, flow_facts: SemanticFacts, vuln_text: str, fixed_text: str) -> ObligationShape | None: ...
def classify_discharger(vuln: FileIR, fixed: FileIR, *, function: str, facts: ObligationFacts, texts: tuple[str, str]) -> tuple[str, str] | None: ...  # (discharger kind, provenance) added on the fixed side

Preconditions: both sides parse; the labeled function exists on both. Postconditions: None when no operation of a required kind is in the labeled function or no discharger was added; the shape carries no text. Stored through the existing writer with shape = to_dict(); id from key().

Operations and Siblings

@dataclass(frozen=True)
class Operation:
    path: str; line: int; function: str; kind: str; fact_id: str; resource: str | None   # resource token, identifier only

@dataclass(frozen=True)
class Discharge:
    path: str; line: int | None; function: str; kind: str; fact_id: str; provenance: str; scope: str   # scope: decorator | router | statement | hop

def find_operations(ir: FileIR, facts: ObligationFacts, ranges) -> tuple[Operation, ...]: ...
def find_discharges(ir: FileIR, facts: ObligationFacts, flow_facts: SemanticFacts, ranges, entry: EntryPointRecord | None) -> tuple[Discharge, ...]: ...

@dataclass(frozen=True)
class SiblingSet:
    key: tuple[str, str]                      # (router or module, resource token)
    handlers: tuple[str, ...]                 # "path::function"
    discharging: dict[str, tuple[str, ...]]   # discharger kind -> handlers that discharge it

def sibling_sets(entries: Sequence[EntryPointRecord], operations, discharges) -> tuple[SiblingSet, ...]: ...
def consistency_anomalies(sets, *, min_siblings: int) -> tuple[Anomaly, ...]: ...   # handler, kind, siblings that discharge

Invariants: a set with fewer than min_siblings members yields no anomaly and is counted as under-populated; anomalies never touch existing findings.

DeclaredPolicy

# .openultrasast/obligations.toml (project-owned; the tool never writes it)
version = 1
identity_source = "request.user"          # closed: one of the discharger facts' identity_sources or "token"
[[resource]]
name = "book"                              # resource token as seen in operations
sensitivity = "high"                       # low | medium | high
identity_field = "user_id"                 # constraint param that ties the resource to its owner
[[route]]
path = "/health"
access = "public"                          # public | authenticated | role
roles = []

load_declared_policy(path) -> DeclaredPolicy | None; unknown field or value → PolicyError naming it; the scan continues without a policy and records {"stage": "obligations", "reason": "policy_invalid", "detail"}. DeclaredPolicy.version_hash goes to the manifest.

Dominance

class Dominance(Protocol):
    def dominates(self, witness: Discharge, operation: Operation, path: PathRecord | None) -> bool: ...

class OrderDominance:
    """Default: decorator and router-middleware witnesses dominate the handler; a statement witness dominates when it
    precedes the operation in the same function and the function has an early exit between them or the witness is a
    guard call; along a PathRecord, a witness on an earlier hop dominates."""

guard-dominance-regime provides a nesting-aware implementation behind the same protocol; the checker takes the implementation as a parameter.

Checker

@dataclass(frozen=True)
class ObligationFinding:
    operation: Operation
    missing: str                 # discharger kind
    provenance: str | None       # when an identity constraint exists but binds request input
    label: str                   # consistency_violation | declared_policy_violation | function_local
    evidence: tuple[str, ...]    # sibling handler ids or the policy clause
    known_fix: str | None        # mechanism record id whose shape matches (operation kind, discharger kind)
    intent: str | None = None    # hunter adjudication: public | protected | unknown

@dataclass(frozen=True)
class ObligationResult:
    findings: tuple[ObligationFinding, ...]
    discharges: tuple[Discharge, ...]
    sibling_sets: tuple[SiblingSet, ...]
    degradations: tuple[dict[str, object], ...]

def check_obligations(*, irs, entries, facts, flow_facts, policy, paths, dominance, store_shapes, min_siblings) -> ObligationResult: ...
def findings_to_static(result) -> list[StaticFinding]: ...   # id "obligation:<kind>:<path>:<line>:<missing>", evidence suspicion, tags

Rules: reached = a PathRecord ends at the operation, or (no paths) the operation's function is an entry point; label precedence declared > consistency > function_local; a declared public route suppresses path_guard obligations for that route; an operation with a matching store shape gains known_fix; a discharge that binds request_input where authenticated_context is required is reported with provenance = "request_input".

EntryPoints (registration-based)

mapping.analyze_entry_points names a handler from a decorator today. Real projects register handlers by call (add_url_rule(path, view_func=fn), api.add_resource(Cls, path), router.get(path, requireAuth, handler)) or by convention (Resource verb methods, Next.js route.ts exports). Because both the sibling grouping (Req 3.1) and the function-local reach test (Req 5.3) key on a named entry-point handler, an unnamed registration makes every obligation unreachable. The mapper gains those four forms; access classification, evidence, conditions and the record shape are unchanged, and middleware identifiers on a registration line are access evidence exactly as decorators are.

IntentAdjudication

When a hunter client is available (the same resolution as pairs --hunter), the checker asks one closed question per finding with a route: "Is this route meant to be public?" with the handler text and siblings as context; the answer is recorded as intent and in the rationale. It never changes the label or the evidence level; when the client is absent a degradation intent_adjudication_unavailable is recorded once.

Reports, manifest, rank

Markdown per finding: - Obligation: <kind> on <resource>, - Missing discharger: <kind> (<provenance>), - Evidence: <label> — <siblings | clause>, - Known fix: <guard shape> learned from <pairs>, - Intent: <adjudication>; a ## Obligations section lists sibling sets and the policy version. SARIF properties obligation_kind, obligation_missing, obligation_label, obligation_evidence, obligation_known_fix, obligation_intent. Manifest obligations = {sibling_sets, under_populated, operations, findings_by_label, policy_version | null, degradations, sets} (sets lists {module, resource, handlers} so the report can name them). Rank: ranking_priority = base(sensitivity) x weight(label) with declared > consistency > function_local, capped strictly below the lowest sandbox-proven finding in the run. Proof rungs live on CandidateVerdict, never on StaticFinding, so the cap takes the proven finding ids from the run's TRIGGERABLE verdicts: rank_obligations(findings, proven_ids=...) runs once at MAP and again after REGRESS when the sandbox proved anything.

Corpus and leave-one-out

Harvest mode handler_context: the labeled function plus its decorators and the registration statements that name it (router .get(...)/.post(...) calls, add_url_rule, middleware attachment) as one excerpt per side. Catalog row obligation = "<OPERATION_KIND>" (additive); vocabulary gains unconstrained_protected_read, unconstrained_protected_write, unguarded_privileged_action (existing missing_auth_guard, identity_from_request_body, permissive_default stay). loo.score_pair_with_store runs check_obligations on both sides for obligation rows; grouping adds per_obligation_kind; found_by names the shape record that supplied known_fix.

Lever

validate_mechanism dispatches on shape["family"]: obligation rows check operation_kind, discharger_kind, provenance, resource_class against the closed sets. PolicyClauseEdit(kind="resource"|"route", fields, evidence, rationale) is validated against the policy schema, journaled under pending_policy_clauses, and never applied by the loop. propose_mechanism_edits treats obligation rows like sink rows (admit when a trial store recovers a missed holdout absence pair without leaking; retract leakers); evaluate_mechanism_profiles runs the checker for obligation rows.

Data Models

  • Store row: existing Mechanism with shape = {family: "obligation", language, operation_kind, discharger_kind, provenance, resource_class, mechanism}; guard field carries discharger_kind for report continuity.
  • Facts: obligations.toml with [[operation]] and [[discharger]] tables per language; version.
  • Policy: as above; version_hash = sha1(normalized file).
  • Manifest: obligations block; loo.json gains per_obligation_kind.
  • Journal: pending_policy_clauses: [{clause, evidence, round, rationale}].

Error Handling

Condition Response
Facts entry outside closed sets ObligationFactsError naming the entry; loader fails loud (data bug)
Policy file invalid policy_invalid degradation; scan continues without policy
No path records function-local mode; function_local labels; degradation per language once
Language without grammar skipped with obligations_language_unsupported
Sibling set under minimum counted under-populated, no finding
Hunter unavailable intent_adjudication_unavailable, findings unchanged
Store row with malformed obligation shape skipped with obligations_store_row_invalid

Testing Strategy

  • Unit: facts loader rejects an unknown kind (1.4); derive_obligation on the vampi pair yields protected_read / identity_constraint / authenticated_context and a text-free key (2.1, 2.3); a pair whose fix adds no discharger is skipped with a reason (2.5); sibling_sets groups three handlers on /books and consistency_anomalies flags the unconstrained one with the two siblings as evidence, and stays silent under min_siblings (3.2, 3.3); a public route among authenticated siblings is a missing path_guard with the access classification as evidence (3.4); policy loader rejects an unknown field and a declared public route suppresses the guard obligation but not the identity one (4.2, 4.4); OrderDominance with a stub PathRecord accepts a guard on an earlier hop and rejects one after the operation (5.1, 5.2); no paths → function_local plus degradation (5.3); identity bound from request.args → provenance = request_input (5.4).
  • Integration: standard scan on a fixture with three handlers emits one obligation: finding at suspicion with tags and report lines, manifest block present, absent in quick (5.5, 6.1, 6.2, 6.5); ranking places it below a sandbox-proven finding (6.4).
  • Corpus: handler_context excerpt keeps decorators and registration (7.1); an obligation-labeled catalog row counts an obligation finding inside the function (7.2); LOO on a three-pair toy slice reports per obligation kind and found_by (7.4).
  • Lever: admission of an obligation shape that recovers a holdout absence pair is accepted; one that fires on a fixed twin is rejected with byte-identical store; a PolicyClauseEdit lands in pending_policy_clauses and no scan reads it (8.3, 8.4).
  • Gates: detection, map and local pair gates unchanged; extra-free suite green with semantic tests skipping (9.2, 9.3).

Security Considerations

Shapes and policy carry identifiers only; the policy file is project-owned and never written by the tool; the hunter receives redacted handler text and can only annotate. Obligation findings never execute anything and never reach the sandbox candidate set.

Performance

Operations and discharges are found in the same parse the overlay uses; sibling grouping is linear in handlers; the checker is O(operations x witnesses) per handler and O(paths) with path records; bounded by the MAP file budget.

Migration Strategy

  1. Facts, shapes, exporter and vocabulary; measure how many absence pairs teach.
  2. Operations, discharges, siblings, policy, order dominance, checker in function-local mode; MAP wiring behind [obligations].enabled; reports and manifest.
  3. Corpus harvest mode and label; leave-one-out per kind; roadmap baseline.
  4. Lever and pending clauses.
  5. When reachability-flow-model lands: pass PathRecords to the checker (no interface change). When guard-dominance-regime lands: swap the Dominance implementation.