Skip to content

Commit 9e77980

Browse files
committed
feat(obligations): obligation protocols — barrier-sensitive project invariants (P-025, first slice)
A new sidecar analysis family (the di.py/effects.py pattern): project-declared obligation protocols checked path-sensitively per method. An opening event (IsLoaded = false) creates an obligation, a closing event discharges it, and it must be closed before every declared barrier (OnPropertyChanged(Document)) and, by default, every method exit (return / throw / end of body). - ownlang/obligations.py: matchers, protocols, event trees (assign/call/return/ throw/if/while), the {OPEN, CLOSED} set lattice with union joins (the definite/maybe split of OWN002 vs OWN009), local loop fixpoints with single-shot emission, open-site provenance, the opaque-write discharge asymmetry (may discharge, never invents), and explicit method scoping as the false-positive throttle. - ownlang/ownir.py: additive OwnIR blocks protocols[]/protocol_functions[] (no version bump — the services/effects precedent; internal vocabularies are fail-loud per IR4, duplicate protocol names rejected at load), _protocol_findings beside _di_findings/_effect_findings, line-free messages (OwnAudit fingerprints on path|rule|message), evidence flow opened -> barrier -> late close (SARIF codeFlows). - Codes OBL001-004 (barrier/exit x definite/maybe) + OBL005 (dead-scope advisory, never fails the build) in diagnostics.TITLES/EXPLANATIONS; the cmd_ownir summary now names the advisory codes present instead of hardcoding OWN050. - spec/OwnIR.md §8 (Rules -> §9, Conformance -> §10) + ownir.schema.json $defs (protocol/protocolMatcher/protocolOpenClose/protocolEvent/ protocolFunction), vocabularies pinned both ways by the tests. - tests/test_obligations.py (70 checks) + killer-demo fixtures: OBL001 at BigDocumentViewModel.cs:241 (IsLoaded=false at 184, PropertyChanged(Document) at 241 on the warnings branch, closed only at 260), and its fixed twin staying silent. - docs/proposals/P-025-obligation-protocols.md: decisions on the record, the Roslyn extractor emission slice (designed; extractor is CI-only), the MOS-based interprocedural phase, and non-goals. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NXzqX7Qwn5QzBLGVCATdgm
1 parent db6f673 commit 9e77980

12 files changed

Lines changed: 1785 additions & 8 deletions

