No puede seleccionar más de 25 temas Los temas deben comenzar con una letra o número, pueden incluir guiones ('-') y pueden tener hasta 35 caracteres de largo.

39KB

WSC-MVC — Decisions and Experiment Log

M0 — Environment inventory (2026-09-19, win2025test VM, 100.127.62.31)

Host: Windows Server 2025 Standard, build 10.0.26100, 64-bit, AMD Ryzen 5 1500X (4c). Verified via SSH (key-based, Administrator) + PowerShell 5.1.

  • IIS: installed (Web-Server) with Web-ASP, Web-CGI, Web-ISAPI-Ext, Web-ISAPI-Filter, management tools/console/scripting tools all Installed.
  • URL Rewrite module: present (%SystemRoot%\system32\inetsrv\rewrite.dll, registered as RewriteModule in global modules).
  • WebAdministration PowerShell module: available (v1.0.0.0).
  • WSC/COM runtime: scrobj.dll present in both System32 and SysWOW64.
  • regsvr32.exe, cscript.exe, wscript.exe: present at standard C:\WINDOWS\system32 paths.
  • Existing app pools are all 64-bit (enable32BitAppOnWin64 = False); no 32-bit requirement observed. WSC-MVC will target 64-bit and re-verify if this changes.
  • Existing sites: Default Web Site (*:80), AspClassicUnifiedFramework (*:8080, unrelated pre-existing project). No port conflict for a new dedicated site on *:8090.
  • Disk: C: only (106 GB, ~80 GB free). No D:/E:/F: volumes despite drive letters being visible (0 bytes each — not usable). Project deployed under C:\Projects\wsc-mvc.
  • Firewall: World Wide Web Services (HTTP Traffic-In) inbound rule enabled.
  • HTTP reachability: VM only reachable over the private Tailscale tailnet (100.127.62.31); no public exposure.

No blocking gaps found for M0/M1. Proceeding to M1.

Open question #1 — Can WSC receive ASP intrinsic objects across the IIS/COM boundary?

