# WSC-MVC — Architecture (as implemented through M3) ## Request flow ``` 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 ``` ## Component boundary contract - `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. - WSC public members are exposed as plain `` entries backed by ordinary `Sub`/`Function` procedures, never ``/`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. - Routing is an explicit literal allowlist in `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 path handling is deliberately conservative in M3: ASP/IIS has already decoded the `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. ## Per-request lifetime and diagnostics - `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 harness: GET /self-test — its own app, only Framework/ is shared `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. ## COM identity | 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. ## IIS sites (test host: win2025test, 100.127.62.31) 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. - Each site's `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 `` 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`. - Each site's `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 document is `Default.asp` on both sites; each site's one route is served via a rewrite rule, not the default document. ## Views (M4) `Framework/ViewRenderer.wsc` (`WscMvc.ViewRenderer`) loads `Views/.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"). ## Deferred to later milestones (do not implement early) 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.