docs/ROADMAP.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,3 +325,4 @@ own scan. Label them as estimates wherever they appear.
325325
| [P-017](proposals/P-017-multi-stack-frontends.md) | Multi-stack frontends (OwnTS / OwnJVM: OwnJava + OwnKotlin) | horizon | draft |
326326
| [P-020](proposals/P-020-ownts-react-effects.md) | OwnTS React effects profile (`Own.React`) — effect-storm angle | horizon | draft |
327327
| [P-021](proposals/P-021-async-audit-pack.md) | Async audit pack (`Own.Async`) — safety-first WPF/application async lifecycle diagnostics | P2 | draft |
328+
| [P-025](proposals/P-025-obligation-protocols.md) | Obligation protocols (`Own.Protocols`) — barrier-sensitive project invariants (OBL001–005) | P1 | first slice built (core + bridge + fixtures; extractor pending) |
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
# P-025 — Obligation protocols (`Own.Protocols`) — barrier-sensitive project invariants
2+
3+
- **Status:** first slice built (core + bridge + spec + fixtures, `OBL001–005`
4+
end-to-end over hand-written facts); the Roslyn extractor slice is designed
5+
below but **not** implemented (this sandbox has no dotnet; extractor work is
6+
CI-validated).
7+
- **Built:** [`ownlang/obligations.py`](../../ownlang/obligations.py) (the
8+
path-sensitive checker), the `protocols[]` / `protocol_functions[]` OwnIR
9+
blocks ([spec/OwnIR.md §8](../../spec/OwnIR.md)), the `OBL001–OBL005` codes,
10+
[`tests/test_obligations.py`](../../tests/test_obligations.py) (64 checks),
11+
and the `protocol_isloaded_*` killer-demo fixtures.
12+
- **Depends on:** [spec/OwnIR.md](../../spec/OwnIR.md) (the facts seam),
13+
[P-016](P-016-deep-fact-extraction.md) (the flow lowering the extractor slice
14+
reuses), [P-006](P-006-di-lifetimes.md)/[P-020](P-020-ownts-react-effects.md)
15+
(the sidecar-analysis precedent this copies).
16+
- **Relation to [P-010](P-010-type-disciplines.md):** P-010's `protocol` blocks
17+
are *typestate on an object across its lifetime* (state machines, consume-self
18+
transitions on the affine core). P-025 is deliberately smaller: *temporal
19+
obligations inside a method*, checked against project-declared barriers. P-010
20+
can later subsume these rules; nothing here blocks it.
21+
22+
## Motivation — the invariant the type system cannot know
23+
24+
A legacy WPF method breaks its own invariant on purpose, briefly:
25+
26+
```csharp
27+
IsLoaded = false; // the document tree is now inconsistent — on purpose
28+
RebuildIndexes();
29+
if (hasWarnings)
30+
OnPropertyChanged(nameof(Document)); // ← published the broken object
31+
IsLoaded = true;
32+
OnPropertyChanged(nameof(Document)); // this one is fine
33+
```
34+
35+
`IsLoaded = false` is not a bug; **publishing the object while the flag is
36+
down** is. No general checker can know that `IsLoaded` means "the document is
37+
consistent", that `PropertyChanged("Document")` hands the object to bindings
38+
*right now*, or that `PropertyChanged("Progress")` is harmless meanwhile. That
39+
knowledge is project-specific. Existing tools stop exactly here: analyzers know
40+
universal protocols (dispose your `IDisposable`, unsubscribe your event);
41+
NDepend/CodeQL can query structure but have no barrier-sensitive obligation
42+
model; typestate research languages don't speak legacy C#. The niche is real:
43+
**barrier-sensitive, project-specific obligation checking for code review**
44+
and the OwnAudit STS corpus already shows the shape in the wild (17k
45+
INPC findings, 8 recorded `IsLoaded` findings, `BrokerDataClasses` as the
46+
subscription-leak epicenter).
47+
48+
The same three verbs cover the whole family:
49+
50+
```
51+
IsLoaded=false must become true before PropertyChanged(Document)
52+
_suppressNotifications must be restored before return/throw
53+
BeginUpdate must meet EndUpdate before Refresh / method exit
54+
SuspendCalculation must be resumed before results are published
55+
```
56+
57+
## The model — obligation / barrier / require-closed-before
58+
59+
One protocol = three matchers and a scope (the full shape and its normative
60+
semantics live in [spec/OwnIR.md §8](../../spec/OwnIR.md)):
61+
62+
- **opens** — the event that creates the obligation (`IsLoaded = false`, or a
63+
call: `BeginUpdate()`);
64+
- **closes** — the event that discharges it;
65+
- **barriers** — events it must not cross while open: configured calls (with an
66+
optional distinguished-argument set, so `OnPropertyChanged` can be unsafe for
67+
`Document` but allowed for `Progress`) plus, by default, every method exit
68+
(`return`, `throw`, falling off the end — the OWN001 shape).
69+
70+
The checker ([`ownlang/obligations.py`](../../ownlang/obligations.py)) walks the
71+
method's ordered event tree path-sensitively; the obligation state is a set over
72+
{OPEN, CLOSED} joined by union at merges, so **definite vs maybe** falls out of
73+
the lattice exactly as OWN002 vs OWN009 do. Loops are solved to a local fixpoint
74+
and emit once. Findings carry the ordered evidence slice — *opened here → barrier
75+
fired here → closed only here, after the barrier* — which SARIF renders as a
76+
click-through `codeFlows` trace.
77+
78+
| Code | Meaning |
79+
|------|---------|
80+
| OBL001 | obligation still open when a barrier fires (every path) |
81+
| OBL002 | obligation may still be open at a barrier (some path) |
82+
| OBL003 | obligation not closed before the method exits (every path) |
83+
| OBL004 | obligation may not be closed before an exit (some path) |
84+
| OBL005 | advisory: a protocol's scope matched no reported method (dead rule) |
85+
86+
## Precision policy (the standing red line, applied here)
87+
88+
False positives kill this feature faster than any competitor — a rule that
89+
cries on every `IsLoaded=false` gets switched off like a smoke alarm that hates
90+
toast. Three normative rules (all tested):
91+
92+
1. **Never invent.** An opaque write to a tracked flag (`IsLoaded = Compute()`)
93+
may *discharge* an open obligation (state gains CLOSED → the crossing
94+
degrades to a *maybe*) but never *creates* one.
95+
2. **Unnamed calls are neutral.** A call the protocol doesn't mention neither
96+
discharges nor crosses. A callee that flips the flag internally is invisible
97+
in v1 — that is the phase-3 interprocedural slice, not a v1 guess.
98+
3. **Scope is the throttle.** `scope.methods` restricts a rule to named
99+
methods; the MVP posture is *one protocol, one method, one historical bug*.
100+
A scoped rule matching nothing is surfaced (OBL005), not silently dead.
101+
102+
## Why this shape (decisions on the record)
103+
104+
- **Sidecar analysis, not new core instructions.** `di.py`/`effects.py` set the
105+
pattern: a fact family + a small core analysis routed via `check_facts`. The
106+
alternative (new `Instr` variants in `cfg.py`) touches the frozen
107+
`cfg_json.py` oracle seam, `codegen.py`, the grammar, and the Rust mirror —
108+
all for no v1 gain. Revisit when protocols need loans/RID interplay.
109+
- **Additive OwnIR blocks, no version bump.** `services` and `effects` landed
110+
additively at v0; `protocols`/`protocol_functions` follow the same IR3 rule.
111+
An older core ignores them; their internal vocabularies (`ev`, matcher
112+
`kind`) are fail-loud per IR4 and version *with the blocks*.
113+
- **Rules are data, not a language.** The chat-derived requirement is explicit:
114+
nobody wants to learn OwnLang — including its author. Protocols are declared
115+
as JSON facts (later: generated from attributes/inference and *approved*, see
116+
the roadmap), never hand-written `.own`. OwnLang stays what Own.NET
117+
understands, not what users write.
118+
- **Messages are line-free.** OwnAudit fingerprints findings on
119+
(path, rule, message) for the baseline ratchet and the FP-judge overlay; a
120+
line number in the message would break both on every unrelated edit. Lines
121+
live in the evidence slice.
122+
123+
## The extractor slice (designed, not built — needs CI/dotnet)
124+
125+
`OwnSharp.Extractor` already collects everything required; the slice is
126+
emission, not analysis (one checker: the extractor reports, the core decides):
127+
128+
1. **Events.** Extend the P-016 flow lowering (`LowerFlowStmt`/`EmitFlowExpr`,
129+
with its `onReturn`/`onThrow` continuation threading, so `finally` and
130+
exceptional paths come sound for free) to emit `protocol_functions[].events`
131+
for methods in some protocol's scope: member assigns with literal boolean
132+
RHS (`AssignedFieldName`/`ThisFieldName` already normalize the LHS; a
133+
non-literal RHS emits an opaque assign with no `value`), self-calls with a
134+
`nameof(X)`/string-literal first argument as `{"ev":"call","arg":"X"}`
135+
(`SelfCallName` already recognizes the receiver), and `return`/`throw`.
136+
Scope-gating keeps the facts file small and the honest-skip discipline
137+
(`methods_skipped_unmodelled`) carries over.
138+
2. **Rules.** A project file (e.g. `.own-protocols.json`, schema =
139+
`$defs/protocol`) merged into the facts by `own-check.sh` — configuration
140+
travels with the repo, not the tool invocation.
141+
3. **CI.** A `samples/LoadingProtocolSample.cs` + grep assertions in the
142+
`wpf-extractor` job, and a corpus case once real-world instances are mined
143+
(the OwnAudit STS stand is the natural first target).
144+
145+
## Roadmap (each phase lands only after the previous one holds on real code)
146+
147+
1. **v1 (this slice):** core + bridge + fixtures. Killer demo:
148+
`python -m ownlang ownir tests/fixtures/ownir/protocol_isloaded_violation.facts.json`
149+
`OBL001` at `BigDocumentViewModel.cs:241` with the three-hop path.
150+
2. **Extractor emission** (above) — the same demo on real C#.
151+
3. **Interprocedural obligations:** per-method summaries
152+
(`mayOpen/mustClose/mayCross` per protocol) on the MOS/SCC channel of
153+
[`ownership.py`](../../ownlang/ownership.py), so `ApplyWarnings()` that
154+
notifies internally stops being invisible. Same tier ladder as D5
155+
(inferred → curated → annotation).
156+
4. **Authoring surfaces:** `[OwnProtocol]`-style C# attributes and/or inferred
157+
candidate protocols ("in 27 places `IsLoaded=false``true` precedes the
158+
Document notify; 2 places violate — adopt this rule?") emitted as *suggested*
159+
config a human approves and commits.
160+
5. **Consumption:** OwnAudit picks OBL findings up as canonical finding records
161+
(SARIF evidence/codeFlows already flow through `report/sarif.py`; register
162+
the category for severity mapping and the runtime correlator), and the
163+
diff-aware baseline gate makes them review-time signals ("fail only new
164+
violations").
165+
166+
## Non-goals
167+
168+
- **Not a general temporal-logic engine.** No LTL, no arbitrary predicates, no
169+
cross-object protocols. Three verbs and a scope; the moment a rule needs a
170+
formula, it is a P-010/P-002 customer.
171+
- **Not typestate.** No per-object state machines, no consume-self transitions,
172+
no aliasing of obligation carriers (the protocol tracks *the method's own*
173+
flags/calls; `this`-aliasing is out of scope for v1 by construction, and the
174+
RID machinery exists when that changes).
175+
- **Not a DSL for people to write.** Facts in, findings out. Any future
176+
human-facing surface is attributes or approved generated config.
177+
- **Not on by default anywhere.** No built-in protocol ships with the tool; an
178+
empty `protocols[]` means the analysis does not exist for that repo.
179+
180+
## Open questions
181+
182+
1. **`await` as a barrier.** During an `await` the broken state is observable
183+
by the UI thread; is that a barrier by default, opt-in
184+
(`{"kind": "await"}` in `barriers`), or a per-protocol flag? (The extractor
185+
currently skips most async bodies anyway — honest-skip.)
186+
2. **Cross-member protocols** (open in `BeginLoad`, close in `OnLoaded`): needs
187+
obligation state on the *component*, not the method — the RID model fits,
188+
but the facts shape does not yet.
189+
3. **Suggested-protocol mining:** does inference live in the core (over
190+
`protocol_functions` without rules) or in OwnAudit (over the corpus)?

docs/proposals/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ proposal is marked `done` with a pointer.
4444
| [P-022](P-022-rust-core-migration.md) | Rust core migration: crate DAG, patterns, prior art, differential oracle (Python = golden) | draft / exploratory |
4545
| [P-023](P-023-architecture-guard.md) | Architecture guard (`Own.Arch`): rules.yaml intent model + dependency-graph gate + baseline ratchet | draft |
4646
| [P-024](P-024-security-audit-profile.md) | Security audit profile (external tools + SARIF adapters; rejects own scanner engine) | draft |
47+
| [P-025](P-025-obligation-protocols.md) | Obligation protocols (`Own.Protocols`): barrier-sensitive project invariants (OBL001–005) | first slice built (core + bridge + fixtures; extractor pending) |
4748

4849
> For priorities, milestones, the framing, and the design philosophy across all
4950
> of these, see the strategy hub: [`docs/ROADMAP.md`](../ROADMAP.md). P-004 … P-016

ownlang/__main__.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -351,8 +351,11 @@ def cmd_ownir(path: str, fmt: str = "human", severity: str = "error",
351351
n = len(leaks)
352352
summary = f"\n{n} finding{'s' if n != 1 else ''}"
353353
if notes:
354-
summary += (f" ({len(notes)} unchecked hidden)" if verbosity == "quiet"
355-
else f", {len(notes)} unchecked (OWN050)")
354+
# the advisory band is no longer only OWN050 (OBL005 rides it too) —
355+
# name the codes actually present instead of hardcoding one.
356+
note_codes = "/".join(sorted({x.code for x in notes}))
357+
summary += (f" ({len(notes)} advisory hidden)" if verbosity == "quiet"
358+
else f", {len(notes)} advisory ({note_codes})")
356359
print(summary + ".", file=summary_to)
357360
if verbosity == "verbose" and findings:
358361
by_code: dict[str, int] = {}

ownlang/diagnostics.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
040-041 extern / call-boundary
99
050 C# front-end resolution coverage (P-014; advisory, never a verdict)
1010
11+
Sidecar analysis families carry their own prefixes (DI, EFF, OBL) — each is a
12+
separate analysis the OwnIR bridge routes facts to, not the core lattice.
13+
1114
The split between *definite* (002 use-after-release, 005 use-after-move) and
1215
*maybe* (009, 010) codes is deliberate: a fault that holds on every path is a
1316
different, sharper message than one that holds on only some path through a
@@ -88,6 +91,12 @@ class Severity(Enum):
8891
"DI005": "disposable transient resolved from a long-lived scope (delayed disposal)",
8992
# ---- reactive-effect stability (P-020; a separate analysis, like DI001) ----
9093
"EFF001": "reactive effect re-runs on an unstable dependency identity (render-time IO storm)",
94+
# ---- obligation protocols (P-025; a separate analysis, like DI001) ----
95+
"OBL001": "obligation still open when a barrier fires (open on every path)",
96+
"OBL002": "obligation may still be open when a barrier fires (open on some path)",
97+
"OBL003": "obligation not closed before the method exits (on every path)",
98+
"OBL004": "obligation may not be closed before the method exits (on some path)",
99+
"OBL005": "protocol scope matched no reported method -- rule is dead (advisory)",
91100
}
92101

93102

@@ -178,6 +187,31 @@ class Severity(Enum):
178187
"Fix: resolve disposable transients within a short-lived scope you dispose, or manage "
179188
"their lifetime explicitly."
180189
),
190+
"OBL001": (
191+
"A project-declared obligation protocol (e.g. \"`IsLoaded = false` must be closed by "
192+
"`IsLoaded = true`\") is still open when a declared barrier fires — on every path that "
193+
"reaches the barrier. The classic WPF shape: a method flips a consistency flag down, "
194+
"rebuilds state, and raises `PropertyChanged(\"Document\")` before flipping the flag "
195+
"back up, publishing an inconsistent object to bindings and listeners.\n"
196+
"Fix: close the obligation before the barrier (move the closing assignment/call above "
197+
"the notification), or — if that notification is genuinely safe while open — add it to "
198+
"the protocol's `allow` list."
199+
),
200+
"OBL003": (
201+
"A project-declared obligation is opened but not closed before the method exits "
202+
"(return / throw / falling off the end) on every path — the object is left in its "
203+
"\"temporarily broken\" state for the outside world to observe. The exception path is "
204+
"the classic culprit: `IsLoaded = false; Load(); IsLoaded = true;` leaves the flag down "
205+
"forever when `Load()` throws.\n"
206+
"Fix: close in a `finally`, or on every early-return path."
207+
),
208+
"OBL005": (
209+
"Advisory, not a verdict: a protocol's `scope.methods` matched none of the methods the "
210+
"frontend reported events for — the rule is dead (usually a typo'd or renamed method "
211+
"name). A silently dead project rule is worse than none: it reads as coverage that "
212+
"does not exist.\n"
213+
"Fix: correct the scope, or delete the rule."
214+
),
181215
"EFF001": (
182216
"A React `useEffect` re-runs whenever one of its declared dependencies changes identity. "
183217
"A dependency that is an object/array literal created in render scope gets a fresh "

0 commit comments

Comments
 (0)