Skip to content

Commit e0ec98b

Browse files
PhysShellclaude
andcommitted
fix(roslyn): ProtocolToken / ProtocolRegion are reserved only in the profile's shapes
The state-protocol surface is recognised by two attributes matched by NAME, in any namespace. That made the names reserved everywhere, and a codebase that never declared a state protocol could be refused over them — the whole scan, exit 2, under --flow-locals (the own-check default): [ProtocolToken] public sealed class SessionToken { ... } // a wire-protocol token var session = new SessionToken(); -> a protocol token 'SessionToken' is created outside the protocol's own types Measured on main, with unrelated attributes of those names in another namespace: a marked class, plain struct, record, enum or interface, and a marked method with no callback (called from its own class or from another one) — all refused. The names are now reserved only in the shapes the profile gives them: a state token is a REF STRUCT marked [ProtocolToken] a region entry is a method marked [ProtocolRegion] that TAKES A DELEGATE Anything else carrying one of the names is somebody else's code and is ignored. The shapes are deliberately the widest that still catch a protocol declared WRONGLY, and none of those became an acceptance: * a region entry whose callback takes a marked type that is not a ref struct — still a region entry, still "must be a ref struct" where it is used; * a region entry whose token lost its attribute — still a region entry, so the entity is known and the unmarked type's transition is a boundary violation; * a region entry with the wrong arity — still refused where it is used; * a "region entry" with no delegate (a callback interface) — no longer one, and its type stops being a protocol type, so the token it creates is a token created outside the protocol; * a marked non-ref struct forged by hand, with no region in sight — its transition calls the entity's non-public mutator from outside the protocol. What changes from a refusal to an acceptance is exactly the unrelated code: the marked class / plain struct / record / enum / interface, and the marked method without a delegate. Two collisions keep the reserved shape and stay refused, with a message that now says which names are reserved and in which shapes: an unrelated ref struct marked [ProtocolToken], and an unrelated method marked [ProtocolRegion] that takes a delegate. The profile cannot tell either from a protocol declared wrongly, and a mis-declared protocol must not go unanalysed. scripts/protocol_gate.py gains the `unrelated` stage: programs from a codebase with no state protocol and attributes of these names. The two that are not in a reserved shape must be accepted with not one region lowered, alone and beside the sample protocol; the two that are, are pinned as refusals. On main's extractor the stage fails on the first two. No committed fact moves, and the extractor's output on every C# input CI scans is byte-identical to main's (256 of 256). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 0c2de35 commit e0ec98b

9 files changed

Lines changed: 275 additions & 11 deletions

