# WSC-MVC — Architecture (as implemented through M2) ## Request flow ``` GET /hello -> IIS URL Rewrite rule "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) [Framework/Application.wsc] -> CreateObject("WscMvc.HomeController") -> HomeController.Hello(body) [Controllers/HomeController.wsc] -> LogOutcome ctx, statusLine (best-effort append to logs/app.log) -> Default.asp sets Response.Status/ContentType, 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/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 in M1/M2 is still a minimal hardcoded allowlist inside `Application.Run`. 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 path. This prevents direct `Default.asp?route=...` requests from crossing between apps. M3 will replace these checks with the explicit route table in `Router.wsc`. - `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 the same checks as `tests/Test-Components.vbs`, but callable over plain HTTP and returning JSON — no PowerShell, cscript, or SSH access to the VM required. `Application.Run` routes `path = "/self-test"` to it. **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/Application.wsc` | `WscMvc.Application` | `{851C7763-1638-42FE-A166-BF3DD3A96A88}` | | `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 between M1 and M2 (added a leading `ctx` parameter) under the same CLSID; acceptable because this is active pre-release (v0.1) development with exactly one caller (`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. ## Deferred to later milestones (do not implement early) Per SPEC §3 non-goals and IMPLEMENTATION_PLAN M3+: explicit route table, HTML views/templates, 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`.