✅ Prove Workspace effects survive a real host crash (#365 slice 7) - #419
Conversation
PR #419: ✅ Prove Workspace effects survive a real host crash (#365 slice 7)13 files, +1515 / -21 Scope🔴 PR has 1536 lines changed. Split into focused PRs. 🟡 1536 lines changed. PRs under 400 receive more thorough review. 🟡 Changes span 6 directories. 🟡 PR mixes config and source changes. Structural🟡 1 console statements. Slop✅ Slop indicators look low. Static AnalysisOxlint: 2 diagnostics across 2 files (1 rule) no-floating-promises (2): packages/workflow/tests/support/workspace-restart-child.ts, packages/workflow/tests/support/workspace-crash-child.ts CorrectnessNo extraneous code patterns detected. |
Every other atomic-Workspace proof ends its transaction inside the test process, where Effection tears the scope down. A crash does none of that: it leaves an open SQLite transaction with nobody to roll it back, and what the database holds afterwards is SQLite's recovery rather than anything the adapter runs. So this is made of real processes. One resumes a committed run, performs a real Workspace effect, and stops at the accepted construction-time routed-append hook with the mutation, the immutable root, the current-root pointer and the routed journal row all written and none of them committed. It says so on standard output, a second connection is shown seeing only the baseline, and then it is killed with SIGKILL. A different process, through a newly installed production provider, finds the baseline filesystem, root, retained counts and journal exactly. Two more processes commit a Workspace history and reconstruct it cold: the second observes the committed filesystem, current root, ordered events, event identities and event-to-root associations without performing a recorded effect again, then selects the older event's root through the adapter-private materializer and rebuilds its topology, bytes, modes, hardlinks and symbolic links from the DOFS content that root retains. The crash child assembles the adapter's own modules rather than calling `useWorkflowRunStorage`, because the routed-append hook is installed when the connection registry is constructed and the provider constructs its own. The inspector and both restart processes use the provider. No production behavior changes.
The boundary check read two files and searched them for eight names, which answered a smaller question than the one it was named after: whether any shared module of the coordination surface names a host at all. It now globs that surface — the workflow package outside its Deno adapter, and the durable-stream coordination modules — and refuses storage and runtime implementation types, the adapter's private connection, savepoint and transaction-token identities, runtime detection, process globals, and imports that reach an adapter, a vendored source or a host process. Two things make the sweep answerable. It reads code rather than the file: these modules explain in their own prose that they name no host, and a substring search finds the explanation. And it proves it can fail before it runs, on a crossing and on a comment that only looks like one, because a glob that matched nothing would report the cleanest boundary of all. The list of what it must have found is written down, so a module that stops being matched fails instead of quietly leaving the surface.
728d96f to
6b265ff
Compare
DLC13 claimed the shared coordination surface names no host, and its own glob
already covered a module that imported one:
packages/workflow/src/run.ts
import { randomUUID } from "node:crypto";
The detector listed `node:sqlite`, `node:process` and `node:child_process` —
the host modules somebody had thought of — so the import that was actually
there went unreported and a green run confirmed the blind spot instead of the
boundary.
A module specifier only one host can resolve is now recognized by its shape:
the `node:`, `bun:`, `deno:` and `cloudflare:` schemes, an adapter path, a
vendored source, a host process. Specifiers are read from the import forms
rather than matched as text, so `node:crypto` in a comment or a string is
prose and `import … from "node:crypto"` is a crossing — both asserted, along
with the `node:crypto` case that this test used to miss.
Shared code allocates a run id through Web Crypto, which every supported
runtime resolves, the way `createFingerprinter` already draws its key. The id
is still allocated with cryptographic randomness; only the module that
provides it stops naming a host.
The new recovery prose said a killed process "leaves its transaction open", which describes something that cannot happen: the process is gone, so the operating system closes its connection and releases every lock it held. What is left is a database the next connection recovers, not a transaction still running somewhere. It also timed the guarantee to the writer's life — "while the process is alive" — when what a second connection actually observes is the last committed state for as long as the writer's transaction is uncommitted. That is the ordinary isolation boundary rather than a rule that only applies to crashes, and saying it the other way invites a reader to expect a special case.
| const pinnedCommit = yield* revParse(`${base}^{commit}`); | ||
| return { runId: randomUUID(), base, pinnedCommit }; | ||
| // Web Crypto rather than `node:crypto`: a run id is allocated in shared | ||
| // code, which names no host. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // code, which names no host. |
| return; | ||
| } | ||
| // Every read below is on the connection that opened the transaction, so | ||
| // it sees that transaction's own uncommitted writes. Nothing else can. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // it sees that transaction's own uncommitted writes. Nothing else can. |
| }); | ||
| // Deno leaves when its event loop is empty, and a suspended Effection | ||
| // task is not on it. A timer nothing clears is what keeps this process | ||
| // and its open transaction alive until the signal arrives. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // and its open transaction alive until the signal arrives. |
| // and its open transaction alive until the signal arrives. | ||
| setInterval(() => {}, 1_000); | ||
| // The transaction stays open from here until the operating system takes | ||
| // this process away, which is what the process is for. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // this process away, which is what the process is for. |
| function* workflow(): Workflow<void> { | ||
| // The run this process resumes already holds this effect's result, so it | ||
| // replays. Executing it would mean the crash effect below is not the | ||
| // first live work of the process, and the count says which happened. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // first live work of the process, and the count says which happened. |
|
|
||
| main(function* () { | ||
| // `process.argv` rather than `Deno.args`: this file is Deno-only to run, and | ||
| // still has to typecheck under the Node project like every other source. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // still has to typecheck under the Node project like every other source. |
DLC13 claimed a fail-closed runtime-neutral boundary and asked a regular
expression to find it. Module loading is not a pattern, so four different
things went the wrong way at once:
await import(`node:crypto`) // a template literal it never matched
await import("node:crypto") // an escape it never decoded
const t = "node:crypto";
await import(t) // a destination it could not read
const note = `import "node:crypto"`; // prose it rejected as an import
The scan now reads parsed syntax: static imports and re-exports, `import
type`, type-position `import()`, dynamic `import()`, `import =` and
`require()`. The parser hands back decoded specifiers, so an escape and a
no-substitution template are the module they name, and characters that only
look like an import — in a comment, a string, a template — load nothing and
are reported as nothing.
A specifier this surface computes is refused rather than skipped. Nothing can
show it is not a host module, and a boundary that admits what it cannot read
is not a boundary.
The scan also refuses to be quiet about its own failure: a module whose text
imports something must yield a specifier, so a parse that stopped working
reports every file as clean exactly once and then fails.
Host schemes, adapter paths, vendored sources and host processes are still
classified by shape rather than by an enumerated list.
DLC13 said it rejected process globals and runtime adapter imports by shape,
and did neither:
const pid = process.pid; // reported nothing
import "./node.ts"; // reported nothing
The globals were a substring list, and `process` could not go on it: `Bun` is
inside `Bundle` and `Deno` inside `Denominator`, so text matching would have
started rejecting words for their spelling. They are read from the parse
instead — an identifier that refers to the binding it names, so `preprocessor`,
`x.process` and `{ process: 1 }` are not uses of a host and `crypto` is a
standard rather than a host at all.
The adapter rule knew `/deno/` and `/deno.ts`, which is the adapter that
happens to exist. Code Rule 12 names four of them for the CLI alone, so any
whole path segment naming a runtime this repository builds an entry point for
is one. Segments are compared whole for the same reason the globals are parsed:
`nodes/`, `bundle.ts` and `vendors/` contain a runtime's name without being one.
Both recognitions are held by mutation. Deleting the global scan fails on
`process.pid`; narrowing the adapter rule back to Deno fails on `./node.ts`.
The crash suite's and crash child's opening comments said a killed process
leaves SQLite holding a transaction. The transaction is open at the kill point,
`SIGKILL` runs no application cleanup, the operating system closes the
connection and releases its locks, and the next connection recovers the
interrupted transaction to the last committed state — which is what they now
say, matching architecture.md.
| const pinnedCommit = yield* revParse(`${base}^{commit}`); | ||
| return { runId: randomUUID(), base, pinnedCommit }; | ||
| // Web Crypto rather than `node:crypto`: a run id is allocated in shared | ||
| // code, which names no host. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // code, which names no host. |
|
|
||
| main(function* () { | ||
| // `process.argv` rather than `Deno.args`: this file is Deno-only to run, and | ||
| // still has to typecheck under the Node project like every other source. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // still has to typecheck under the Node project like every other source. |
The global scan asked whether an identifier was spelled `process` and whether
it sat in a declaration position. Neither question is the one that matters, so
three ordinary things were reported as crossings:
function inspect(process: { pid: number }) { return process.pid; }
const Buffer = 1; const b = Buffer;
import { Deno } from "./host.ts"; Deno.cwd();
A parameter, a local and an import named `process` are not the host's
`process`. What separates them from the real thing is not spelling or position
but what declared the name, so the scan now walks the enclosing scope chain
outward from each reference and reports only the ones nothing declared.
Shadowing therefore ends where its scope does: a parameter named `process`
covers its own function and no more, and a `const Deno` inside a block leaves
the reference after that block still reported.
Three mutations hold the classifier now. Dropping binding resolution reports
`process` for the parameter case; deleting the global scan misses
`process.pid`; narrowing adapter recognition back to Deno misses `./node.ts`.
| const pinnedCommit = yield* revParse(`${base}^{commit}`); | ||
| return { runId: randomUUID(), base, pinnedCommit }; | ||
| // Web Crypto rather than `node:crypto`: a run id is allocated in shared | ||
| // code, which names no host. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // code, which names no host. |
| }); | ||
| // Deno leaves when its event loop is empty, and a suspended Effection | ||
| // task is not on it. A timer nothing clears is what keeps this process | ||
| // and its open transaction alive until the signal arrives. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // and its open transaction alive until the signal arrives. |
| // and its open transaction alive until the signal arrives. | ||
| setInterval(() => {}, 1_000); | ||
| // The transaction stays open from here until the operating system takes | ||
| // this process away, which is what the process is for. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // this process away, which is what the process is for. |
The resolver knew a handful of node shapes rather than the language's binding
rules, so four ordinary declarations were still read as ambient host globals:
const { process: local } = deps;
import { Deno as portable } from "./host.ts";
function read<Deno>(value: Deno): Deno { return value; }
function read() { if (ready) var process = portable; return process.pid; }
Each is a different rule. An aliased member is a label on both sides — the
name being taken and the name it is given — and neither reads a binding. A type
parameter binds its own declaration's type scope. And `var` belongs to the
containing function however deeply the statement that writes it is nested, so
collecting a block's own statements could never find it.
All four are now decided by what the language says declares a name: alias
property names are labels, type parameters join the scope of the function,
class, interface or alias that introduces them, and every `var` in a function
body is hoisted to the function, skipping the nested functions and classes that
own their own.
Shadow termination is unchanged and still asserted: a type parameter covers its
own signature, a hoisted `var` covers its own function, and a reference after
either is reported.
Reverting to the previous resolver false-positives on all seven newly covered
categories — both alias forms, the re-export alias, function and interface type
parameters, and `var` hoisted out of an `if` and out of a `for` — while
`process.pid` and `Deno.cwd()` stay reported.
Four rounds of review found four more declaration forms the hand-written
resolver did not know, and this round found six: `import =`, `namespace`,
mapped-type and `infer` type parameters, statement labels, and accessor
members. The pattern is the defect. A boundary that claims to follow the
language's binding rules cannot be a growing inventory of the declaration
shapes somebody remembered.
Resolution is now the compiler's. Each scanned source becomes a one-file
`ts.Program` with no lib and no module resolution, and every candidate
identifier is handed to the type checker: a name is the host's when nothing in
the file declares it. Value scopes and type scopes, hoisting, aliasing,
namespaces, mapped and inferred type parameters, accessors and shadowing all
come from the compiler, and no rule about any of them is written here.
What stays is one syntactic question the checker cannot be asked: whether an
identifier refers to a binding at all. The member in `x.process`, the loop
label in `break process`, the imported member in `{ Deno as portable }` and
the key in `{ process: local }` are labels, not references — the language says
so, and there are only these positions.
The architecture is what the mutation now discriminates. Restoring the manual
resolver reports a crossing for every one of the six forms above, while the
compiler reports none and both still report `process.pid`, `Deno.cwd()` and a
reference outside its shadow.
| const pinnedCommit = yield* revParse(`${base}^{commit}`); | ||
| return { runId: randomUUID(), base, pinnedCommit }; | ||
| // Web Crypto rather than `node:crypto`: a run id is allocated in shared | ||
| // code, which names no host. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // code, which names no host. |
|
|
||
| main(function* () { | ||
| // `process.argv` rather than `Deno.args`: this file is Deno-only to run, and | ||
| // still has to typecheck under the Node project like every other source. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // still has to typecheck under the Node project like every other source. |
`{ process }` writes one name in two roles: the property the object literal
declares and the value it reads. The ordinary symbol at that identifier is the
property, and the property is declared right there — so asking for it answered
that every host global becomes locally declared the moment it is put in an
object.
const environment = { process }; // reported nothing
const runtimes = { Deno, Bun }; // reported nothing
That direction is the dangerous one. The scan is meant to fail closed, and this
made it quietly admit the exact read it exists to catch, in the shortest way
anyone would write it.
A shorthand assignment's value symbol is what the name refers to, so that is
what the scan now asks for. A declared `process` used as `{ process }` still
resolves to its declaration and is still accepted, and `const { process } =
deps` is a binding element rather than a shorthand assignment and was never
this question.
Replacing the value symbol with the ordinary one reports nothing for
`{ process }`, which is the mutation this keeps.
| const pinnedCommit = yield* revParse(`${base}^{commit}`); | ||
| return { runId: randomUUID(), base, pinnedCommit }; | ||
| // Web Crypto rather than `node:crypto`: a run id is allocated in shared | ||
| // code, which names no host. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // code, which names no host. |
| // and its open transaction alive until the signal arrives. | ||
| setInterval(() => {}, 1_000); | ||
| // The transaction stays open from here until the operating system takes | ||
| // this process away, which is what the process is for. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // this process away, which is what the process is for. |
Two more label positions were being read as ambient globals:
type Pair = [process: string, Deno?: number];
import data from "./x.json" with { process: "portable" };
Both were omissions of the same kind as the last several: the classifier
listed the parent node kinds someone had thought of, so every position it had
not been shown was a reference by default.
TypeScript already draws this line structurally. An `IdentifierName` fills a
`name`, `propertyName` or `label` slot of the node that owns it — and a
qualified name's `right` is the same thing in type position — while an
`IdentifierReference` appears anywhere else. Asking which slot the identifier
occupies replaces the list of kinds with the rule the grammar uses, so named
tuple elements, import attributes, enum members, property signatures and every
declaration's own name are labels because of what they are rather than because
they were reported.
The shorthand property remains the single name slot that is also a read, and
is still resolved to the binding it refers to.
Each classification is held on its own: treating a named tuple member as a
reference reports `["process", "Deno"]` for the tuple, and treating an import
attribute as one reports `["process"]` for the attribute, while
`type Pair = [value: typeof process]` and the `node:fs` beside an attribute
stay reported either way.
Why
Issue #365 claims that one Workspace expansion produces one effect and one
caller-owned SQLite transaction, and that host interruption publishes none of
it. Every proof of that claim so far ends its transaction inside the test
process, where Effection tears the scope down — a cancellation, a failure or a
closed scope, never a crash. At the kill point the transaction is open;
SIGKILLthen runs no application cleanup, so nothing commits and nothingrolls back. The operating system closes the connection and releases its locks,
and the next connection to open the database observes recovery to the last
committed state — which is what decides the outcome, rather than anything the
adapter executes.
This is #365 slice 7, the final proof layer. It adds no product behavior — but
it does remove one host import from shared source, because the boundary test
this PR strengthens found it.
What changes
Before:
(WAC6, WAC13, WAC14). Restart was proven for the ordinary journal only
(WJ25). Historical restoration was proven inside one process (WRR4).
After:
SIGKILLduring an uncommitted effect, a cold restart of a committed Workspace, and a
cold reconstruction of an older event's root.
parsing it, recognizes host modules by shape rather than by an enumerated
list, refuses destinations it cannot read statically, and proves it can fail
before it runs.
packages/workflow/src/run.tsallocates its run id through Web Cryptoinstead of
node:crypto, so shared source names no host.How it works
The crash timeline
baselineExecutions: 0)BEGIN IMMEDIATE; mutation savepoint writes/crash.txtthrough DOFScurrent_rootpublished, secret gate runs (gateCalls: 1)transaction.journalafterRoutedJournalAppendreports and suspends; the transaction is open and never reachesCOMMITprocess.kill(pid, "SIGKILL")— no application cleanup runs; the OS closes the connection and releases its locks.join()reportssignal: "SIGKILL", no exit codeThe child's readings at step 6 and the parent's at step 7 are of one database at
one moment, through the connection that made the writes and through a
connection that did not:
/crash.txtcurrent_rootReview guide
Start with:
packages/workflow/tests/workspace-crash-recovery.test.tsThen review:
tests/support/workspace-crash-child.ts— the killed process and the inspectortests/support/workspace-restart-child.ts— commit, cold read, cold restorationtests/workspace-effect.test.tsDLC13 — the boundary sweeppackages/workflow/src/run.ts— the one production changescripts/runtime-test-exclusions.ts, specs,architecture.mdLook carefully at:
useWorkflowRunStorage. The routed-append hook is installed when theconnection registry is constructed, and the provider constructs its own; the
child needs both that hook and the authoritative connection to read
uncommitted rows from. The inspector and both restart processes use the
provider itself.
durableRun. A journal holding a Close is a run with nothing left toexecute, so the crash child's effect would never run at all.
empties, and a suspended Effection task is not on it.
run.tsnow callscrypto.randomUUID(). Web Crypto is the cross-runtimestandard and
packages/core/src/secrets/findings.tsalready draws its keythe same way, so this needs no new abstraction or contextual capability. Run
ids remain cryptographically random.
What must stay true
slice-6 coordinator, checked across a process boundary by WAC24.
short-lived raw read-only
DatabaseSyncthat distinguishes committed fromuncommitted visibility.
structure, checked by DLC13 over the globbed surface.
process,Deno,Bun,Buffer,globalThis,navigator,__dirnameand__filenameare recognized by theTypeScript compiler: each scanned source becomes a one-file
ts.Programwithno lib and no module resolution, and a name is the host's when the type
checker finds nothing in the file declaring it. Value and type scopes,
hoisting, aliasing,
namespace,import =, mapped andinfertypeparameters, accessors and shadowing therefore follow the language rather than
a list kept in the test, and cross-runtime Web APIs such as
cryptoarenever crossings. A shorthand property is asked for its value symbol rather
than its property symbol, because
{ process }declares a property and readsa binding with one name and only the second is the question. The only
syntactic question left is whether an identifier refers to a binding at all,
and that is asked of the grammar rather than of a list of node kinds: an
IdentifierNamefills aname,propertyNameorlabelslot of its parent— or a qualified name's
right— and anIdentifierReferenceis anywhereelse. Member accesses, statement labels, imported members, object keys, named
tuple elements, import attributes, enum members and every declaration's own
name are therefore labels because of what they are.
read from parsed syntax — static imports and re-exports,
import type,type-position
import(), dynamicimport(),import =andrequire()—with quoted and no-substitution template specifiers decoded, so comments and
strings that merely contain import syntax load nothing. A destination the
surface computes is refused, because nothing can show it is not a host
module. Host schemes, whole path segments naming any runtime this repository
builds an entry point for (
deno,node,bun,compiled,cloudflare,workerd), vendored sources and host processes are classified by shaperather than by a list, so the next such import is caught without anyone
having predicted it. Segments are compared whole, so
nodes/,bundle.tsand
vendors/are not crossings for containing a runtime's name.How to verify it
SIGKILLbetween the routed append and the commitpublishes nothing, and fails if the transaction were committed before the
parent looked. Verified by mutation: moving the child's announcement to after
withWorkspaceEffectsreturns makesexpect(during.outside).toEqual(before)fail with 5 blob refs against 2, 3 blobs against 2, and a changed current
root.
root, ordered events, event identities and event-to-root associations, and
fails if a recorded effect ran again — each effect appends its name to a
marker file, and the first process's two lines must still be two.
lookup for
/tree/file.txthappens before restoration and is answered fromthe live frontier, so the successful read afterwards is cache invalidation
rather than a cache never consulted. The hardlink relationship is proven by
identity rather than by inspection:
resnapshotRoot === historicalcan onlyhold if the canonical manifest's hardlink group was reproduced. The manifests
the historical root references and the later root does not are asserted
non-empty, so restoration cannot have borrowed live content.
import { randomUUID } from "node:crypto"torun.tsmakes it report{"packages/workflow/src/run.ts": ["node:crypto"]}. Each evasion a textmatch would miss was checked the same way, by putting it into real shared
source and watching the scan report it —
await import(`node:crypto`)and
await import("node\u003acrypto")both reportnode:crypto, andconst t = "node:crypto"; await import(t)reportsa computed module specifier. The regressions additionally assertnode:crypto,node:os,node:fs(asimport type, type-positionimport()andimport =),bun:sqliteand../deno.ts, and assert thatthe same specifier in a comment, a string or a template loads nothing. A
module whose text imports something must yield a specifier, so a parse that
stopped working cannot report the surface as clean.
compiler with the previous hand-written resolver reports a crossing for every
form the language binds and it did not know —
import Deno = require(...)→["Deno"],namespace Deno→["Deno"],type Rename<T> = { [process in keyof T]: T[process] }→["process"],type Value<T> = T extends infer Buffer ? Buffer : never→["Buffer"],process: for (;;) { break process; }→["process"],class Queue { get process() {…} }→["process"]— while the compilerreports none of them. Both still report
process.pid,Deno.cwd()and areference outside its shadow, so the mutation isolates resolution rather than
detection. Resolving a shorthand property with the ordinary
getSymbolAtLocation()reports nothing forconst environment = { process };against expected
["process"]— a fail-open hole, since the property theliteral declares is always local. Each label classification is held on its
own: treating a named tuple element as a reference reports
["process", "Deno"]fortype Pair = [process: string, Deno?: number], andtreating an import attribute as one reports
["process"]for its key, whiletype Pair = [value: typeof process]and thenode:fsbeside an attributestay reported either way. Deleting the host-global scan still fails
on
process.pid, and narrowing adapter recognition back to Deno still failson
./node.ts.allocator change is covered by the suite that owns run identity.
deno task lint,deno task check(0 errors) anddeno task check:jsrpass on this head, as do the focused DLC13, crash-recovery and WorkflowRun identity suites (3 suites, 32 steps). The complete three-runtime battery is CI's: the parser import is new under Node and Bun, andtest-nodeandtest-bunare where that is proven rather than on one developer's machine. The new suite is Deno-only and registered for Node and Bun inscripts/runtime-test-exclusions.tsagainst this issue; both helpers still typecheck undertsconfig.node.json.#415 has merged, so this PR now targets
maindirectly and its CI runs thefull set of jobs. The battery above is the local run on the rebased head.
Scope
Included
it found (
node:cryptoinpackages/workflow/src/run.ts)architecture.md,specs/workflow-spec.md§9.6 andspecs/workflow-workspace-spec.md§10.1/§13, and the WAC24–WAC26 conformance rows
Intentionally unchanged
production edit is the run-id allocator's import in
packages/workflow/src/run.tsxmdworkflow start/resume;API.Filesand<File>integrationadds only the process-level composition they do not cover
New abstractions
tests/support/workspace-process.tsexists because the constants and thefilesystem reader are shared by two child processes and the suite that
launches them, and importing them from a child would run that child's
main()inside the test.New dependencies
typescript@^5.0.0— already a root devDependency; this adds thedeno.jsonimport-map entry, one line indeno.lock, so a workspace membercan resolve it.
acornis in the import map butparses JavaScript, and the scanned surface is TypeScript.
Risks and limitations
for the reason above. If a future slice exposes the connection hooks through
a provider-level seam, the child should use it.
code()strips comments for the identifier scan and does not modelregular-expression literals. No module on the scanned surface contains one
that could hide a forbidden name, and the scanner's own failure cases are
asserted. Module loading no longer depends on it at all — that is decided by
a parse.
Scope confirmation
Closes #365
Rebased onto
mainafter #415 merged. My identifiers moved from WAC19–WAC21 toWAC24–WAC26, because #415 landed with WAC19–WAC23 of its own.