I don't have a solution, only a workaround:

```
undoCellContext[expr_] := Module[{ccSymbols, rules},
  ccSymbols = 
   Cases[expr, _Symbol, \[Infinity]] // DeleteDuplicates // 
    Select[StringStartsQ["$CellContext`"]@*ToString];
  rules = (# -> Symbol["Global`"<>SymbolName[#]]) & /@ ccSymbols;
  expr /. rules
  ]

Once[...,"Notebook"] // undoCellContext
```

As an optimization, when `LeafCount[expr]` is suitably large, `undoCellContext` could switch from building a list and deleting duplicates to populating a HashSet within the delayed rule given to Cases.

It would also perhaps help if `undoCellContext` held the expression, and the replacement symbols, unevaluated until it was done with it.

We could "Fix" Once on versions where it's misbehavinbg:

```
If[$VersionNumber<=14.,
  Unprotect[Once];
  Once`Private`$overridden = True;
  Once[expr__,"Notebook"] := Block[{Once`Private`$overridden = False},
    Once[expr, "Notebook"] // undoCellContext
  ] /; Once`Private`$overridden
  Protect[Once];
]
```

For reference, `Once` is not guilty. The underlying problem is that `PersistentObject[...,"Notebook"]["Value"]` converts <code>Global`</code> symbols to <code>$CellContext`</code> on readout.