‎frontend/roslyn/OwnSharp.Extractor/ProtocolLowering.cs‎

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@
1313
// [ProtocolToken] a ref struct: every instance METHOD is a transition that consumes the
1414
// token (and borrows the entity for the call); a PROPERTY is a read.
1515
//
16+
// RESERVED NAMES. Matching by name makes the two names a convention the profile owns, but only
17+
// in those shapes: a method marked [ProtocolRegion] that takes a delegate, a ref struct marked
18+
// [ProtocolToken]. An attribute of the same name on anything else (a class, a plain struct, a
19+
// method with no callback) belongs to somebody else's code and is ignored — a scan of a
20+
// codebase that declares no state protocol is never refused over a name.
21+
//
1622
// This is a LOWERING, not an analysis. It reads the syntax of one method at a time and looks
1723
// symbols up; it never tracks state across statements, never follows aliases, and never
1824
// decides whether a program is right. Everything it emits is checked by the core:
@@ -511,7 +517,7 @@ bool Outside(INamedTypeSymbol? owner)
511517
? info.ConvertedType : info.Type ?? info.ConvertedType)?.OriginalDefinition;
512518
if (IsToken(made) && !Within(api))
513519
refusals.Add(At(file, node,
514-
$"a protocol token '{made!.Name}' is created outside the protocol's own types: a token comes only from a region entry or a transition"));
520+
$"a protocol token '{made!.Name}' is created outside the protocol's own types: a token comes only from a region entry or a transition" + ReservedNames));
515521
else if (node is BaseObjectCreationExpressionSyntax
516522
&& Bound(model.GetSymbolInfo(node)) is IMethodSymbol
517523
{ DeclaredAccessibility: not Accessibility.Public } ctor
@@ -613,11 +619,37 @@ private static bool HasAttribute(ISymbol? symbol, string name) =>
613619
symbol is not null && symbol.GetAttributes().Any(a =>
614620
a.AttributeClass?.Name == name || a.AttributeClass?.Name == name + "Attribute");
615621

616-
private static bool IsToken(ITypeSymbol? type) => HasAttribute(type, "ProtocolToken");
622+
// The two attributes are matched by NAME, so the names are reserved — but only in the
623+
// SHAPE the profile gives them. A codebase that never declared a state protocol can own an
624+
// attribute called ProtocolToken (a wire-protocol token class, say), and a name alone must
625+
// not turn its scan into a refusal. So:
626+
//
627+
// a state token is a REF STRUCT marked [ProtocolToken];
628+
// a region entry is a method marked [ProtocolRegion] that TAKES A DELEGATE.
629+
//
630+
// Anything else carrying one of the names is not part of a protocol and is left alone.
631+
// The shapes are deliberately the widest ones that still catch a protocol declared wrongly:
632+
// a marked region entry whose callback does not take a token, or takes a marked type that
633+
// is not a ref struct, is still a region entry and is refused where it is used.
634+
635+
/// Marked [ProtocolToken], whatever it is. Only for saying WHY a marked type is not a token.
636+
private static bool HasTokenName(ITypeSymbol? type) => HasAttribute(type, "ProtocolToken");
637+
638+
private static bool IsToken(ITypeSymbol? type) =>
639+
type is { IsRefLikeType: true } && HasTokenName(type);
640+
641+
private static bool IsRegionEntry(IMethodSymbol? method)
642+
{
643+
if (method is null)
644+
return false;
645+
var declared = (method.ReducedFrom ?? method).OriginalDefinition;
646+
// an UNRESOLVED parameter type may be the callback of a degraded scan: keep it in
647+
return HasAttribute(declared, "ProtocolRegion")
648+
&& declared.Parameters.Any(p => p.Type.TypeKind is TypeKind.Delegate or TypeKind.Error);
649+
}
617650

618-
private static bool IsRegionEntry(IMethodSymbol? method) =>
619-
method is not null && HasAttribute((method.ReducedFrom ?? method).OriginalDefinition,
620-
"ProtocolRegion");
651+
private const string ReservedNames =
652+
" (the attribute names ProtocolRegion and ProtocolToken are reserved in these shapes: a method marked [ProtocolRegion] that takes a delegate is a region entry, a ref struct marked [ProtocolToken] is a state token)";
621653

622654
private static bool IsApiType(INamedTypeSymbol? type) =>
623655
type is not null && (IsToken(type)
@@ -789,7 +821,7 @@ private static void LowerRegion(Ctx ctx, InvocationExpressionSyntax entry, List<
789821
{
790822
var args = entry.ArgumentList.Arguments;
791823
if (args.Count != 2)
792-
throw Refuse(ctx, entry, "a protocol region entry takes (entity, callback)");
824+
throw Refuse(ctx, entry, "a protocol region entry takes (entity, callback)" + ReservedNames);
793825
var entityExpr = Unparen(args[0].Expression);
794826
var entity = ctx.Model.GetSymbolInfo(entityExpr).Symbol;
795827
if (entityExpr is not IdentifierNameSyntax || entity is not (ILocalSymbol or IParameterSymbol))
@@ -809,9 +841,9 @@ private static void LowerRegion(Ctx ctx, InvocationExpressionSyntax entry, List<
809841
};
810842
if (parameter is null
811843
|| ctx.Model.GetDeclaredSymbol(parameter) is not IParameterSymbol token
812-
|| !IsToken(token.Type))
844+
|| !HasTokenName(token.Type))
813845
throw Refuse(ctx, entry,
814-
"the callback of a protocol region takes exactly one protocol token");
846+
"the callback of a protocol region takes exactly one protocol token" + ReservedNames);
815847
// A token that is not a ref struct can be captured by a nested lambda, stored in a
816848
// field, or carried across an await — every one of which lets it reach ANOTHER
817849
// region, the shape the core cannot check. The language forbids all three for a

‎frontend/roslyn/README.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,18 @@ SQL, another process. Those belong to concurrency tokens, constraints and
118118
transactions (two of them are pinned as stated limits under
119119
`protocol-samples/efcore/known-gaps`).
120120

121+
**Reserved names.** Matching by name makes `ProtocolToken` and `ProtocolRegion`
122+
names the profile owns — but only in its own shapes: a **ref struct** marked
123+
`[ProtocolToken]` is a state token, a method marked `[ProtocolRegion]` **that
124+
takes a delegate** is a region entry. An attribute with one of these names on
125+
anything else (a class, a plain struct, a record, a method with no callback)
126+
belongs to somebody else's code and is ignored, so a codebase that declares no
127+
state protocol is not refused over a name. The two shapes themselves stay
128+
reserved: an unrelated attribute of the same name on a ref struct, or on a method
129+
that takes a delegate, is read as a protocol declared wrongly and refused — the
130+
profile cannot tell the two apart, and a mis-declared protocol must not go
131+
unanalysed.
132+
121133
**The trust boundary.** The types that DECLARE a protocol — the tokens, and the
122134
type holding a region entry — are its trusted definition surface: they construct
123135
tokens, enter regions and implement transitions, and their bodies are not
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
namespace Net.Wire;
2+
3+
// [ProtocolToken] on everything a state token is NOT: a class, a plain struct, a record, an
4+
// enum, an interface. They are created, copied and passed around like any other value. A
5+
// state token is a REF STRUCT, so none of these is one, and the scan has nothing to say.
6+
[ProtocolToken]
7+
public sealed class SessionToken
8+
{
9+
public string Value = "";
10+
}
11+
12+
[ProtocolToken]
13+
public struct Ticket
14+
{
15+
public int Id;
16+
}
17+
18+
[ProtocolToken]
19+
public sealed record Hello(int Version);
20+
21+
[ProtocolToken]
22+
public enum FrameKind { Data, Control }
23+
24+
[ProtocolToken]
25+
public interface IFrame
26+
{
27+
int Size { get; }
28+
}
29+
30+
public static class U1TokenNameOnOrdinaryTypes
31+
{
32+
public static int Read(Ticket ticket) => ticket.Id;
33+
34+
public static int Run(FrameKind kind)
35+
{
36+
var session = new SessionToken { Value = "x" };
37+
var hello = new Hello((int)kind);
38+
var copy = new Ticket { Id = hello.Version };
39+
var again = copy;
40+
return Read(again) + session.Value.Length;
41+
}
42+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
namespace Net.Wire;
2+
3+
// [ProtocolRegion] on methods that take no delegate: there is no callback, so there is no
4+
// region body and nothing a region entry could mean. Called from their own class and from
5+
// another one, as a statement and inside an expression.
6+
public static class Frames
7+
{
8+
[ProtocolRegion]
9+
public static int Parse(byte[] frame) => frame.Length;
10+
11+
[ProtocolRegion]
12+
public static void Reset(byte[] frame, int offset)
13+
{
14+
frame[offset] = 0;
15+
}
16+
17+
public static int Own() => Parse(new byte[4]);
18+
}
19+
20+
public static class U2RegionNameWithoutACallback
21+
{
22+
public static int Run()
23+
{
24+
var frame = new byte[8];
25+
Frames.Reset(frame, 0);
26+
return Frames.Parse(frame) + Frames.Own();
27+
}
28+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
using System;
2+
3+
namespace Net.Wire;
4+
5+
// THE RESIDUAL, pinned. [ProtocolRegion] on a method that DOES take a delegate has exactly the
6+
// shape of a region entry, and the profile cannot tell it from a protocol whose author forgot
7+
// to mark the token — which must stay a refusal, or a mis-declared protocol would go
8+
// unanalysed in silence. So this collision is still refused; the message says which names are
9+
// reserved and in which shapes.
10+
public static class Bytes
11+
{
12+
[ProtocolRegion]
13+
public static void Each(byte[] frame, Action<byte> body)
14+
{
15+
foreach (var b in frame)
16+
body(b);
17+
}
18+
}
19+
20+
public static class U3RegionNameWithACallback
21+
{
22+
public static int Run()
23+
{
24+
var frame = new byte[4];
25+
var sum = 0;
26+
Bytes.Each(frame, b => { sum += b; });
27+
return sum;
28+
}
29+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
namespace Net.Wire;
2+
3+
// THE OTHER RESIDUAL, pinned. A REF STRUCT marked [ProtocolToken] is exactly what a state
4+
// token is. Creating one by hand is the forgery the boundary exists to refuse, and the
5+
// profile cannot know that this one belongs to somebody else. Still refused, with the
6+
// reserved-name note.
7+
[ProtocolToken]
8+
public ref struct Cursor
9+
{
10+
public int Position;
11+
}
12+
13+
public static class U4TokenNameOnARefStruct
14+
{
15+
public static int Run()
16+
{
17+
var cursor = new Cursor { Position = 3 };
18+
return cursor.Position;
19+
}
20+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
using System;
2+
3+
namespace Net.Wire;
4+
5+
// Somebody else's attributes, with the profile's names. A networking library that talks about
6+
// "protocol tokens" and "protocol regions" has every right to them, and has never heard of
7+
// Own.NET. Every program in this directory is scanned WITH this file and WITHOUT the sample
8+
// protocol: it is a codebase that declares no state protocol at all.
9+
[AttributeUsage(AttributeTargets.All)]
10+
public sealed class ProtocolTokenAttribute : Attribute { }
11+
12+
[AttributeUsage(AttributeTargets.All)]
13+
public sealed class ProtocolRegionAttribute : Attribute { }
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"U1_token_name_on_ordinary_types": null,
3+
"U2_region_name_without_a_callback": null,
4+
"U3_region_name_with_a_callback": "the callback of a protocol region takes exactly one protocol token (the attribute names ProtocolRegion and ProtocolToken are reserved in these shapes",
5+
"U4_token_name_on_a_ref_struct": "a protocol token 'Cursor' is created outside the protocol's own types"
6+
}

‎scripts/protocol_gate.py‎

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
The Layer 2/3 ledgers freeze the facts the Roslyn lowering emits for the state-protocol
55
surface, and both engines replay them with zero `dotnet`. This script is the other half: it
66
runs the REAL extractor over the C# those facts came from, so a frozen fact can never drift
7-
away from the program it claims to describe. Five questions (needs `dotnet` on PATH; zero
7+
away from the program it claims to describe. Six questions (needs `dotnet` on PATH; zero
88
Python dependencies). Every path below is under `frontend/roslyn/protocol-samples/`:
99
1010
1. **cases** — every `cases/<Case>.cs` is run through the extractor (with the sample API
@@ -26,7 +26,15 @@
2626
it all the same: a token made by hand, the protocol's state written past every token. A
2727
control with a null expectation must be ACCEPTED: a mention is not an operation.
2828
29-
5. **efcore** — `efcore/` is an ordinary ASP.NET Core + EF Core backend. It must run (its
29+
5. **unrelated** — every `unrelated/<Case>.cs.txt` is a program from a codebase that declares
30+
NO state protocol and happens to own attributes named `ProtocolToken` / `ProtocolRegion`
31+
(`unrelated/WireAttributes.cs.txt`). The names are reserved only in the profile's shapes —
32+
a ref struct, a method that takes a delegate — so a case with a null expectation must be
33+
ACCEPTED, with not one region lowered, alone and beside the sample protocol. The two
34+
cases that do have the reserved shape are pinned as refusals: the residual is stated, not
35+
discovered.
36+
37+
6. **efcore** — `efcore/` is an ordinary ASP.NET Core + EF Core backend. It must run (its
3038
acceptance runner drives real HTTP against real SQLite); the extractor must lower its
3139
handlers to the committed `tests/fixtures/lowered/typestate_ef_orderbackend.facts.json`
3240
with a clean verdict, from the project file alone; a copy whose handlers rely on IMPLICIT
@@ -273,6 +281,78 @@ def same_assembly(dll: str, fails: list[str]) -> int:
273281
return len(on_disk)
274282

275283

284+
def _lowers_a_region(facts: dict[str, object]) -> bool:
285+
def walk(nodes: object) -> bool:
286+
return isinstance(nodes, list) and any(
287+
isinstance(n, dict) and (n.get("op") == "borrow_mut" or walk(n.get("body"))
288+
or walk(n.get("then")) or walk(n.get("else")))
289+
for n in nodes)
290+
functions = facts.get("functions")
291+
return isinstance(functions, list) and any(
292+
isinstance(fn, dict) and walk(fn.get("body")) for fn in functions)
293+
294+
295+
def unrelated(dll: str, fails: list[str]) -> int:
296+
"""A codebase with no state protocol, and attributes that share the profile's names.
297+
298+
The programs compile on their own (with `WireAttributes.cs.txt`). A null expectation
299+
means the scan must ACCEPT the program — exit 0, a clean verdict, no region lowered —
300+
both alone and with the sample protocol in the same scan; a text means the program has
301+
the reserved shape and the refusal carrying that text is the pinned residual."""
302+
here = os.path.join(PROTO, "unrelated")
303+
with open(os.path.join(here, "expected.json"), encoding="utf-8") as f:
304+
expected = json.load(f)
305+
shared = "WireAttributes"
306+
on_disk = sorted(n[:-7] for n in os.listdir(here) if n.endswith(".cs.txt") and n[:-7] != shared)
307+
if on_disk != sorted(expected):
308+
fails.append(f"unrelated: expected.json {sorted(expected)} != sources {on_disk}")
309+
return 0
310+
with tempfile.TemporaryDirectory() as tmp:
311+
one = os.path.join(tmp, "Wire")
312+
os.makedirs(one)
313+
for case in [shared, *on_disk]:
314+
shutil.copyfile(os.path.join(here, f"{case}.cs.txt"), os.path.join(one, f"{case}.cs"))
315+
with open(os.path.join(one, "Wire.csproj"), "w", encoding="utf-8") as f:
316+
f.write(_PROJECT.format(items='<Compile Include="*.cs" />'))
317+
built = _run(["dotnet", "build", one, "-nologo", "-v", "q"], cwd=tmp)
318+
if built.returncode != 0:
319+
codes = sorted(set(re.findall(r"error (CS\d+)", built.stdout)))
320+
fails.append(f"unrelated: the programs do not build: {codes}")
321+
attributes = os.path.join(one, f"{shared}.cs")
322+
for case in on_disk:
323+
src = os.path.join(one, f"{case}.cs")
324+
want = expected[case]
325+
scans = [("alone", [attributes, src])]
326+
if want is None:
327+
scans.append(("beside the sample protocol", [API_REL, attributes, src]))
328+
for label, inputs in scans:
329+
out = os.path.join(tmp, f"{case}.json")
330+
if os.path.exists(out):
331+
os.remove(out)
332+
done = _run(["dotnet", dll, *inputs, "--flow-locals", "-o", out])
333+
where = f"unrelated/{case} ({label})"
334+
if want is None:
335+
if done.returncode != 0:
336+
fails.append(f"{where}: a codebase with no state protocol was not "
337+
f"accepted (exit {done.returncode}): "
338+
f"{done.stderr.strip()[-300:]}")
339+
continue
340+
with open(out, encoding="utf-8") as f:
341+
facts = json.load(f)
342+
got = _verdict(facts)
343+
if got != []:
344+
fails.append(f"{where}: verdict {got!r}, expected clean")
345+
if _lowers_a_region(facts):
346+
fails.append(f"{where}: a region was lowered from a name alone")
347+
elif done.returncode != 2:
348+
fails.append(f"{where}: the extractor exited {done.returncode}; the "
349+
f"reserved shape is pinned as a refusal (2)")
350+
elif want not in done.stderr:
351+
fails.append(f"{where}: refusal text lacks {want!r}: "
352+
f"{done.stderr.strip()[-300:]}")
353+
return len(on_disk)
354+
355+
276356
EF = os.path.join(PROTO, "efcore")
277357
EF_REL = f"{SAMPLES_REL}/efcore/OrderBackend"
278358
EF_PROJECT = f"{EF_REL}/OrderBackend.csproj"
@@ -502,13 +582,15 @@ def main() -> int:
502582
n_refused = refused(dll, fails)
503583
n_nocompile = does_not_compile(fails)
504584
n_same = same_assembly(dll, fails)
585+
n_unrelated = unrelated(dll, fails)
505586
n_ef = efcore(dll, write, fails)
506587
n_rust = rust_cli(rust, fails) if rust is not None else 0
507588
for f in fails:
508589
print(f"FAIL: {f}")
509590
print(f"protocol gate: {n_cases} cases lowered from C# and judged, {n_refused} refused by "
510591
f"the lowering, {n_nocompile} rejected by the C# compiler, {n_same} same-assembly "
511-
f"programs held to the boundary, {n_ef} checks on the real "
592+
f"programs held to the boundary, {n_unrelated} programs with the reserved names and "
593+
f"no protocol, {n_ef} checks on the real "
512594
f"ASP.NET Core + EF Core backend"
513595
+ (f", {n_rust} documents byte-identical on both CLIs" if rust else "")
514596
+ f"; {len(fails)} failure(s)" + (" [fixtures written]" if write else ""))

0 commit comments

Comments
 (0)