Decision: not attempted for M1. Per SPEC and AGENTS.md hard rules, business logic must never reference ASP Request/Response/Server/Session anyway, so this is avoided by design rather than tested as a compatibility question. Application.Run and HomeController.Hello use a primitive-only, ByRef-out contract: all parameters are strings (pathInfo in; statusLine, contentType, body out via VBScript's default ByRef parameter passing — no ByVal/ByRef keywords are available in WSC <public> XML, so behavior follows the Sub/Function signature in the script block). Default.asp owns all ASP host object access and is the only file that touches Request/Response/Server.

This resolves the open question pragmatically: no experiment needed because the architecture never crosses that boundary. Revisit only if a future milestone needs to pass richer data (e.g. multi-value POST bodies) — prefer additional scalar out-params or a Scripting.Dictionary (a standard COM component, not an ASP intrinsic) over passing ASP objects into a WSC.

Registration tooling

regsvr32.exe /s "<path>.wsc" registers a Windows Script Component directly (scrobj.dll is the underlying handler associated with the .wsc extension). Verified working for both Application.wsc and HomeController.wsc on this host — see docs/TEST-RESULTS.md. Both scripts require elevation (registry write); the deployment/registration step is intentionally separate from the request-handling pipeline per SPEC §10 and AGENTS.md.

Finding: regsvr32 /s /u component.wsc does not actually unregister. Verified experimentally on this host (Windows Server 2025, build 26100): after regsvr32 /s /u on both components, regsvr32 reports exit code 0, but HKLM:\SOFTWARE\Classes\<ProgID> and HKLM:\SOFTWARE\Classes\CLSID\<CLSID> are both still present and functional (confirmed via direct registry inspection and a follow-up CreateObject that still succeeded). This reproduced identically via Start-Process -Wait and via a direct cmd-level regsvr32 /s /u call with %ERRORLEVEL% checked. This is a real scrobj.dll/WSC limitation, not a script bug — do not trust regsvr32 /u's exit code as evidence of removal for .wsc files on this host.

Fix implemented: tools/Unregister-Components.ps1 still calls regsvr32 /s /u first (harmless best-effort), then explicitly checks whether HKLM:\SOFTWARE\Classes\<ProgID> still resolves to this project's known CLSID, and if so removes exactly that ProgID key and exactly that CLSID key (recursive), nothing else. It refuses (throws) instead of deleting if the registered CLSID under that ProgID doesn't match what this project expects, to guarantee it can never remove a different component's registration that happens to reuse a ProgID string. Full reversibility cycle (register → verify via HTTP+WSH → unregister → verify HTTP 500 + WSH CreateObject failure → re-register → verify recovery) was run end-to-end and passed; see docs/TEST-RESULTS.md.

Finding: IIS httpErrors substitutes its own error page for remote clients by default

With the out-of-the-box system.webServer/httpErrors setting (errorMode="DetailedLocalOnly"), a request from a remote client (e.g. over the tailnet, not literally localhost on the IIS box) that hits a 500 gets IIS's own generic friendly-error HTML page, not whatever Default.asp wrote to the response body — even though Default.asp's own On Error Resume Next / Err.Number handling ran correctly and wrote its intended "Internal Server Error" / text/plain body. Verified by comparing a curl from the Linux host (tailnet IP) against a curl run locally on the VM (http://localhost:8090/hello) with components deliberately unregistered: the local request returned our own exact Content-Type: text/plain; charset=utf-8 / Internal Server Error body; the remote request returned IIS's generic HTML page. Both are HTTP 500 and both satisfy the SPEC §14/§10 requirement (safe failure, no path or exception text leaked to any client) — this is IIS being conservative for non-local requests, not a defect in our error handling. Documented here so a future milestone doesn't mistake this for Default.asp's error branch not firing.

Diagnostic note: system.webServer/asp is locked (overrideModeDefault="Deny") at the machine level by default, so it cannot be overridden from a site's own web.config. Toggling scriptErrorSentToBrowser for diagnosis requires appcmd unlock config /section:system.webServer/asp first; remember to appcmd lock config /section:system.webServer/asp again afterward and to manually strip any <asp .../> element that Set-WebConfigurationProperty writes into the site's web.config before re-locking, or the site's config becomes invalid (locked section referenced from a location that has an explicit override) and every request 500s until the stray element is removed. This happened once during M1 testing on this host and was fixed by editing web.config directly; the repository's web.config was never affected (this only happened to the deployed copy on the VM).

Static/source protection approach (M1; superseded, see the public/-only webroot entry below)

M1's original approach: IIS requestFiltering/hiddenSegments (blocks any URL path containing Framework, Controllers, tests, tools, docs as a path segment, anywhere in the tree) plus requestFiltering/fileExtensions denylist for .wsc, .vbs, .ps1, .md, with Default.asp at the project root (matching the SPEC §5 target tree at the time) rather than a separate public/ webroot. Verified with tests/Test-Http.ps1 (direct .wsc request must return 404).

Superseded 2026-09-19: replaced with physical separation (public/-only IIS site root) at Daniel's explicit direction — see that entry below for the current approach and rationale. The fileExtensions denylist is kept as defense-in-depth; hiddenSegments was removed since it no longer protects anything that exists under the served root.

Site/port allocation

New dedicated site WscMvc, app pool WscMvc (64-bit, no managed code), binding *:8090. Chosen to avoid the existing *:80 and *:8080 bindings already in use on this VM. (Physical path later changed to C:\Projects\wsc-mvc\public — see the public/-only webroot entry. A second site, WscMvcTests on *:8091, was added later still — see the “test harness as its own app” entry.)

M2 — Lifecycle, central error handling, correlation-safe diagnostics (2026-09-19)

Added Framework/RequestContext.wsc (WscMvc.RequestContext), a per-request, primitive-only data holder (path, HTTP method, a correlation id, a log directory, elapsed-time tracking). Default.asp creates and Initializes one per request and passes it into Application.Run(ctx, ...) in place of the raw pathInfo string from M1. Application.wsc centralizes both the expected-vs-unexpected outcome decision (404 is expected control flow; a CreateObject/method-call failure is 500) and best-effort logging of every outcome (logs/app.log: timestamp, correlation id, method, path, status line, elapsed ms) in one place (LogOutcome), per SPEC §10/§13's M2 requirement. logs/ was added to web.config's hiddenSegments so it is never HTTP-reachable.

Three real defects were found and fixed while building this, all confirmed experimentally rather than assumed:

1. Property Get/Let/Set cannot be used at the top level of a WSC <script> block. First draft of RequestContext.wsc exposed Path/HttpMethod/CorrelationId/LogDir via Property Get procedures declared via <property><get/></property> in the <public> block. Registration failed with regsvr32 exit code 5 (DllRegisterServer failed). Isolated with GetObject("script:<path>.wsc") from cscript, which surfaced the real error: VBScript error 1048, "Must be defined inside a Class". VBScript only allows Property Get/Let/Set inside a Class...End Class block; a WSC's top-level script scope is not a class, regardless of what the WSC <public> XML declares. Fixed by exposing plain parameterless Functions (Path(), HttpMethod(), etc.) via <method> instead of <property> — the same pattern already proven working in Application.wsc/HomeController.wsc. Late-bound VBScript call sites (ctx.Path, no parens) work identically whether the target is a method or a property, so no caller code needed to change.

2. FormatNumber() leaks a locale thousands-separator into generated ids. The first correlation-id implementation built a value from FormatNumber(Timer, 4); on this host's locale that produces a comma-grouped string (e.g. 32,188.0400), so a correlation id came out containing a literal comma (32,1880400-714275) — harmless today but a latent bug for any future comma-delimited log/CSV consumer. Confirmed via a standalone GetObject-based probe before it ever reached a real component. Fixed by building the numeric parts with Fix()/integer arithmetic instead of FormatNumber().

3. Randomize + Rnd() collide when called twice inside the same clock tick. After fixing (2), a WSH test creating two RequestContext instances back-to-back got the same correlation id both times. Root cause, confirmed experimentally: Randomize with no argument reseeds VBScript's RNG from the system timer; two calls within the same timer resolution window reseed to an identical state, so the immediately-following Rnd() returns the same first value both times. This is a genuine hazard for any correlation/nonce-style id generated this way in a tight loop (exactly the shape of “two requests arriving close together,” which is the whole point of a correlation id). Fixed by dropping Randomize/Rnd() entirely in favor of Scripting.FileSystemObject.GetTempName(), confirmed experimentally to return a distinct value on every call in a tight loop with no dependency on Randomize state.

4. Best-effort append-logging to one shared text file loses lines under concurrency, and a plain retry-the-open loop does not fix it. With 8 genuinely concurrent /hello requests (confirmed genuinely concurrent via a control experiment: unique-per-request marker files with no shared-file contention all succeeded independently), a naive OpenTextFile(path, ForAppending, Create:=True) on every request lost roughly half its lines — not because the open call fails under contention (it mostly succeeds; OpenTextFile isn't exclusive), but because unsynchronized concurrent appends is a lost-update race. Retrying only the open call made this worse, not better (measured: down to 1 surviving line of 8), because it does nothing to stop the underlying race. Fixed with a manual mutex: FileSystemObject.CreateTextFile(lockPath, OverwriteExisting:=False) atomically fails with Err.Number=58 "File already exists" when another request already holds the lock file — confirmed correct/exclusive with a dedicated diagnostic harness, not assumed. The lock genuinely serializes the critical section (open app.log, write, close), but making every concurrent request eventually win requires a large retry budget: measured 300 retries (~330-360ms of blocking) needed for 8-way concurrency before all callers reliably see the lock come free. That much added latency on every request is a bad tradeoff for a best-effort diagnostic write, so the shipped retry budget is deliberately short (10 attempts, ~a few ms). Accepted, documented consequence: under moderate-to-heavy concurrent load, some app.log lines will legitimately be dropped; this never affects the HTTP response, which is correct and on-time in 100% of observed cases regardless of logging outcome. Revisit with a real logging mechanism (e.g. per-request-unique files aggregated out of band, or a proper OS-level named mutex via a small helper) in M6 if complete log coverage under load becomes an actual requirement — do not spend more effort on this in M2, it is explicitly a “safe diagnostic information” nice-to-have per SPEC §10, not a correctness requirement.

Investigated and resolved: an inherited IUSR:(F) ACE appeared under logs/

While diagnosing the concurrency finding above, icacls C:\Projects\wsc-mvc\logs showed an inherited NT AUTHORITY\IUSR:(I)(F) (Full Control) entry that wasn't present as an explicit ACE on C:\Projects\wsc-mvc or C:\Projects themselves. Initially flagged as a possible SPEC §10 “no broad filesystem write privileges” violation (anonymous web identity with Full Control beyond what it needs). Investigated properly rather than left as a guess: a recursive icacls C:\Projects\wsc-mvc /T scan shows the IUSR:(F) ACE present only on logs/ and files/folders created underneath it during testing (app.log, diagnostic marker files, etc.) — confirmed absent on Framework/, Controllers/, Default.asp, web.config, tools/, tests/. Both C:\Projects and C:\Projects\wsc-mvc carry an inherited CREATOR OWNER:(I)(OI)(CI)(IO)(F) ACE (the (IO) = inherit-only flag), which is standard NTFS behavior: it doesn't grant CREATOR OWNER anything on the folder itself, but materializes into a concrete Full Control ACE for whichever security principal actually creates each new child file/folder underneath. Since IUSR (impersonated by classic ASP for anonymous requests, per this site's anonymousAuthentication config) was the one creating files under logs/ during testing, it received Full Control on exactly those files it created there — nowhere else. Resolved, not a defect: this is expected NTFS ownership semantics, correctly scoped to only the dynamically-created log tree (which is already denied over HTTP via hiddenSegments), not a broad grant across the source tree. No remediation needed; no M6 follow-up required for this specific concern.

Test harness: HTTP+JSON self-test endpoint (2026-09-19)

Daniel asked for the test harness to be “frontend agnostic” — runnable via a plain HTTP/API call from any CLI (not tied to PowerShell/cscript, which require either being on the VM or an SSH hop to it). Added GET /self-test (Controllers/SelfTestController.wsc), which runs the same checks as tests/Test-Components.vbs in-process against the live registered components and returns JSON ({"ok":bool,"checks":[{"name","pass","detail"}, ...]}). Verified directly from the Linux OpenClaw host with plain curl (no SSH, no PowerShell): a passing run, and a genuine failing run (deliberately removed WscMvc.HomeController's registry entries, confirmed /self-test correctly reported "ok":false with application_run_hello pinpointed and a clear detail message, while every other check still correctly reported true — then re-registered and confirmed full recovery).

Design choice: HTTP status is always 200 when the self-test mechanism itself executes; pass/fail lives in the JSON body ("ok" and per-check "pass"). A 5xx is reserved for the diagnostics mechanism itself being broken (e.g. SelfTestController fails to instantiate), which still flows through Application.Run's existing central error handling unchanged. This mirrors conventional health-check endpoint design and keeps a clean separation between “the test infrastructure is broken” and “a test found a real problem.”

SelfTestController is the first component whose failure details intentionally include raw Err.Description — everywhere else in this codebase that would violate SPEC §10 (“avoid... raw exception text to clients”), but a diagnostics-only endpoint's entire job is reporting what broke. Flagged in docs/ARCHITECTURE.md for reconsideration (gate or remove) before any production deployment, during the M6 hardening pass — not a concern for the current dev/test milestones.

Restructure: public/-only webroot, physical separation over request-filtering (2026-09-19)

Daniel asked for Default.asp to be served out of a public/ folder with public/ as the only folder IIS serves, plus enableParentPaths so the app can still reach sibling directories. This changes SPEC §5's originally fixed target tree (Default.asp at the project root) — done with Daniel's explicit direction, per AGENTS.md's precedence rule that user instructions outrank a SPEC “fixed decision,” and SPEC.md §5 has been updated in place to document the new tree as current, not left silently stale.

What changed: Default.asp and web.config moved into public/; Framework/, Controllers/, tests/, tools/, docs/, logs/ are now siblings of public/, entirely outside it. The IIS site's physicalPath was changed from the project root to C:\Projects\wsc-mvc\public. This is strictly stronger than the previous hiddenSegments-based approach: those directories aren't merely denied by a request-filtering rule now, they simply don't exist anywhere under the served tree, so there's no config file whose misconfiguration could ever expose them.

enableParentPaths: Default.asp needs Server.MapPath("../logs") since logs/ is now outside public/. system.webServer/asp is locked (overrideModeDefault="Deny") at the machine level (already discovered during M1 diagnostics), so it can't be set from public/web.config directly. Used appcmd set config WscMvc -section:system.webServer/asp /enableParentPaths:True /commit:apphost instead of the machine-wide unlock/relock approach used earlier for diagnostics — /commit:apphost writes a <location path="WscMvc"> block directly into applicationHost.config, scoped to only this site, without touching the global overrideModeDefault (which would affect every site on the host) and without the earlier stray-element/relock failure mode. tools/Setup-Site.ps1 now runs this automatically and is idempotent (re-running it is a no-op if already configured; verified by running it twice).

Verified, not assumed, that this doesn't open a traversal hole: enableParentPaths only affects server-side MapPath/#include resolution of literal strings already in the script (here, the hardcoded "../logs" - never user input). It has no effect on how IIS resolves an incoming client URL. Confirmed directly: GET /../Framework/Application.wsc, GET /..%2fFramework/Application.wsc, GET /..%252fFramework/Application.wsc, and GET /../logs/app.log against the live site all returned 404/403 — IIS's own URL normalization and request filtering reject these independently of the ASP-level setting.

Full regression re-run after the restructure: tests/Test-Components.vbs (WSH), tests/Test-Http.ps1 (HTTP), and tests/run-self-test.sh (curl+JSON) all pass; /hello and /self-test work correctly; logs/app.log confirmed actually being written via the new parent-relative path (not just assumed - read back the full file content over SSH); GET /Framework/Application.wsc returns 404 (now because the path genuinely doesn't exist under the site root, not because of a hiddenSegments rule).

Test harness as its own app, sharing the same framework (2026-09-19)

Daniel: “the tests need to be its own app in its own folder but still uses the same framework as the real app.” /self-test had been a route on the production WscMvc site — a permanent diagnostics endpoint reachable on the same port as real traffic, which is the wrong shape once you think about it as “would this be here in production.”

Design: a second IIS site, WscMvcTests (*:8091, physical path test-app/public/), sharing every Framework//Controllers/ COM component with production (registered once, globally — COM resolution doesn't care which IIS site's worker process calls Server.CreateObject). The only file that exists twice is Default.asp (public/Default.asp and test-app/public/Default.asp), and only because IIS requires each site to have its own physical files, not because the bootstrap logic differs between them — it's fully generic (doesn't hardcode which routes exist or which site is calling it), so the two copies are deliberately byte-identical, each carrying a comment pointing at the other with a “keep in sync” note. Considered an NTFS hardlink to guarantee they can never drift, but that adds real complexity (git doesn't preserve hardlinks across a checkout; a deployment step would need to re-establish it) for a ~40-line file that's designed to almost never change — plain, documented duplication is simpler and matches AGENTS.md's “prefer the simplest viable contracts” guidance.

What moved where: public/web.config (production) no longer has a /self-test rewrite rule — Application.wsc's Run method still technically has that route case (shared framework code), but nothing on the production site's web.config can ever reach it, so GET /self-test against production now just 404s like any other undefined path. test-app/public/web.config has only the /self-test rule (no /hello) since testing /hello's behavior is already covered indirectly — SelfTestController.RunSelfTest exercises Application.Run("/hello", ...) in-process as one of its own checks.

Tooling gap closed while doing this: the icacls ... /grant "IIS_IUSRS:(OI)(CI)M" grant on a site's logs/ folder had been a manual one-off SSH command during earlier milestones, never actually scripted. Adding a second site made this an immediate, concrete problem (a manual step that's easy to forget once there are two sites to set up), so tools/Setup-Site.ps1 now performs it automatically, idempotently (creates the logs/ folder if missing, icacls /grant is itself idempotent - doesn't duplicate the ACE on repeat runs), scoped to whichever site's own logs folder is being set up (Join-Path (Split-Path -Parent $PhysicalPath) 'logs', so it's always the correct sibling for that specific site).

Real bug found and fixed while doing this, in my own tooling: my first attempt to invoke Setup-Site.ps1 -PhysicalPath "C:\Projects\wsc-mvc\test-app\public" over a chained SSH/cmd/PowerShell command failed with New-Website : Parameter 'PhysicalPath' should point to existing path — nested shell-quoting layers had passed the literal string 'C:\Projects\wsc-mvc\test-app\public' (with the single quotes as literal characters) as the parameter value. The script's own Write-Output "Created site..." line printed unconditionally regardless of whether New-Website actually succeeded (it hadn't - $ErrorActionPreference was the default Continue, so the error was non-fatal and the script kept going, prompting a misleading “success” message followed by a real IIS:\Sites\WscMvcTests not-found error on the next line). Fixed two ways: (1) stopped fighting nested quoting and instead wrote the actual invocation into a small script file copied to the VM and run with -File, which sidesteps the quoting problem entirely; (2) added $ErrorActionPreference = 'Stop' and an explicit Test-Path $PhysicalPath pre-check to Setup-Site.ps1 itself, so a bad path now fails loudly and immediately instead of printing a false-success message and failing confusingly two lines later. Re-verified both sites set up correctly and idempotently after the fix.

Verified after the split: production GET /hello → 200; production GET /self-test → 404 (genuinely unreachable now); test-app GET /self-test → 200 with correct JSON; test-app GET /hello → 404 (not routed there, expected); both sites’ Framework/Application.wsc → 404; both sites write to their own separate logs/app.log (confirmed distinct file paths, distinct content); re-running Setup-Site.ps1 for both sites a second time is a true no-op. Full tests/Test-Components.vbs, tests/Test-Http.ps1 (now production-only), and tests/run-self-test.sh (now pointed at the test-app site) all pass.

Correction: test-app should only share Framework/, not Controllers/ (2026-09-19)

Follow-up direction from Daniel right after the previous entry: “Test app should only share /framework folder.” The just-shipped version had SelfTestController.wsc sitting in the shared root Controllers/ folder alongside HomeController.wsc — meaning both apps’ registration tooling pointed into the same Controllers/ directory, blurring the intended boundary. Framework/ (Application.wsc, RequestContext.wsc) is the genuinely reusable dispatch/context engine, meant to be shared; Controllers/ is application-specific business logic, and SelfTestController is test-app-specific, not production business logic, so it doesn't belong there.

Fix: moved Controllers/SelfTestController.wsc -> test-app/Controllers/SelfTestController.wsc and updated registration tooling to reference the app-owned path. Then closed a subtler boundary bypass: removing rewrite rules alone was insufficient because callers can address Default.asp?route=... directly. Each app's bootstrap now passes a fixed application name into shared Application.Run; the route allowlist matches both application and path. Production therefore cannot activate /self-test, and the test app cannot activate /hello, even through direct Default.asp requests. SelfTestController no longer invokes production's /hello route or HomeController; it verifies that the tests route set rejects /hello.

COM registration for a .wsc records the exact file path in the registry (HKLM:\SOFTWARE\Classes\CLSID\{guid}\ScriptletURL), so moving the file requires re-registration. tools/Register-Components.ps1 and tools/Unregister-Components.ps1 now point to test-app/Controllers/SelfTestController.wsc; verification includes checking that registry value and exercising both HTTP boundaries.

M3 — Explicit route table and method handling (2026-09-19)

Added Framework/Router.wsc (WscMvc.Router) as the explicit route allowlist. It receives the per-request RequestContext and fixed application selector (production or tests), then returns either a hardcoded handler key or a completed expected response. Application.wsc dispatches only through explicit Select Case branches for those handler keys; no URL text is ever used as a ProgID or method name.

Route path validation is intentionally conservative for M3. ASP/IIS has already decoded the route query parameter once, so the router does not decode it again. Any remaining % escape, backslash, query/fragment marker, doubled slash, dot-segment, or control character is rejected with 400 Bad Request; otherwise the route is lowercased and one trailing slash is trimmed for literal matching. The original path remains preserved in RequestContext.Path and logs.

The response contract gained an allowHeader out parameter from Application.Run. Default.asp is still the only layer that touches ASP Response; it emits the Allow header only when the framework returns one. This keeps routing/framework code ASP-intrinsic-free while still allowing correct HTTP behavior for app-routed 405 Method Not Allowed responses.

IIS/Classic ASP on the local Windows 11 test host intercepted PUT/DELETE requests before they reached the application, returning IIS's own Allow: GET, HEAD, OPTIONS, TRACE. Therefore HTTP integration tests assert framework 405 behavior using POST /hello, which Classic ASP does deliver to the app and which returns the framework's Allow: GET. The full Router/Application unsupported-method contract, including DELETE /self-test -> Allow: GET, POST, is covered by WSH component tests and the self-test controller's direct Router.Match checks. This matches the M3 scope: support GET and POST initially; distinguish unsupported methods where the request reaches the framework.

M4 — Views: ViewRenderer, controller/render split (2026-09-19)

Added Framework/ViewRenderer.wsc (WscMvc.ViewRenderer) and Views/Home.html. Design choice: controllers (HomeController.Hello) stay primitive-only (return viewName, message as plain strings) and Application.wsc's handler subs own building the Scripting.Dictionary and calling ViewRenderer.Render — matches the existing pattern of keeping business-logic components decoupled from any object more complex than what they strictly need to hand back, and avoids deciding a controller-to-Dictionary contract before a second controller actually needs one. viewsDir is threaded through as a new Application.Run parameter (not through RequestContext, which would have cascaded into every ctx.Initialize call site across both Default.asp files, SelfTestController.wsc (5 call sites), and the WSH tests, for a value that is pure Framework wiring rather than genuine per-request data) — smallest-footprint choice given Application.Run's signature is already established as changeable pre-release (see the M2/M3 CLSID-stability note above).

/hello's existing exact-body contract ("Hello from WSC-MVC!", fixed since M1/SPEC §4) was deliberately preserved rather than changed to prove the render pipeline: Views/Home.html is exactly {{Message}}, and the message string has no HTML-special characters, so HTML-encoding is a no-op and the response is byte-identical pre- and post-M4 — verified directly (Content-Length: 19 unchanged) rather than assumed. This let the M1 acceptance test double as a live M4 regression check instead of requiring a new observable behavior to prove the slice works.

No experimental surprises this milestone (unlike M0-M3, which each surfaced a real WSC/VBScript defect) — returning a newly-created object through a Sub's ByRef out-parameter (ctrl.Hello viewName, message, both scalars in this case, but the same mechanism was also exercised for Set data = CreateObject(...) inside RunHomeHello) behaved exactly as ordinary VBScript ByRef semantics predict, and Err.Raise inside a WSC method, caught by the caller's existing On Error Resume Next + Err.Number pattern, worked identically to the naturally-thrown errors (CreateObject failures) already relied on elsewhere. Confirmed via the WSH suite's dedicated ViewRenderer contract tests (encoding, missing template, unresolved placeholder, invalid view name) before trusting it in Application.wsc.

Confirmed Views/ is unreachable over HTTP purely from being a sibling of public/ (same guarantee already used for Framework//Controllers/) — no new web.config rule was needed: GET /Views/Home.html → 404 on both sites.

M5 — Data: ADODB contract, and why LocalDB was abandoned for a real SQL Server (2026-09-19)

“database-backed example” in SPEC SS3 vs. M5's own gate: SPEC SS3's non-goals list includes “database-backed example,” which reads at first glance like it contradicts M5's gate (“real DB integration tests pass”). Read against SS9 (a full ADODB contract described as a real, later-phase deliverable), the non-goal is interpreted as “no new demo feature/route/page built to show the DB off” — not “don't implement the ADODB contract.” M5 was implemented and proven the same way M1-M4 proved their components: direct WSH component tests (tests/Test-Components.vbs) plus one check added to the existing test-app SelfTestController (already a diagnostics-only surface, not a production feature) — no new production controller, route, or view was added. Flagged to Daniel before implementing; no objection raised.

Engine choice, attempt 1 (LocalDB) - failed for a real, evidenced reason, not a guess. Daniel initially approved “SQL Server Express / LocalDB.” LocalDB was tried first (already installed on this host, zero additional install) and its ADODB contract fully passed under the interactive dev account (cscript), via Driver={ODBC Driver 17 for SQL Server} - MSOLEDBSQL is not installed on this host and legacy SQLOLEDB cannot locate the LocalDB named-pipe instance at all, so ODBC Driver 17 is the only working provider found. However, SPEC SS10's “validate application pool identity... where applicable” discipline requires proving this under the real IIS worker identity, not just an interactive session - AGENTS.md is explicit that WSC/COM/host assumptions must be verified, not assumed. A dedicated database_connectivity_under_app_pool_identity check was added to SelfTestController (test-app's WscMvcTests site, ApplicationPoolIdentity, confirmed via Get-WebConfigurationProperty - no custom identity configured) and hit over real HTTP. It failed: SQL Server Network Interfaces: (ADODB error -2147467259). This matches Microsoft's own documented guidance that LocalDB is a per-user, interactive-profile-dependent instance not supported for IIS-hosted use - now confirmed experimentally on this exact host/identity, not inferred from documentation alone.

Pivot to a real SQL Server, provided by Daniel mid-session. Daniel supplied connection details (host/port/sa credentials) for an existing SQL Server 2022 instance reachable over the tailnet (100.120.114.48:1433). SQL authentication over plain TCP has no dependency on the calling process's Windows identity at all (unlike LocalDB's Trusted_Connection/named-pipe model), so this sidesteps the ApplicationPoolIdentity problem entirely rather than working around it. Verified: TCP reachability (Test-NetConnection succeeded), then an ADODB connection with SQL auth (interactive session, then - critically - re-verified via the same SelfTestController HTTP check under the real app-pool identity, which now reports "pass":true). tools/Setup-TestDatabase.ps1 was generalized from a LocalDB-only script to accept -ServerInstance/-SqlLogin/-SqlPassword (the last a SecureString, converted to plain text only in the narrow window sqlcmd.exe - an external process with no SecureString-aware API - actually needs it as a CLI argument; flagged by the IDE's own PSScriptAnalyzer and fixed before use, not after). It remains idempotent (same create-if-missing pattern as Setup-Site.ps1) and is never invoked from application code - only as an explicit, manually-run deployment step, per SPEC SS10.

Credential handling. The live password reached this session as a plaintext note (db, opened by Daniel in the IDE) and is never embedded in any tracked file. .gitignore now excludes db, db.connectionstring, db.local, and *.secrets, added and verified (git check-ignore -v) before the actual connection string file was written to disk - not after. db.connectionstring (project root, untracked) holds the one live ADO connection string; Default.asp (both sites) reads it via Server.MapPath exactly like logDir/viewsDir and threads it through Application.Run's new dbConnectionString parameter - Framework/Database.wsc itself never touches the filesystem or ASP intrinsics for this. Test-app's public/ is two directory levels below the project root (root -> test-app -> public, vs. production's one level, root -> public), so its Default.asp needed "../../db.connectionstring", not "../db.connectionstring" - caught by checking the actual path depth rather than copy-pasting production's relative path. tests/Test-Components.vbs reads the same file directly and skips (NOT RUN, not FAIL) its entire Database contract section when the file is absent, so the rest of the suite still gives a clean verdict on a machine without this secret provisioned. The sa login is a real production-grade credential with full admin rights on the target server; using it directly from the app is accepted for this dev/test milestone but is a real least-privilege gap - flagged here for M6 hardening (a dedicated, minimally-privileged SQL login scoped to WscMvcTest), not silently treated as the final state.

A real bug caught in my own diagnostic code, not just the framework. The first version of the database_connectivity_under_app_pool_identity self-test check chained Open/ExecuteScalar/Close under one On Error Resume Next block and checked Err.Number only once at the end - exactly the anti-pattern AGENTS.md's VBScript rules forbid (“no On Error Resume Next across an entire function; check Err.Number immediately”). Because On Error Resume Next lets execution continue to the next statement after an error, ExecuteScalar's later “Database is not open” error silently overwrote Open's real, more informative failure - masking the actual root cause (SQL Server Network Interfaces) behind a misleading message. Fixed by checking Err.Number after each call individually, matching the rest of the codebase's established discipline, before trusting the result to diagnose the LocalDB failure above.

Framework/Database.wsc (WscMvc.Database) contract summary: explicit Open/Close (idempotent) and BeginTransaction/CommitTransaction/RollbackTransaction; ExecuteNonQuery/ExecuteScalar take sql (ADO positional ? placeholders), paramOrder (a comma-separated string, not an array - deliberately avoiding an untested VBScript-array-through-WSC-<public>-method marshaling question for zero real benefit), and paramsDict (a Scripting.Dictionary, the same object-parameter pattern already proven by ViewRenderer.wsc in M4). Parameter ADODB type/size are inferred from VBScript VarType()/Len() - a deliberate minimal v0.1 policy (not a general ORM type system, per SPEC SS3). Null values pass through as a real SQL NULL via Parameter.Value = Null, verified round-tripping correctly, not assumed. Verified via tests/Test-Components.vbs: a parameterized INSERT/SELECT round-trip of a value containing a literal single quote (proving no string-concatenation injection risk and no manual escaping needed), explicit commit and rollback (each checked against a COUNT(*) read-back, not just “no error”), and safe failure on an unopened/closed connection.

Powered by TurnKey Linux.