GET / or GET /hello
-> IIS URL Rewrite rule "WscMvc-Root" (^$) or "WscMvc-Hello" (^hello/?$)
-> /Default.asp?route=/hello
-> Server.CreateObject("WscMvc.RequestContext"); ctx.Initialize path, httpMethod, logDir
-> Server.CreateObject("WscMvc.Application")
-> Application.Run(ctx, "production", statusLine, contentType, body, allowHeader) [Framework/Application.wsc]
-> CreateObject("WscMvc.Router")
-> Router.Match(ctx, "production", ..., handlerKey) [Framework/Router.wsc]
-> CreateObject("WscMvc.HomeController")
-> HomeController.Hello(viewName, message) [Controllers/HomeController.wsc]
-> CreateObject("WscMvc.ViewRenderer")
-> ViewRenderer.Render(viewsDir, viewName, data, body) [Framework/ViewRenderer.wsc]
-> LogOutcome ctx, statusLine (best-effort append to logs/app.log)
-> Default.asp sets Response.Status/ContentType, optional Allow header, writes body
Default.asp is the only file that touches ASP intrinsic objects (Request, Response, Server). It contains no business logic — only reading the route query parameter and REQUEST_METHOD, resolving logs/'s physical path via Server.MapPath, invoking WscMvc.RequestContext and WscMvc.Application, and writing the response.Framework/RequestContext.wsc, Framework/Router.wsc, Framework/Application.wsc, Controllers/HomeController.wsc, and test-app/Controllers/SelfTestController.wsc never reference Request/Response/Server/Session. All data crosses the ASP-to-WSC and WSC-to-WSC boundaries as either VBScript scalars (strings) or our own RequestContext COM object (not an ASP intrinsic) — never an ASP host object. See docs/DECISIONS.md for why the original SPEC §15 open question about passing ASP intrinsics into a WSC never needed a direct experiment: the architecture never crosses that boundary by design.<method> entries backed by ordinary Sub/Function procedures, never <property>/Property Get. VBScript's Property Get/Let/Set requires a Class...End Class block and cannot appear at a WSC's top-level script scope — confirmed experimentally (see docs/DECISIONS.md), not assumed from general WSC documentation.Framework/Router.wsc. Every call includes a fixed application name (production or tests) supplied by that app's own bootstrap; a route must match both the application and normalized path. Literal routes are checked directly before any future parameterized route support. Router decisions return either a hardcoded handler key for Application.wsc to dispatch with explicit Select Case branches, or a completed expected response (400, 404, 405 with Allow). No URL segment is ever treated as a ProgID, method name, template name, or filesystem path.route query parameter once, so Router.wsc does not decode again. It rejects remaining % escapes, backslashes, query/fragment markers, doubled slashes, dot-segments, and control characters, then lowercases and trims one trailing slash for literal matching. The original path remains available through RequestContext.Path for logging.Application.Run is the single central point that decides expected (404, ordinary control flow) vs. unexpected (COM/method failure, 500) outcomes, and the single point that logs every outcome. Default.asp still independently guards its own three sequential calls (RequestContext creation, Initialize, Application creation, Run) since a WSC failing to even instantiate happens outside Application.Run's reach.RequestContext and Application are both created fresh per request via Server.CreateObject in Default.asp and dropped (Set ... = Nothing) at the end of the page; neither is ever cached in ASP Session/Application scope, per SPEC §10.RequestContext.Initialize generates a correlation id from Fix(Timer) plus Scripting.FileSystemObject.GetTempName() — not Randomize/Rnd(), which was tried first and shown experimentally to collide when two contexts are created within the same clock tick (see docs/DECISIONS.md).Application.LogOutcome appends one line per request (timestamp, correlation id, method, path, status line, elapsed ms) to logs/app.log, guarded end-to-end by On Error Resume Next so a logging failure can never affect the HTTP response. Concurrent writers are serialized with a manual lock-file mutex (logs/app.log.lock, via CreateTextFile(..., OverwriteExisting:=False)) since no ASP intrinsic locking primitive (Application.Lock) is available to code that must not reference ASP intrinsics. The retry budget is deliberately short (see docs/DECISIONS.md): logging is explicitly best-effort and may drop lines under heavy concurrency without affecting correctness of the response.test-app/Controllers/SelfTestController.wsc (WscMvc.SelfTestController) exposes framework checks over plain HTTP and returns JSON — no PowerShell, cscript, or SSH access to the VM required. Both the test site's root (GET /) and GET /self-test rewrite to the test app's /self-test route. The route table allows both GET and POST for /self-test. Production similarly maps both GET / and GET /hello to /hello, which is GET only.
This lives on its own IIS site (test-app/), not the production site. Changed 2026-09-19 at Daniel's explicit direction: a diagnostics endpoint permanently reachable on the same site/port as real traffic was the wrong shape. test-app/public/ is a second, separate IIS site's physical path (own site name, own app pool, own port, own logs/), whose web.config routes /self-test and nothing else. The production site's web.config no longer has a /self-test rewrite rule at all — GET /self-test against the production site now returns a plain 404 (nothing rewrites that path to Default.asp).
Only Framework/ is genuinely shared between the two sites — Controllers/ is not. Application.wsc/RequestContext.wsc in Framework/ are the reusable dispatch/context engine, registered once globally via COM and used by both sites. Controllers/ holds application-specific business logic: production owns Controllers/HomeController.wsc, while the test app owns test-app/Controllers/SelfTestController.wsc. COM registration for a .wsc records its exact file path, so registration tooling points each controller at its app-owned directory.
Each site also owns its thin Default.asp. The files differ only in the fixed application name passed to the shared Application.Run: production passes "production"; the test app passes "tests". That selector is part of the route match, so neither ordinary URL Rewrite nor a direct Default.asp?route=... request can activate a controller belonging to the other app. SelfTestController no longer invokes /hello or HomeController; its framework checks use the tests route set and explicitly verify that /hello is rejected.
The HTTP status is always 200 if the self-test mechanism itself ran (a broken SelfTestController/Application still degrades to the normal 500 path via the same central error handling); the JSON body's top-level "ok" field and each check's "pass" field carry the actual test results — conventional health-check design (5xx is reserved for the diagnostics mechanism being broken, not for a failed assertion inside it).
curl http://100.127.62.31:8091/self-test
{"ok":true,"checks":[{"name":"request_context_contract","pass":true,"detail":""}, ...]}
tests/run-self-test.sh wraps this in curl + python3 -m json.tool, runnable from any CLI — point it at the test-app site's URL. It also probes direct Default.asp?route=/hello and requires a 404. tests/Test-Http.ps1 tests production and, when -TestBaseUrl is supplied, verifies direct-query isolation in both directions.
SelfTestController's error details intentionally include raw Err.Description text — unlike every other route, which must never leak internals to an arbitrary caller, this route's entire purpose is diagnostics. It is a dev/test-milestone tool; reconsider whether the test-app site should be gated or removed before any production deployment during the M6 hardening pass.
| Component | ProgID | CLSID |
|---|---|---|
Framework/RequestContext.wsc |
WscMvc.RequestContext |
{1C36FA55-34DF-4974-94B9-D657389362B2} |
Framework/Router.wsc |
WscMvc.Router |
{C92F9338-B478-4EAD-B865-892FFB1E1C51} |
Framework/Application.wsc |
WscMvc.Application |
{851C7763-1638-42FE-A166-BF3DD3A96A88} |
Framework/ViewRenderer.wsc |
WscMvc.ViewRenderer |
{4948DF84-5DC6-448A-9F1B-EB596C28842B} |
Controllers/HomeController.wsc |
WscMvc.HomeController |
{87488446-60BE-4068-8368-0B709BB68F3F} |
test-app/Controllers/SelfTestController.wsc |
WscMvc.SelfTestController |
{D2634944-4646-4C55-956E-4C05E7E10904} |
CLSIDs are fixed at creation and must never be recycled for a different component (AGENTS.md). Application's public Run signature changed during pre-release development (M2 added a leading ctx parameter; M3 added an allowHeader output parameter) under the same CLSID; acceptable because this is active pre-release (v0.1) development with exactly one caller shape (Default.asp, updated in lockstep) — not a claim that live interface changes are safe for a published/external client.
Two separate IIS sites now, both set up/reconciled by the same tools/Setup-Site.ps1 (parameterized by name/pool/path/port; idempotent, safe to re-run):
| Production | Test app | |
|---|---|---|
| Site name | WscMvc |
WscMvcTests |
| Port | *:8090 |
*:8091 |
| Physical path | C:\Projects\wsc-mvc\public |
C:\Projects\wsc-mvc\test-app\public |
| App pool | WscMvc |
WscMvcTests |
| Routes | /hello |
/self-test |
| Logs | C:\Projects\wsc-mvc\logs\app.log |
C:\Projects\wsc-mvc\test-app\logs\app.log |
Both are 64-bit, no managed code, anonymous auth identity IUSR. Each site's public/ contains only Default.asp and web.config; every other directory (Framework/, Controllers/, test-app/Controllers/, tests/, tools/, docs/, both logs/ folders) is a sibling of whichever public/ is actually served, outside both sites’ physical paths entirely — see the “only Framework/ is shared” note above for which of those directories are truly cross-site versus app-specific. Changed 2026-09-19 at Daniel's explicit direction (see docs/DECISIONS.md); previously there was one site with the whole project directory as its root and those folders hidden via hiddenSegments, and /self-test was a route on that same site.
web.config keeps only a .wsc/.vbs/.ps1/.md extension denylist as defense-in-depth (in case a stray file ever lands directly in its public/); no hiddenSegments are needed since the directories they used to hide no longer exist under either served root at all.enableParentPaths=true is set for both sites (scoped via appcmd ... /commit:apphost, a separate <location> block per site in applicationHost.config — not a machine-wide unlock of the locked system.webServer/asp section) so each site's Default.asp can Server.MapPath("../logs") to reach its own logs/. This setting affects only server-side script MapPath/#include resolution, not client-supplied URL paths — IIS's own URL normalization independently rejects ..-traversal in an incoming request regardless of this setting; verified with a direct request attempt, see docs/DECISIONS.md.logs/ has an explicit, scoped icacls ... /grant "IIS_IUSRS:(OI)(CI)M" (now automated by tools/Setup-Site.ps1, previously a manual one-off command) so classic ASP (impersonating IUSR for anonymous requests) can write app.log. No other project directory grants IUSR/IIS_IUSRS write access — verified with a recursive icacls /T scan, see docs/DECISIONS.md.Default.asp on both sites; each site's one route is served via a rewrite rule, not the default document.Framework/ViewRenderer.wsc (WscMvc.ViewRenderer) loads Views/<ViewName>.html and substitutes {{Key}} placeholders from a Scripting.Dictionary built by Application.wsc's handler subs (e.g. RunHomeHello) from a controller's plain scalar output — controllers (HomeController.Hello(viewName, message)) never build the Dictionary or touch rendering themselves, keeping them primitive-only like the rest of the request-data boundary. viewsDir is resolved by each Default.asp via Server.MapPath("../Views") (same parent-relative pattern as logDir) and threaded through Application.Run's new viewsDir parameter — ViewRenderer.wsc itself never references ASP intrinsics.
Views/ is a sibling of public/, exactly like Framework//Controllers/, so it is unreachable over HTTP by the same physical-separation guarantee (verified: GET /Views/Home.html → 404) — no additional deny rule was needed.
Substitution is a single deterministic left-to-right scan; template content is never executed as VBScript or evaluated as an expression (SPEC §8). Placeholder values are HTML-encoded (&, <, >, ", ') for text context only — attribute/URL/JS-context encoding is not implemented; this is a deliberate scope limit (SPEC §8 requires “separate, explicit handling” per context, and no current view needs anything but text context), not an oversight. An unterminated {{, a placeholder with no matching dictionary key, an invalid view name, or a missing template file are all safe errors (Err.Raise with a generic, path-free message) that flow into Application.wsc's existing central 500 handling, which already discards Err.Description entirely.
Views/Home.html is exactly {{Message}}; HomeController supplies message = "Hello from WSC-MVC!", a string with no HTML-special characters, so /hello's response body is byte-identical to the pre-M4 (M1-era) contract — the rendering pipeline was proven end-to-end without changing observable behavior. Views/Layout.html and a layout convention are deferred until a second view actually needs shared chrome (IMPLEMENTATION_PLAN M4 explicitly sequences “add layout after renderer works”).
Per SPEC §3 non-goals and IMPLEMENTATION_PLAN M5+: ADODB, auth. A more robust logging mechanism (if complete coverage under concurrent load is ever required) is deferred to M6 — see the concurrency finding in docs/DECISIONS.md. A layout convention (Views/Layout.html) and non-text-context encoding are deferred until a concrete view needs them — see the Views section above.
Powered by TurnKey Linux.