Do `Cancelled` exceptions "know" which block they belong to?

The first paragraph of “Cancellation semantics” in the docs is this:

You can freely nest cancellation blocks, and each Cancelled exception “knows” which block it belongs to. So long as you don’t stop it, the exception will keep propagating until it reaches the block that raised it, at which point it will stop automatically.

Hmm, hang on a minute. Surely cancelled exceptions don’t need to know what block they came from? When a cancelled block finishes (i.e., its __exit__() method is called), it can just look through all its parent scopes (stopping when it gets to a shielded one) and propagate a cancel if one of those is cancelled? In fact, I think it needs to do that in case a parent block has been cancelled in the code since an inner cancellation has been raised (which is either sync-only or could be shielded async code):

async def possible_cancel_bug():
    with trio.CancelScope() as outer_scope:
        with trio.CancelScope() as inner_scope:
            await trio.sleep(0.1)
            inner_scope.cancel()
            print("inner cancelled")
            try:
                await trio.sleep(0.1)
            finally:
                outer_scope.cancel()
                print("outer cancelled")
            print("should not be here inner")
        print("should not be here outer")
    print("all done")

I was pretty confident that this function would not print “should not be here inner” because await trio.sleep(0.1) will have raised a Cancelled due to the cancellation of the inner cancel scope. But I thought, based on that paragraph in the docs, that “should not be here outer” might get printed, because that Cancelled “knows” that it’s associated with the inner cancel block, so it would get swallowed by the inner scope.

In fact “should not be here outer” is NOT printed. Phew! (Although it would not have been the worst problem ever, given that the next await in the outer scope would then have raised.) Also, looking in a debugger, there does not appear to be any hidden attribute of the Cancelled() exception that could point at a particular block.

So is that text in the docs really right? Maybe it’s describing an old implementation that has changed since. I suppose you could argue that, when the outer block is cancelled, then the Cancelled now implicitly “belongs to” that outer block and it “knows” about it by virtue of code in CancelScope().__exit__() (rather than an explicit attribute on the exception object). Perhaps something more like this would be clearer (or, maybe it would be more correct but less clear!):

You can freely nest cancellation blocks. So long as you don’t stop it, each Cancelled exception will keep propagating up to the outermost cancellation scope that is currently cancelled (not including those outside an enclosing shielded cancellation scope – see below).

(Side note: it occurred to me to check this while looking into races and bugs in asyncio’s task groups, including this one: Nested TaskGroup can silently swallow cancellation request from parent TaskGroup.)

Ah ha! I tried this out with various old versions of Trio and found that it prints “should not be here outer” in 0.10.0 but does not in 0.11.0. Sure enough, there’s an entry in the release notes for 0.11.0:

trio.Cancelled exceptions now always propagate until they reach the outermost unshielded cancelled scope, even if more cancellations occur or shielding is changed between when the Cancelled is delivered and when it is caught. (#860)

That text in the docs was there in 0.10.0 docs so it just wasn’t updated when the behaviour changed.