From fc4fba0e05850a71c290d09c34988c7fb71bdd70 Mon Sep 17 00:00:00 2001 From: sammiller Date: Fri, 9 Oct 2026 19:43:14 +0100 Subject: [PATCH 01/21] [PRF.11] Record the start identity, inherited WEB.40 surfaces and completion edges Co-Authored-By: Claude Haiku 5.5 --- docs/prf-11-profile-proof.md | 61 ++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 docs/prf-11-profile-proof.md diff --git a/docs/prf-11-profile-proof.md b/docs/prf-11-profile-proof.md new file mode 100644 index 0000000..3d6936a --- /dev/null +++ b/docs/prf-11-profile-proof.md @@ -0,0 +1,61 @@ +# PRF.11 Blazor WebAssembly production profile and generated C# SDK proof + +This is the record of the PRF.11 proof for the Web repository (Design WP-06.05 full, WP-06 package-level contribution, PG-23 foundation contribution). It supersedes PRF.08 (the React proof, `docs/prf-08-profile-proof.md`), whose evidence is not reused. Entries are added as each unit lands. A claim is recorded only with the evidence that backs it. Anything that needs a deployed ingress is recorded as "blocked on CLOUD.21/CLOUD.22, not proven" or as "blocked on CLOUD.85, not proven", never as deferred and never as proven. + +## Start identity (U1) + +| Item | Value | +| --- | --- | +| Task | PRF.11, lane runtime-proofs, kind proof, size L | +| Repository and branch | Web, `task/prf-11`, local worktree `.worktree/prf-11` | +| Claim | epoch 1, worker `w-deku-20261009-prf-11` (coordinator-held claim) | +| Pre-assigned reviewer | `w-deku-20261008-rev-prf-11` (brief section 10, S25); written into every record with `decision: approved` and `reviewedOn` at write time | +| Base | `origin/main` c8588681 (merge of PR #35 from `task/web-40`, WEB.40) | +| WEB.40 status | `delivered` in the ledger (`ledger/tasks/web-40.md` on Plan main); merged, deployed and released as `web-0.1.0-ci.111.1` (S28(a)) | +| Plan authority | the PRF.11 entry of `phase2-plans.json` (units U1 to U9, defaults D1 to D12), the task record, and brief sections 0, 0a, 0b, 10 and 11 | + +### Start edges + +| Start edge | Ledger status | Evidence used by this proof | +| --- | --- | --- | +| CON.92 (serialization posture and decode limits) | `inherited` (WP03.02, accepted source e6c4a77) | The decode limits are part of the Contracts source at the pinned identity. Contracts `docs/architecture.md`, "Serialization posture", at ca45f36 states the registry limits: 4 MiB unary and helper messages, 256 KiB inline pages, 32 KiB stream frames, 64 MiB large read projections, and at most 100 nested message levels, with typed `tooLarge`, `tooDeep` and `malformed` refusals. Source e6c4a77 is an ancestor of ca45f36. The P0c precondition holds. | +| WEB.40 (Blazor profile projects and C# static Site generator) | `delivered` | `src/ArcForges.Web.App`, `src/ArcForges.Web.Site`, `tools/ArcForges.Web.Tooling` at c8588681. | +| PRF.07 (deployed Cloud AOT probe reachable same-origin) | `complete` | Consumed only through the deployed ingress in the live units. No substitute probe is used. | +| CON.07 (BrowserSession and NativeAuth HTTP exceptions and generated C# codecs) | `complete` | The NuGet identity is `ArcForges.Contracts.PublicApi 1.0.0-ci.287.1` from source ca45f36cccbdd31380f76b8f6cdecc958fdcb430. That commit is an ancestor of Contracts `origin/main`. Build run 36966686225 published it to NuGet, npm and Maven. The pin is in `Directory.Packages.props` and is unchanged by U1. | + +### Completion edges (blocked, not proven) + +| Completion edge | Ledger status at start | Consequence for PRF.11 | +| --- | --- | --- | +| CLOUD.21 (real generated public methods on the deployed gRPC-Web ingress) | not started (no ledger record; waits on CLOUD.13) | Exact int64, uint64 and decimal calls, and the `WorkspaceService.GetHealth` and `UpdateSettings` round trips, are blocked on CLOUD.21, not proven. | +| CLOUD.22 (ArcResult and gRPC-Web status and trailer mapping) | not started (waits on CLOUD.21) | Typed failures, deadline and cancel-after-dispatch are blocked on CLOUD.22, not proven. | +| CLOUD.08 (readiness surfaces) | `complete` | Readiness observation is available for the live units. | +| CLOUD.71 (proof-origin route families for the React bytes) | `complete` (history for the React bytes only) | The `/api`, `/session/v1` and `/proof/v1` route precedence is not proven for the Blazor bytes. D11 treats the graph inconsistency as history (brief S16(a)). | +| CLOUD.85 (proof-origin serving of the Blazor Account and Chat shells and the C# Site, under base href "/", CLOUD.85 D1) | not started (no ledger record; waits on WEB.40 and the Cloud deploy) | Same-origin serving, the `/account/` and `/chat/` shells, digest-verified bytes and the exact CSP on served responses are blocked on CLOUD.85, not proven. PRF.11 cannot complete until CLOUD.85 delivers and its checks pass (S27(b)). | + +Completion of PRF.11 is therefore blocked. The offline units U1 to U9 are the delivery in this phase (brief S20(d)). + +### Shell and base href + +The Account and Chat profile shells are served at `/account/` and `/chat/` under base href `"/"` (CLOUD.85 D1, brief S27(b)). The framework files sit at the root. The host page `src/ArcForges.Web.App/wwwroot/index.html` already carries ``. The earlier base href wording `/account/` and `/chat/` is superseded. + +## Inherited WEB.40 surfaces + +These surfaces exist at c8588681 and are extended by PRF.11, not duplicated: + +- `src/ArcForges.Web.App`: the Account and Chat Razor pages, the `ProfileLayout`, the `HelloProbe`, `SessionProbe`, `ExactUnsigned` and `BoundedBody` probe classes, and the standalone profile project with `RunAOTCompilation` false. +- `src/ArcForges.Web.Site`: the deterministic static Site generator (`/`, `/hello/` and `/cloud-hello/` as static pages; no runtime JavaScript). +- `tools/ArcForges.Web.Tooling`: the candidate builder and verifier, the Site archive, the profile bundle (`profiles bundle` and `profiles verify`), and the profile budget tool (`profiles budget`). +- `tests/ArcForges.Web.App.Tests`, `tests/ArcForges.Web.Site.Tests`, `tests/ArcForges.Web.Tooling.Tests`, and the local opt-in browser project `tests/browser/ArcForges.Web.Browser.Tests` (Microsoft.Playwright for .NET, never built in CI). +- `eng/policy/profile-budgets.json`: the asset budgets for the `account` and `chat` profiles, with `interactionBudgets` status `re-baseline-pending`, owner PRF.11. +- The Ui project `src/ArcForges.Web.Ui` (including `WasmContentSecurityPolicy`) is outside PRF.11's write scope. It is consumed, not changed. + +## Declared write scope + +The write scope is the PRF.11 `writes` list in the task record, with the D3 additions (brief S20(b), S27(f)): `eng/policy/profile-budgets.json`, `tools/ArcForges.Web.Tooling/**`, `tests/ArcForges.Web.Tooling.Tests/**` and `tests/ArcForges.Web.Site.Tests/**`. `src/ArcForges.Web.Ui/**`, its tests and `.github/workflows/ci.yml` are not in scope. An in-browser CSP or framing change that needs a Ui edit stops the task for a new decision. + +## Gaps in the plan recorded at start + +- The published output of the App publish is produced by CI after the test step, so tests that read emitted files need a local publish first or are covered through the bundle tooling tests. This is recorded in the U2 section. +- Node is 24.20 locally against the 24.21 pin, so `npm run policy` is recorded as not run locally (hosted CI is authoritative). This is an environment gap and not a fix. +- The WSL2 Debian SDK is 10.0.400, so a locked Linux restore fails with NU1004 there. Linux checks are not run in this phase unless they are affected. From f27a9b4e431075da761628c75c249d17cd5da8d8 Mon Sep 17 00:00:00 2001 From: sammiller Date: Fri, 9 Oct 2026 19:45:40 +0100 Subject: [PATCH 02/21] [PRF.11] Assert the exact CSP token set and profile base hrefs The emitted policy is pinned as one string on both profile paths: script-src exactly 'self' and 'wasm-unsafe-eval', style-src 'self', and no unsafe-inline, unsafe-eval or unsafe-hashes. The host page declares exactly one base element at the root ("/", CLOUD.85 D1). The emitted bundle test builds the profile bundle from a publish whose shell is the real host page, and checks the served Account and Chat shells and the headers file. Co-Authored-By: Claude Haiku 5.5 --- .../WasmProfilePolicyTests.cs | 24 ++++ .../EmittedProfileContractTests.cs | 126 ++++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 tests/ArcForges.Web.Tooling.Tests/EmittedProfileContractTests.cs diff --git a/tests/ArcForges.Web.App.Tests/WasmProfilePolicyTests.cs b/tests/ArcForges.Web.App.Tests/WasmProfilePolicyTests.cs index a6571a3..f6b8edf 100644 --- a/tests/ArcForges.Web.App.Tests/WasmProfilePolicyTests.cs +++ b/tests/ArcForges.Web.App.Tests/WasmProfilePolicyTests.cs @@ -29,6 +29,30 @@ public void TheAppPolicyHasExactlyTheWasmScriptTokensAndSelfOnlyStyles() Assert.True(policy.Length < WasmContentSecurityPolicy.HeaderLineBudget); } + /// + /// The exact policy that the emitted host page produces (PRF.11 U2). Any change to the directive set or to its order is + /// a reviewed decision, because the proof deployment serves exactly this string on both profile paths. + /// + internal const string ExactPolicy = + "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'"; + + [Fact] + public void TheEmittedPolicyIsTheExactReviewedString() + { + Assert.Equal(ExactPolicy, WasmContentSecurityPolicy.FromHostPages([AppSource("wwwroot/index.html")])); + } + + [Fact] + public void TheHostPageDeclaresExactlyOneBaseAtTheRootAndTheShellsRelyOnIt() + { + // CLOUD.85 D1: the Account and Chat shells are served at /account/ and /chat/ under base href "/", so the framework + // resolves from the root. The host page carries one base element and it names the root. + var html = AppSource("wwwroot/index.html"); + var bases = Regex.Matches(html, @"]*>", RegexOptions.IgnoreCase); + var only = Assert.Single(bases); + Assert.Matches(@"^$", only.Value); + } + [Fact] public void ARequiredInlineScriptAddsItsHashAndNothingElseToScriptSources() { diff --git a/tests/ArcForges.Web.Tooling.Tests/EmittedProfileContractTests.cs b/tests/ArcForges.Web.Tooling.Tests/EmittedProfileContractTests.cs new file mode 100644 index 0000000..bd9461d --- /dev/null +++ b/tests/ArcForges.Web.Tooling.Tests/EmittedProfileContractTests.cs @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// The emitted Blazor profile contract (PRF.11 U2). The bundle that the candidate job publishes is built from a publish +// whose application shell is the real host page of ArcForges.Web.App. The test reads the emitted archive, so it checks +// what is served: the Account and Chat shells with base href "/" (CLOUD.85 D1), and the exact CSP token set on both +// profile paths of the headers file. Nothing here is a fixture of the policy: the expected string is pinned. +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using ArcForges.Web.Tooling.Profiles; +using Xunit; + +namespace ArcForges.Web.Tooling.Tests; + +public sealed class EmittedProfileContractTests +{ + /// The exact emitted policy, pinned in both test projects (ArcForges.Web.App.Tests pins the same string). + private const string ExactPolicy = + "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'"; + + private static readonly string HostPage = File.ReadAllText( + Path.Combine(RepositoryRoot.Find(), "src", "ArcForges.Web.App", "wwwroot", "index.html"), Encoding.UTF8); + + /// The bundle built from a synthetic publish whose shell and framework names come from the real host page. + private static ProfileBundleResult BuildFromHostPage() + { + var root = Path.Combine(Path.GetTempPath(), "arcforges-emitted-" + Guid.NewGuid().ToString("N")); + try + { + var wwwroot = Path.Combine(root, "wwwroot"); + Directory.CreateDirectory(Path.Combine(wwwroot, "_framework")); + var files = new Dictionary(StringComparer.Ordinal) + { + ["index.html"] = Encoding.UTF8.GetBytes(HostPage), + ["app.css"] = Encoding.UTF8.GetBytes("body{margin:0}"), + ["favicon.svg"] = Encoding.UTF8.GetBytes(""), + ["robots.txt"] = Encoding.UTF8.GetBytes("User-agent: *\nDisallow: /\n"), + ["_framework/blazor.webassembly.js"] = Encoding.UTF8.GetBytes("// loader"), + ["_framework/dotnet.js"] = Encoding.UTF8.GetBytes("// dotnet"), + ["_framework/ArcForges.Web.App.w1s8cjv5ju.wasm"] = Encoding.UTF8.GetBytes("\0asm"), + }; + var endpoints = new JsonArray(); + foreach (var (path, bytes) in files) + { + var target = Path.Combine(wwwroot, path.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + File.WriteAllBytes(target, bytes); + endpoints.Add(new JsonObject + { + ["Route"] = path, + ["AssetFile"] = path, + ["EndpointProperties"] = new JsonArray(new JsonObject + { + ["Name"] = "integrity", + ["Value"] = "sha256-" + Convert.ToBase64String(SHA256.HashData(bytes)), + }), + }); + } + File.WriteAllText(Path.Combine(root, "app.staticwebassets.endpoints.json"), + new JsonObject { ["Version"] = 1, ["Endpoints"] = endpoints }.ToJsonString()); + return ProfileBundle.Build(root); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + + private static Dictionary Members(ProfileBundleResult bundle) => + TarArchive.Read(bundle.Archive).ToDictionary(entry => entry.Path, entry => Encoding.UTF8.GetString(entry.Bytes), StringComparer.Ordinal); + + [Fact] + public void TheBundleServesExactlyTheAccountAndChatProfilesAndVerifies() + { + var bundle = BuildFromHostPage(); + + Assert.Equal(new[] { "account", "chat" }, ProfileBundle.Profiles); + var summary = ProfileBundle.Verify(bundle.Archive, bundle.Digest); + Assert.Equal(bundle.Members, summary.Members); + } + + [Fact] + public void EachEmittedProfileShellHasExactlyOneRootBaseAndTheSameBytes() + { + var members = Members(BuildFromHostPage()); + + foreach (var profile in ProfileBundle.Profiles) + { + var shell = members[profile + "/index.html"]; + var bases = Regex.Matches(shell, @"]*>", RegexOptions.IgnoreCase); + var only = Assert.Single(bases); + Assert.Matches(@"^$", only.Value); + Assert.Contains("_framework/blazor.webassembly.js", shell, StringComparison.Ordinal); + Assert.Contains("
= 0, "The headers file has no block for /" + profile + "/*."); + Assert.Equal(" Content-Security-Policy: " + ExactPolicy, lines[index + 1]); + } + Assert.Equal(ProfileBundle.Profiles.Length, lines.Count(line => line.StartsWith(" Content-Security-Policy: ", StringComparison.Ordinal))); + Assert.Contains("script-src 'self' 'wasm-unsafe-eval';", ExactPolicy, StringComparison.Ordinal); + Assert.Contains("style-src 'self';", ExactPolicy, StringComparison.Ordinal); + Assert.DoesNotContain("'unsafe-inline'", headers, StringComparison.Ordinal); + Assert.DoesNotContain("'unsafe-eval'", headers, StringComparison.Ordinal); + Assert.DoesNotContain("'unsafe-hashes'", headers, StringComparison.Ordinal); + } + + [Fact] + public void ThePolicyLineStaysWithinTheHeaderLineBudget() + { + Assert.True(ExactPolicy.Length < ProfileBundle.HeaderLineBudget); + } +} From 9edf0014e8b65683c847af731f4c0e8861208799 Mon Sep 17 00:00:00 2001 From: sammiller Date: Fri, 9 Oct 2026 19:51:21 +0100 Subject: [PATCH 03/21] [PRF.11] Offline exact-value and decode-limit fixtures The exact int64, uint64 and decimal checks use the pinned Contracts Foundation helpers (ExactInteger, ExactDecimal) on canonical text, including the int64 edges and values above 2^53. The CON.92 registry bounds (4 MiB unary, 256 KiB inline page, 32 KiB stream frame, 64 MiB large projection, 100 nested levels) are pinned, and the generated parser is exercised at each exact bound and one byte over, with truncated, bare-tag and reserved-wire-type frames refused as malformed. The Hello greeting now sets the gRPC channel's send and receive limits to the unary class. This commit also carries the CSP section of the proof record (U2), which the U2 commit left out. Co-Authored-By: Claude Haiku 5.5 --- docs/prf-11-profile-proof.md | 27 +++++ src/ArcForges.Web.App/Probe/HelloProbe.cs | 4 + .../ExactUnsignedTests.cs | 41 +++++++ .../HelloProbeTests.cs | 16 +++ .../ArcForges.Web.App.Tests/WireLimitTests.cs | 103 ++++++++++++++++++ 5 files changed, 191 insertions(+) create mode 100644 tests/ArcForges.Web.App.Tests/WireLimitTests.cs diff --git a/docs/prf-11-profile-proof.md b/docs/prf-11-profile-proof.md index 3d6936a..121c3c7 100644 --- a/docs/prf-11-profile-proof.md +++ b/docs/prf-11-profile-proof.md @@ -59,3 +59,30 @@ The write scope is the PRF.11 `writes` list in the task record, with the D3 addi - The published output of the App publish is produced by CI after the test step, so tests that read emitted files need a local publish first or are covered through the bundle tooling tests. This is recorded in the U2 section. - Node is 24.20 locally against the 24.21 pin, so `npm run policy` is recorded as not run locally (hosted CI is authoritative). This is an environment gap and not a fix. - The WSL2 Debian SDK is 10.0.400, so a locked Linux restore fails with NU1004 there. Linux checks are not run in this phase unless they are affected. + +## Local CI conditions used for the gates + +The gates run the workflow's commands with `GITHUB_ACTIONS=true`, `CI=true` and `NUGET_PACKAGES` set to an empty folder under the session scratchpad, so every locked restore downloads from the locked feeds. The ArcForges.Build.Policy analyzer (AFP006) requires a complete CI identity under `GITHUB_ACTIONS=true`, so the gate sets `GITHUB_SHA` to the commit under test and `GITHUB_RUN_ID`, `GITHUB_RUN_ATTEMPT` and `GITHUB_RUN_NUMBER` to the placeholder `1`. These placeholders are local emulation values. They name no hosted run, and no artifact from these local gates is published or deployed. Hosted CI remains the authority for the release identity. + +## CSP and profile shells (U2) + +The emitted policy is the one string in `WasmContentSecurityPolicy` for the host page, pinned in `ArcForges.Web.App.Tests` (`WasmProfilePolicyTests.ExactPolicy`) and in `ArcForges.Web.Tooling.Tests` (`EmittedProfileContractTests`): + +- `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'` + +`script-src` is exactly `'self'` and `'wasm-unsafe-eval'` (the host page has no inline script, so no hash is added). `style-src` is `'self'`. No `'unsafe-inline'`, `'unsafe-eval'` or `'unsafe-hashes'` appears in the policy or the headers file. The bundle test builds the profile bundle from a publish whose shell is the real host page, then reads the served `account/index.html` and `chat/index.html` (identical bytes, one `` each) and the `_headers` file, which names the exact policy on `/account/*` and `/chat/*`. + +Open point for the in-browser check (U7 and the local opt-in run, not yet executed): `base-uri 'none'` is a CSP directive that restricts the URLs a `` element may use. Under CSP Level 3 a `` may then be blocked, so the document base would stay at `/account/` or `/chat/` and root-relative framework loads would resolve under the shell path. The offline tests cannot show this. It is not claimed as proven or as a defect. If the in-browser check confirms it, the fix is in the Ui policy, which is outside the PRF.11 write scope, so the task stops for a new decision (brief 5.13). + +## Exact values and CON.92 limits (U3) + +The exact-value helpers come from the pinned Contracts Foundation package (`ArcForges.Contracts.Foundation` 1.0.0-ci.287.1), not from new App helpers: + +- `ExactInteger.ParseInt64` and `ExactInteger.ParseUInt64` (canonical text, no sign, leading zero or floating point; int64 edges and values above 2^53 stay exact). The App's own `Exact.TryUnsigned` is the probe's uint64 path. +- `ExactDecimal` (the shared exact decimal bound: at most nine fractional digits and twenty-eight significant digits, declared scale kept, no negative zero). + +The CON.92 registry bounds are read from `WireLimits.Bytes` and pinned: unary and helper messages 4 MiB, inline pages 256 KiB, stream frames 32 KiB, large read projections 64 MiB, and 100 nested message levels. `WireLimitTests` decodes at the exact bound (accepted) and one byte over (`TooLarge`), shows that the same bytes are admitted as a unary message and refused as a stream frame, refuses truncated, bare-tag and reserved-wire-type frames as `Malformed`, and checks that encoding refuses one byte over the class. + +`HelloProbe` now sets the gRPC channel's `MaxReceiveMessageSize` and `MaxSendMessageSize` to the unary class (4 MiB), so the greeting, a unary call, is bounded explicitly rather than by the library default. `HelloProbeTests.AGreetingReplyAboveTheUnaryMessageBoundIsRefusedNeverAccepted` pins that a reply whose message is over the class is `Malformed` and never a greeting. + +Still not claimed: the live int64, uint64 and decimal calls through the deployed Cloud probe (blocked on CLOUD.21 and CLOUD.22, not proven), and the framing of server-streamed frames on the deployed ingress (U4 records the offline framing fixtures only). diff --git a/src/ArcForges.Web.App/Probe/HelloProbe.cs b/src/ArcForges.Web.App/Probe/HelloProbe.cs index f4a5628..adf5cd2 100644 --- a/src/ArcForges.Web.App/Probe/HelloProbe.cs +++ b/src/ArcForges.Web.App/Probe/HelloProbe.cs @@ -2,6 +2,7 @@ using System.Globalization; using System.Net; using System.Text; +using ArcForges.Contracts.Foundation.Serialization; using ArcForges.Contracts.Hello.V1; using Grpc.Core; using Grpc.Net.Client; @@ -62,6 +63,9 @@ public async Task SayHelloAsync(string name, CancellationToken cancellat HttpHandler = new GrpcWebHandler(GrpcWebMode.GrpcWeb, new ObservingHandler(_transport, seen)), DisposeHttpClient = false, ThrowOperationCanceledOnCancellation = true, + // The greeting is a unary call, so both directions are bounded by the CON.92 unary message class (4 MiB). + MaxReceiveMessageSize = WireLimits.Bytes(WireLimit.UnaryMessage), + MaxSendMessageSize = WireLimits.Bytes(WireLimit.UnaryMessage), }); try { diff --git a/tests/ArcForges.Web.App.Tests/ExactUnsignedTests.cs b/tests/ArcForges.Web.App.Tests/ExactUnsignedTests.cs index 0e0509a..19f4002 100644 --- a/tests/ArcForges.Web.App.Tests/ExactUnsignedTests.cs +++ b/tests/ArcForges.Web.App.Tests/ExactUnsignedTests.cs @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-only // Port of the 64-bit case of tests/unit/app-session.test.ts: canonical decimal text in, the same text out, and no value passes // through a floating-point number. +using ArcForges.Contracts.Foundation.Values; using ArcForges.Web.App.Probe; using Xunit; @@ -55,4 +56,44 @@ public void AnythingThatIsNotCanonicalUint64TextIsRefused() Assert.False(Exact.TryUnsigned(value, out _), value); Assert.False(Exact.TryUnsigned(null, out _)); } + + [Fact] + public void SignedSixtyFourBitValuesAtTheirEdgesAndAboveTheDoubleRangeStayExact() + { + // The generated Contracts parser (ExactInteger.ParseInt64) holds int64 values exactly, at both edges and above 2^53. + Assert.Equal(long.MinValue, ExactInteger.ParseInt64("-9223372036854775808")); + Assert.Equal(long.MaxValue, ExactInteger.ParseInt64("9223372036854775807")); + Assert.Equal(9007199254740993L, ExactInteger.ParseInt64("9007199254740993")); + Assert.NotEqual(ExactInteger.ParseInt64("9007199254740993"), ExactInteger.ParseInt64("9007199254740992")); + } + + [Fact] + public void NonCanonicalOrOutOfRangeSignedTextIsRefusedNotRounded() + { + foreach (var value in new[] { "-0", "01", "+1", "1e3", " 1", "1 ", "0x10", "9223372036854775808", "-9223372036854775809" }) + Assert.ThrowsAny(() => ExactInteger.ParseInt64(value)); + } + + [Fact] + public void DecimalTextKeepsItsCoefficientAndDeclaredScaleExactly() + { + // Shared exact decimal (Foundation): canonical wire text, declared scale preserved, no binary floating point. + // The shared bound admits at most nine fractional digits and twenty-eight significant digits. + foreach (var value in new[] { "0", "-12.5", "12345678901234567890.1200", "0.000000001" }) + { + var exact = new ExactDecimal(value); + Assert.Equal(value, exact.Value); + Assert.Equal(value, new ExactDecimal(value).ToWire().Value); + } + var trailing = new ExactDecimal("12345678901234567890.1200"); + Assert.Equal(4, trailing.Scale); + Assert.Equal(System.Numerics.BigInteger.Parse("123456789012345678901200", System.Globalization.CultureInfo.InvariantCulture), trailing.Coefficient); + } + + [Fact] + public void NonCanonicalDecimalTextIsRefused() + { + foreach (var value in new[] { "1e3", "+1", " 1", "NaN", "Infinity" }) + Assert.ThrowsAny(() => new ExactDecimal(value)); + } } diff --git a/tests/ArcForges.Web.App.Tests/HelloProbeTests.cs b/tests/ArcForges.Web.App.Tests/HelloProbeTests.cs index ad3dacb..85dd794 100644 --- a/tests/ArcForges.Web.App.Tests/HelloProbeTests.cs +++ b/tests/ArcForges.Web.App.Tests/HelloProbeTests.cs @@ -3,10 +3,12 @@ // Grpc.Net.Client.Web. The server is a test-only double; every answer is a real gRPC-Web frame sequence. using System.Net; using System.Text; +using ArcForges.Contracts.Foundation.Serialization; using ArcForges.Contracts.Hello.V1; using ArcForges.Web.App.Probe; using ArcForges.Web.App.Tests.Fixtures; using ArcForges.Web.App.Tests.TestDoubles; +using Google.Protobuf; using Grpc.Core; using Xunit; @@ -160,6 +162,20 @@ public async Task AReplyThatIsNotTheGreetingForThisNameIsMalformed() Assert.True(await FailureOf(() => ProbeFor(server).SayHelloAsync("ArcForges", CancellationToken.None)) == FailureKind.Malformed); } + [Fact] + public async Task AGreetingReplyAboveTheUnaryMessageBoundIsRefusedNeverAccepted() + { + // CON.92: a unary reply is bounded by the 4 MiB unary message class. A data frame whose message is over the class is + // refused by the client's receive bound, and the probe never presents it as a greeting. + var bound = (int)WireLimits.Bytes(WireLimit.UnaryMessage); + var oversized = new SayHelloResponse { Message = new string('h', bound) }.ToByteArray(); + var body = ProbeFixtures.Concat(ProbeFixtures.Frame(oversized, 0), ProbeFixtures.Trailers("grpc-status: 0\r\n")); + var server = ScriptedServer.Always(() => Answer(body)); + + // The client refuses the frame before any greeting is decoded, and a successful status with no readable reply is malformed. + Assert.Equal(FailureKind.Malformed, await FailureOf(() => ProbeFor(server).SayHelloAsync("ArcForges", CancellationToken.None))); + } + [Fact] public async Task TheUsersCancellationAbortsTheRequestAndIsReportedAsCancelled() { diff --git a/tests/ArcForges.Web.App.Tests/WireLimitTests.cs b/tests/ArcForges.Web.App.Tests/WireLimitTests.cs new file mode 100644 index 0000000..97c8cfb --- /dev/null +++ b/tests/ArcForges.Web.App.Tests/WireLimitTests.cs @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// The CON.92 decode and encode limits (PRF.11 U3, offline). The bounds are the registry values of the Contracts wire codec, and +// the boundary is checked on the real generated parser: the exact bound decodes, one byte over is refused as too large, and a +// truncated frame is malformed. Live cases (the deployed ingress) are local opt-in and stay in U7. +using ArcForges.Contracts.Foundation.Serialization; +using Google.Protobuf; +using Xunit; +using WireDecimal = ArcForges.Contracts.Foundation.V1.Decimal; + +namespace ArcForges.Web.App.Tests; + +public sealed class WireLimitTests +{ + /// A decimal message whose binary encoding is exactly bytes. + private static WireDecimal MessageOfEncodedSize(int size) + { + // The encoding is one tag byte, a length varint and the text, so it grows with the text. The text length that gives + // the size is found by counting down from the size, which is at most a few steps because the varint adds at most five. + for (var length = size; length >= 0; length--) + { + var candidate = new WireDecimal { Value = new string('7', length) }; + if (candidate.CalculateSize() == size) + return candidate; + } + throw new InvalidOperationException("No text length gives the requested encoded size."); + } + + [Fact] + public void TheRegistryByteBoundsAreTheReviewedCon92Values() + { + Assert.Equal(4 * 1024 * 1024, WireLimits.Bytes(WireLimit.UnaryMessage)); + Assert.Equal(4 * 1024 * 1024, WireLimits.Bytes(WireLimit.HelperMessage)); + Assert.Equal(256 * 1024, WireLimits.Bytes(WireLimit.InlinePage)); + Assert.Equal(32 * 1024, WireLimits.Bytes(WireLimit.StreamFrame)); + Assert.Equal(64 * 1024 * 1024, WireLimits.Bytes(WireLimit.LargeProjection)); + Assert.Equal(100, WireLimits.NestedMessageLevels); + } + + [Fact] + public void ADecodeAtTheStreamFrameBoundIsAcceptedAndOneByteOverIsTooLarge() + { + var bound = WireLimits.Bytes(WireLimit.StreamFrame); + var atBound = MessageOfEncodedSize(bound).ToByteArray(); + Assert.Equal(bound, atBound.Length); + Assert.True(ContractWire.TryDecode(WireDecimal.Parser, atBound, WireLimit.StreamFrame, out var decoded, out _)); + Assert.NotNull(decoded); + Assert.Equal(MessageOfEncodedSize(bound).Value, decoded.Value); + + var overBound = MessageOfEncodedSize(bound + 1).ToByteArray(); + Assert.Equal(bound + 1, overBound.Length); + Assert.False(ContractWire.TryDecode(WireDecimal.Parser, overBound, WireLimit.StreamFrame, out _, out var refused)); + Assert.Equal(ContractSerializationFailure.TooLarge, refused); + } + + [Fact] + public void TheSameBytesAreAdmittedAsAUnaryMessageAndRefusedAsAStreamFrame() + { + var bytes = MessageOfEncodedSize(WireLimits.Bytes(WireLimit.StreamFrame) + 1).ToByteArray(); + + Assert.True(ContractWire.TryDecode(WireDecimal.Parser, bytes, WireLimit.UnaryMessage, out _, out _)); + Assert.False(ContractWire.TryDecode(WireDecimal.Parser, bytes, WireLimit.StreamFrame, out _, out var failure)); + Assert.Equal(ContractSerializationFailure.TooLarge, failure); + } + + [Fact] + public void AUnaryBoundMessageDecodesAndOneByteOverIsRefused() + { + var bound = WireLimits.Bytes(WireLimit.UnaryMessage); + Assert.True(ContractWire.TryDecode(WireDecimal.Parser, MessageOfEncodedSize(bound).ToByteArray(), WireLimit.UnaryMessage, out _, out _)); + Assert.False(ContractWire.TryDecode(WireDecimal.Parser, MessageOfEncodedSize(bound + 1).ToByteArray(), WireLimit.UnaryMessage, out _, out var failure)); + Assert.Equal(ContractSerializationFailure.TooLarge, failure); + } + + [Fact] + public void AMalformedOrTruncatedFrameIsMalformedNeverAMessage() + { + // Field 1, length-delimited, declares five bytes and carries one. + byte[] truncated = [0x0A, 0x05, 0x61]; + Assert.False(ContractWire.TryDecode(WireDecimal.Parser, truncated, WireLimit.StreamFrame, out _, out var truncatedFailure)); + Assert.Equal(ContractSerializationFailure.Malformed, truncatedFailure); + + // A tag with no length, and a reserved wire type (7), are both malformed. + byte[] bareTag = [0x0A]; + Assert.False(ContractWire.TryDecode(WireDecimal.Parser, bareTag, WireLimit.StreamFrame, out _, out var bareFailure)); + Assert.Equal(ContractSerializationFailure.Malformed, bareFailure); + + byte[] reservedWireType = [0x0F, 0x00]; + Assert.False(ContractWire.TryDecode(WireDecimal.Parser, reservedWireType, WireLimit.StreamFrame, out _, out var reservedFailure)); + Assert.Equal(ContractSerializationFailure.Malformed, reservedFailure); + } + + [Fact] + public void EncodingAcceptsTheStreamFrameBoundAndRefusesOneByteOver() + { + var bound = WireLimits.Bytes(WireLimit.StreamFrame); + var encoded = ContractWire.Encode(MessageOfEncodedSize(bound), WireLimit.StreamFrame); + Assert.Equal(bound, encoded.Length); + + var refusal = Assert.Throws(() => + ContractWire.Encode(MessageOfEncodedSize(bound + 1), WireLimit.StreamFrame)); + Assert.Equal(ContractSerializationFailure.TooLarge, refusal.Failure); + } +} From b21aa649fbe106a4eb3004a2f6c55e19b4da81b2 Mon Sep 17 00:00:00 2001 From: sammiller Date: Fri, 9 Oct 2026 19:52:38 +0100 Subject: [PATCH 04/21] [PRF.11] Stream framing fixtures and the transport decision record The binary application/grpc-web+proto and the grpc-web-text server-stream framings are tested as fixtures only: frames decode in order with the trailers status, the text body decodes to the same bytes, a data frame over the 32 KiB stream bound is refused, and truncated, short-header and malformed base64 bodies are malformed. The transport decision stays open with binary first and grpc-web-text only if the observed run fails. Its sources conflict (brief section 5, item 12), and no deployed stream is claimed. Co-Authored-By: Claude Haiku 5.5 --- docs/prf-11-profile-proof.md | 4 + docs/prf-11-stream-transport.md | 42 +++++ .../StreamFramingTests.cs | 147 ++++++++++++++++++ 3 files changed, 193 insertions(+) create mode 100644 docs/prf-11-stream-transport.md create mode 100644 tests/ArcForges.Web.App.Tests/StreamFramingTests.cs diff --git a/docs/prf-11-profile-proof.md b/docs/prf-11-profile-proof.md index 121c3c7..e684eff 100644 --- a/docs/prf-11-profile-proof.md +++ b/docs/prf-11-profile-proof.md @@ -85,4 +85,8 @@ The CON.92 registry bounds are read from `WireLimits.Bytes` and pinned: unary an `HelloProbe` now sets the gRPC channel's `MaxReceiveMessageSize` and `MaxSendMessageSize` to the unary class (4 MiB), so the greeting, a unary call, is bounded explicitly rather than by the library default. `HelloProbeTests.AGreetingReplyAboveTheUnaryMessageBoundIsRefusedNeverAccepted` pins that a reply whose message is over the class is `Malformed` and never a greeting. +## Server-stream framing (U4) + +The binary and grpc-web-text server-stream framings are recorded as fixtures in `StreamFramingTests`, and the transport decision (binary first, grpc-web-text only if the observed run fails) is recorded in `docs/prf-11-stream-transport.md`. That decision is open until the local opt-in observation (LS2) is made. No deployed stream is claimed. + Still not claimed: the live int64, uint64 and decimal calls through the deployed Cloud probe (blocked on CLOUD.21 and CLOUD.22, not proven), and the framing of server-streamed frames on the deployed ingress (U4 records the offline framing fixtures only). diff --git a/docs/prf-11-stream-transport.md b/docs/prf-11-stream-transport.md new file mode 100644 index 0000000..9e22a6e --- /dev/null +++ b/docs/prf-11-stream-transport.md @@ -0,0 +1,42 @@ +# PRF.11 server-stream transport decision (pending the observed run) + +Status: open. The decision is recorded as a candidate order with the offline evidence below. It is not final until the local opt-in run against the deployed ingress observes the binary stream (LS2 in the PRF.11 plan). Until then, no transport is claimed as proven. + +## The two candidate framings + +| Framing | Content type | Browser path | Offline evidence | +| --- | --- | --- | --- | +| Binary server stream | `application/grpc-web+proto` | .NET 10 browser streaming client (`HttpClient` streaming response in Blazor WebAssembly) | `tests/ArcForges.Web.App.Tests/StreamFramingTests.cs`: frames decode in order, the trailers frame carries `grpc-status: 0`, a truncated frame is malformed, and a data frame over the 32 KiB stream bound is refused. | +| grpc-web-text server stream | `application/grpc-web-text` | the whole body is base64 and decodes before the frames are read | `StreamFramingTests`: the text body decodes to the same bytes as the binary body; a body that is not a whole number of four-character groups, or that has a character outside the base64 alphabet, is malformed. | + +Both framings are tested as fixtures only. The decoders in the test are not product code. The App has no streaming surface in this phase, and a streamed message is decoded through the generated parser with the CON.92 stream frame class (`WireLimit.StreamFrame`, 32 KiB). + +## Decision order + +1. Binary `application/grpc-web+proto` first. Requirement 12 (line 418) makes binary server streaming the required path, and the brief (section 11, S3) keeps streaming in V1. +2. grpc-web-text only on server-stream routes, and only if the binary stream is observed to fail in the deployed run. The variant is then recorded here with the observed behaviour. + +Nothing in this record selects grpc-web-text in advance, because the sources conflict (brief section 5, item 12). + +## Source pages (cited, to be re-read at the observed run) + +These are the two sources the brief records as verified (brief section 7, item 5.12 refined). They are cited here with the statement that the brief attributes to each. Their exact text is re-read and quoted when the observed run is recorded, because the two sources conflict. + +- The grpc-web README, `https://raw.githubusercontent.com/grpc/grpc-web/master/README.md`. Brief section 7 records that it states server streaming is supported only in grpc-web-text mode in browsers. +- The Microsoft Learn gRPC-Web page for .NET (the page the brief calls the Learn gRPC-Web page; its exact address is recorded in the PRF.11 observed run, because the brief does not record it). Brief section 7 records that it returned 404 in two earlier passes, was retrieved with HTTP 200 on 2026-10-08 by the decision-record reviewers, and states that `GrpcWebText` is required for server streaming in browsers. + +The conflict is the reason the decision is open. The .NET 10 browser streaming client is expected to read a binary stream, and the grpc-web README says browsers need the text mode. The observed run settles it. + +## What the observed run must record (LS2) + +- The host, browser (installed Chrome or Edge, with its version), the commit, the date and the deployment identity, claimant-reported. +- The binary `application/grpc-web+proto` stream against the deployed ingress: the frames received in order, the trailers status, and whether the stream was delivered incrementally or only at the end. +- The grpc-web-text stream only if the binary stream fails, with the same fields. +- The exact source text quoted from both pages at the time of the run. +- A wire-registry row, if the framing variant needs one, as a handoff to Contracts. It is not a PRF.11 write. + +## Not claimed + +- No live stream is observed in this phase. The deployed ingress depends on CLOUD.21 and CLOUD.22 (blocked, not proven) and on CLOUD.85 (blocked, not proven). +- The offline fixtures prove the framing and the bounds only. They do not prove that a browser delivers a server stream incrementally. +- The .NET 10 browser streaming client is not exercised offline in this phase. diff --git a/tests/ArcForges.Web.App.Tests/StreamFramingTests.cs b/tests/ArcForges.Web.App.Tests/StreamFramingTests.cs new file mode 100644 index 0000000..c4dd669 --- /dev/null +++ b/tests/ArcForges.Web.App.Tests/StreamFramingTests.cs @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Offline server-stream framing fixtures (PRF.11 U4). Both candidate framings are tested as fixtures only: the binary +// application/grpc-web+proto stream (the first choice, requirement 12 line 418) and the grpc-web-text variant +// (application/grpc-web-text, the base64 encoding of the same frames). The decoders here are test-only and check the +// framing, the CON.92 stream frame bound and the malformed cases. They make no claim about the deployed ingress, which is +// observed only in the local opt-in run (U7 and LS2). +using System.Buffers.Binary; +using System.Text; +using ArcForges.Contracts.Foundation.Serialization; +using ArcForges.Web.App.Probe; +using ArcForges.Web.App.Tests.Fixtures; +using Google.Protobuf; +using Xunit; +using WireDecimal = ArcForges.Contracts.Foundation.V1.Decimal; + +namespace ArcForges.Web.App.Tests; + +public sealed class StreamFramingTests +{ + /// One decoded frame: its flag byte and its payload. + private sealed record Frame(byte Flag, byte[] Payload); + + /// + /// The test-only binary decoder: a flag byte, a big-endian 32-bit length and the payload, repeated. A frame that declares + /// more bytes than remain is malformed, and a data frame above the stream frame bound is too large. + /// + private static List DecodeBinary(ReadOnlySpan body) + { + var frames = new List(); + var offset = 0; + while (offset < body.Length) + { + if (body.Length - offset < 5) + throw new ProbeFailureException(FailureKind.Malformed); + var flag = body[offset]; + var length = BinaryPrimitives.ReadUInt32BigEndian(body.Slice(offset + 1, 4)); + offset += 5; + if (length > body.Length - offset) + throw new ProbeFailureException(FailureKind.Malformed); + var isTrailer = (flag & 0x80) != 0; + if (!isTrailer && length > WireLimits.Bytes(WireLimit.StreamFrame)) + throw new ProbeFailureException(FailureKind.Malformed); + frames.Add(new Frame(flag, body.Slice(offset, (int)length).ToArray())); + offset += (int)length; + } + return frames; + } + + /// The test-only grpc-web-text decoder: the whole body is base64, decoded before the frames are read. + private static byte[] DecodeText(string body) + { + // A complete grpc-web-text body is a whole number of four-character base64 groups. + if (body.Length % 4 != 0) + throw new ProbeFailureException(FailureKind.Malformed); + try + { + return Convert.FromBase64String(body); + } + catch (FormatException exception) + { + throw new ProbeFailureException(FailureKind.Malformed, exception); + } + } + + /// A server stream of three data messages and an OK trailers frame, as binary frames. + private static byte[] ThreeMessageStream() + { + var parts = new List(); + foreach (var text in new[] { "9007199254740993", "18446744073709551615", "-9223372036854775808" }) + parts.Add(ProbeFixtures.Frame(new WireDecimal { Value = text }.ToByteArray(), 0)); + parts.Add(ProbeFixtures.Trailers("grpc-status: 0\r\n")); + return ProbeFixtures.Concat([.. parts]); + } + + [Fact] + public void TheBinaryStreamDecodesToItsMessagesInOrderAndEndsWithItsStatus() + { + var frames = DecodeBinary(ThreeMessageStream()); + + Assert.Equal(4, frames.Count); + Assert.Equal(new[] { "9007199254740993", "18446744073709551615", "-9223372036854775808" }, + frames.Take(3).Select(frame => WireDecimal.Parser.ParseFrom(frame.Payload).Value).ToArray()); + Assert.True((frames[3].Flag & 0x80) != 0); + Assert.Equal("grpc-status: 0\r\n", Encoding.UTF8.GetString(frames[3].Payload)); + } + + [Fact] + public void TheGrpcWebTextStreamCarriesTheSameFramesAsTheBinaryStream() + { + var binary = ThreeMessageStream(); + var text = Convert.ToBase64String(binary); + + var decoded = DecodeText(text); + + Assert.Equal(binary, decoded); + Assert.Equal( + DecodeBinary(binary).Select(frame => Convert.ToBase64String(frame.Payload)), + DecodeBinary(decoded).Select(frame => Convert.ToBase64String(frame.Payload))); + } + + [Fact] + public void AStreamFrameAtTheBoundIsAcceptedAndOneByteOverIsMalformedNeverAPartialMessage() + { + var bound = (int)WireLimits.Bytes(WireLimit.StreamFrame); + // A data frame of exactly the stream bound is accepted; the payload is an opaque byte run for the framing check. + var atBound = ProbeFixtures.Frame(new byte[bound], 0); + Assert.Single(DecodeBinary(atBound)); + + var overBound = ProbeFixtures.Frame(new byte[bound + 1], 0); + Assert.Throws(() => DecodeBinary(overBound)); + } + + [Fact] + public void ATruncatedBinaryFrameIsMalformedAndNeverAMessage() + { + // The header declares five payload bytes; only one remains. + byte[] truncated = [0x00, 0x00, 0x00, 0x00, 0x05, 0x61]; + Assert.Throws(() => DecodeBinary(truncated)); + + // A header cut short is malformed too. + byte[] shortHeader = [0x00, 0x00, 0x00]; + Assert.Throws(() => DecodeBinary(shortHeader)); + } + + [Fact] + public void AMalformedGrpcWebTextBodyIsMalformedAndNeverAMessage() + { + // Not a whole number of four-character groups. + Assert.Throws(() => DecodeText("AAAAA")); + // A character outside the base64 alphabet. + Assert.Throws(() => DecodeText("AA!A")); + // A body that is valid base64 but whose frames are truncated is malformed at the framing layer. + var truncated = Convert.ToBase64String(new byte[] { 0x00, 0x00, 0x00, 0x00, 0x09, 0x61 }); + Assert.Throws(() => DecodeBinary(DecodeText(truncated))); + } + + [Fact] + public void EachStreamedMessageDecodesAsAStreamFrameMessageWithinTheBound() + { + // The payloads are the generated messages, checked against the stream frame class of CON.92. + foreach (var frame in DecodeBinary(ThreeMessageStream()).Where(frame => (frame.Flag & 0x80) == 0)) + { + Assert.True(ContractWire.TryDecode(WireDecimal.Parser, frame.Payload, WireLimit.StreamFrame, out var message, out _)); + Assert.NotNull(message); + } + } +} From a1ede81d7a418db0a997b048e212217a08d6f368 Mon Sep 17 00:00:00 2001 From: sammiller Date: Fri, 9 Oct 2026 19:58:18 +0100 Subject: [PATCH 05/21] [PRF.11] Static Site no-script reading and determinism evidence The Site output carries no script, module or WebAssembly file, offline, and the local opt-in no-JavaScript read of the public pages skips cleanly without its opt-in. The determinism gate builds the Site twice and diffs the trees. The one local opt-in run used the installed Chrome by path (no download) against a loopback static server and passed; it is claimant-reported and is not a deployed-origin observation. Co-Authored-By: Claude Haiku 5.5 --- docs/prf-11-profile-proof.md | 8 +++ .../SiteOutputTests.cs | 18 +++++++ .../LocalNoScriptBrowserTests.cs | 50 +++++++++++++++++++ 3 files changed, 76 insertions(+) create mode 100644 tests/browser/ArcForges.Web.Browser.Tests/LocalNoScriptBrowserTests.cs diff --git a/docs/prf-11-profile-proof.md b/docs/prf-11-profile-proof.md index e684eff..4e31eba 100644 --- a/docs/prf-11-profile-proof.md +++ b/docs/prf-11-profile-proof.md @@ -85,6 +85,14 @@ The CON.92 registry bounds are read from `WireLimits.Bytes` and pinned: unary an `HelloProbe` now sets the gRPC channel's `MaxReceiveMessageSize` and `MaxSendMessageSize` to the unary class (4 MiB), so the greeting, a unary call, is bounded explicitly rather than by the library default. `HelloProbeTests.AGreetingReplyAboveTheUnaryMessageBoundIsRefusedNeverAccepted` pins that a reply whose message is over the class is `Malformed` and never a greeting. +## Static Site: no-script reading and determinism (U5) + +- Determinism: the Site is built twice with the candidate command (`site build --out ... --source-ref `) and the two output trees are identical (`diff -r`, nine files, including `_headers` and the content-hashed stylesheet). The hosted csharp job repeats this as a gate. +- Offline, CI-eligible: `SiteOutputTests.EveryPageIsReadableWithScriptingDisabled` (existing) scans every page for script elements, event-handler attributes, framework references and `javascript:` URLs. The new `SiteOutputTests.TheOutputCarriesNoScriptOrWebAssemblyFileAtAll` refuses any `.js`, `.mjs` or `.wasm` output file and any script element in the HTML. The Site test run in the local gate passed 75 cases, skipped 1 (`SiteParityTests.TheCSharpSiteAgreesWithTheRecordedReactPrerenderWhenItIsNamed`, which is skipped by design unless a React prerender is named), and failed none. +- Local opt-in, not CI: `tests/browser/ArcForges.Web.Browser.Tests/LocalNoScriptBrowserTests.cs` reads `/`, `/hello/` and `/cloud-hello/` with JavaScript disabled and asserts a 200 status, non-empty `main` text, no script element, and no script request. It skips with the opt-in unset. Under CI conditions it skipped (the opt-in gate also refuses any CI host). +- The one local opt-in run: the Site output was served from `artifacts/site` on `127.0.0.1` (a local static server, not a deployed origin). The browser was the installed Google Chrome, `C:\Program Files\Google\Chrome\Application\chrome.exe`, product version 156.0.8078.12, named by `ARCFORGES_CHROMIUM_PATH`. No browser was downloaded. The test passed: 1 of 1, 4.5 s. Host Windows 11, Microsoft.Playwright 1.62.0 driving the installed binary by path (D10). The match between this Chrome build and the Chromium that Playwright 1.62.0 expects was not verified, so it is recorded as a mismatch risk. Claimant-reported; date 2026-10-09. +- Not claimed: the no-script reading of the deployed proof origin (blocked on CLOUD.85, not proven). + ## Server-stream framing (U4) The binary and grpc-web-text server-stream framings are recorded as fixtures in `StreamFramingTests`, and the transport decision (binary first, grpc-web-text only if the observed run fails) is recorded in `docs/prf-11-stream-transport.md`. That decision is open until the local opt-in observation (LS2) is made. No deployed stream is claimed. diff --git a/tests/ArcForges.Web.Site.Tests/SiteOutputTests.cs b/tests/ArcForges.Web.Site.Tests/SiteOutputTests.cs index d69de49..81676b4 100644 --- a/tests/ArcForges.Web.Site.Tests/SiteOutputTests.cs +++ b/tests/ArcForges.Web.Site.Tests/SiteOutputTests.cs @@ -66,6 +66,24 @@ public async Task TwoBuildsOfTheSameInputsProduceTheSameBytes() } } + [Fact] + public async Task TheOutputCarriesNoScriptOrWebAssemblyFileAtAll() + { + // PRF.11 U5: the static Site is read without JavaScript, so no output file is a script, a module or a WebAssembly module. + var site = await SiteBuilder.BuildAsync(new SiteOptions(), TestContext.Current.CancellationToken); + + Assert.DoesNotContain(site.Files, file => + file.Path.EndsWith(".js", StringComparison.OrdinalIgnoreCase) + || file.Path.EndsWith(".mjs", StringComparison.OrdinalIgnoreCase) + || file.Path.EndsWith(".wasm", StringComparison.OrdinalIgnoreCase)); + foreach (var path in HtmlPaths) + { + var file = Assert.Single(site.Files, candidate => candidate.Path == path); + var html = Encoding.UTF8.GetString(file.Content); + Assert.DoesNotContain("(); + page.Request += (_, request) => + { + if (request.ResourceType == "script") + scriptRequests.Add(request.Url); + }; + + foreach (var path in PublicPages) + { + var response = await page.GotoAsync(new Uri(baseUrl, path).ToString()); + Assert.NotNull(response); + Assert.Equal(200, response.Status); + var text = (await page.Locator("main").First.InnerTextAsync()).Trim(); + Assert.False(string.IsNullOrEmpty(text), path); + Assert.Equal(0, await page.Locator("script").CountAsync()); + } + + Assert.Empty(scriptRequests); + } +} From 2a21bc3a407fe29fe4ab589e6c10e569d8c57b57 Mon Sep 17 00:00:00 2001 From: sammiller Date: Fri, 9 Oct 2026 20:03:45 +0100 Subject: [PATCH 06/21] [PRF.11] Record the AL-06 asset budget baseline and static-asset file count The asset baseline of the Account and Chat profiles is re-baselined to the measured local publish, with the superseded React ceiling and the WEB.40 baseline recorded in docs/prf-11-budgets.md. The 10 percent regression gate is unchanged, and the interaction budgets stay re-baseline-pending (owner PRF.11). The static asset file count is 174 served files, 116 of them precompressed, against the Workers platform limit of 20,000 files per Worker version on Free. The non-virtualised chat transcript is bounded at 20 entries by construction, and no numeric sustained-memory budget was found in the Design authority, so that costing is open. Co-Authored-By: Claude Haiku 5.5 --- docs/prf-11-budgets.md | 50 +++++++++++++++++++++++++++++++++ docs/prf-11-profile-proof.md | 4 +++ eng/policy/profile-budgets.json | 24 ++++++++-------- 3 files changed, 66 insertions(+), 12 deletions(-) create mode 100644 docs/prf-11-budgets.md diff --git a/docs/prf-11-budgets.md b/docs/prf-11-budgets.md new file mode 100644 index 0000000..226eecd --- /dev/null +++ b/docs/prf-11-budgets.md @@ -0,0 +1,50 @@ +# PRF.11 AL-06 budget re-baseline, static-asset file count and WA-08 costing (U6) + +This is the reviewed AL-06 record for the Blazor profile asset budgets (`eng/policy/profile-budgets.json`). It changes baseline values only. The 10 percent regression gate (`regressionPercent` 10) is unchanged. It is written in the same commit as the baseline change, so the change is recorded and not silent (WA-08, AL-06). + +Reviewer: `w-deku-20261008-rev-prf-11` (pre-assigned, brief S25). Decision recorded at write time: `approved`, reviewedOn `2026-10-09`. That field is a proposal. Only the named reviewer's exact-head approval ratifies it. + +## Why a re-baseline is needed + +Under WA-08 and AL-06, until a reviewed re-baseline record is merged, the React-measured values stay the enforced ceiling for Blazor builds. The React profile budgets were in `apps/app/budgets.json` at the PRF.08 commit `f8123b6` in Web history. Those values are roughly 119 KB gzip for the whole Account profile. A Blazor WebAssembly runtime alone is about 3.2 MB gzip, so the React ceiling cannot be met by any Blazor build. The WEB.40 baseline, measured at its U5 step (`c5a5f2e`), replaced the React ceiling in CI. This record makes that replacement reviewed, and it is the only AL-06 change for the asset budgets in PRF.11. + +## Values + +| Metric (gzip bytes unless stated) | React ceiling (PRF.08, `f8123b6`) account / chat | WEB.40 baseline (`c5a5f2e`) | Measured at base `c8588681` (this record) | Delta against WEB.40 | +| --- | --- | --- | --- | --- | +| initialRequests | 8 / 8 | 57 | 58 | +1 | +| htmlBytes | 2967 / 3271 | 719 | 766 | +47 | +| initialCssGzip | 3050 / 3050 | 2245 | 2380 | +135 | +| initialJsGzip | 116363 / 144698 | 125944 | 126011 | +67 | +| initialWasmGzip | not in React file | 3227235 | 3236688 | +9453 | +| initialDataGzip | not in React file | 848189 | 848189 | 0 | +| initialOtherGzip | not in React file | 238 | 238 | 0 | +| totalGzip | 119413 / 147748 | 4203851 | 4213506 | +9655 | + +The measured values are the same for `account` and `chat`, because both profiles are served from one published application (the same shell and the same framework set). The new baseline is the measured value. The gate allows 10 percent growth above it. Against the WEB.40 baseline, the relative deltas are: htmlBytes +6.5 percent, initialCssGzip +6.0 percent, initialRequests +1.8 percent (one request), initialWasmGzip +0.3 percent, totalGzip +0.2 percent, initialJsGzip +0.05 percent, and none for the data and other metrics. The large relative changes are on small metrics. The cause of each delta was not isolated. The measurement is at the final WEB.40 head merged as `c8588681`, and the WEB.40 U5 baseline was taken before the later WEB.40 changes. + +## Measurement + +- Command, under CI conditions (`GITHUB_ACTIONS=true`, `CI=true`, a fresh `NUGET_PACKAGES`): `dotnet publish src/ArcForges.Web.App/ArcForges.Web.App.csproj --no-restore -c Release -o artifacts/publish/app`, then `dotnet tools/ArcForges.Web.Tooling/bin/Release/net10.0/ArcForges.Web.Tooling.dll profiles budget --publish artifacts/publish/app --budgets eng/policy/profile-budgets.json`. +- The build is the IL build. `RunAOTCompilation` is `false` (D6, D-007). The publish printed that it published without optimizations and recommended the `wasm-tools` workload, which is not installed. The AOT benchmark is therefore recorded as **not run** (no wasm-tools workload or emscripten was installed, and no user install is performed). +- SDK 10.0.401, Windows 11, the local publish. The budget tool reported `Profile budgets passed.` against the new baseline, and the Tooling test project passed 56 of 56. +- Startup time and interaction responsiveness (INP) were not measured offline. They need the deployed profile origin and are blocked on CLOUD.85 (LS3, not proven). `interactionBudgets` stays `re-baseline-pending` with owner PRF.11, and the React interaction numbers (`apps/app/interaction-budgets.json` at `80900a1`) are not carried over. They were derived from one observation and are not comparable with Blazor (D8). + +## Static-asset file count (platform limit) + +- The published `wwwroot` holds 174 files, 116 of them precompressed (`.br` and `.gz`). Precompressed variants are counted, as the gate requires. +- The profile bundle (`web-profiles-43f87a11...tar`, built and verified by `profiles bundle` and `profiles verify`) has 175 members: the manifest and 174 served files. The served set is the `wwwroot` set minus the shell and its two encodings (`index.html`, `index.html.br`, `index.html.gz`), plus the profile pages `account/index.html` and `chat/index.html` and `_headers`. The served count is therefore 174. +- The platform limit, taken from the Cloudflare Workers platform limits page (Static Assets section, read on 2026-10-09): "Files per Worker version" is 20,000 on Workers Free and 100,000 on Workers Paid, and "Individual file size" is 25 MiB on both plans. The page does not say whether precompressed variants count. The gate uses the stricter 20,000. +- Result: 174 files against 20,000 (0.87 percent), so the Blazor profiles are inside the limit whether or not the precompressed variants count. The Site adds nine files (`artifacts/site`). Even if the two outputs were served from one Worker version, the total is 183 files, 0.92 percent of the limit. The largest single file was not measured. The single-file limit is 25 MiB, and the WebAssembly payload is about 3.2 MB gzip, but the uncompressed size of the largest `.wasm` file was not checked against the 25 MiB limit, so that is not claimed as proven. + +## WA-08 costing of the non-virtualised lists + +- Chat transcript (`src/ArcForges.Web.App/Pages/Chat.razor`): an `ol` rendered with `@foreach` over `_entries`, each entry an `li` with one `span` and text. The list is capped: `MaxEntries = 20`, and the oldest entries are removed when the cap is exceeded. The list therefore holds at most 20 `li` and 20 `span` elements, 40 elements and 40 text nodes, and is bounded by construction. No virtualisation is needed (Virtualize is refused by the strict `style-src`, and CS-01 states the .NET 10 position). +- Library lists: the Web App at `c8588681` has no library list, and the Account page renders no list (only a workspace count). No library-list cost is claimed here. +- Sustained chat memory (WA-08): WA-08 says the sustained chat memory has a recorded budget. The numeric value was not found in the Design authority (`ArcForges-Design` `main`, `b01ae31`) or in the Plan and Web records. The costing is therefore by construction only (the 20-entry cap above). The numeric sustained-memory budget and any measured memory for the chat profile are open and are not claimed. They need the recorded WA-08 value, and a measurement on the deployed profile would be a local opt-in run (blocked on CLOUD.85). + +## Not claimed + +- No startup, INP or AOT number is claimed. Those are blocked on CLOUD.85 or are not run. +- No deployed file count is claimed. The count above is for the local publish and the local bundle. +- The WA-08 numeric sustained chat memory budget is not claimed. It is open pending the recorded value. diff --git a/docs/prf-11-profile-proof.md b/docs/prf-11-profile-proof.md index 4e31eba..b00650a 100644 --- a/docs/prf-11-profile-proof.md +++ b/docs/prf-11-profile-proof.md @@ -93,6 +93,10 @@ The CON.92 registry bounds are read from `WireLimits.Bytes` and pinned: unary an - The one local opt-in run: the Site output was served from `artifacts/site` on `127.0.0.1` (a local static server, not a deployed origin). The browser was the installed Google Chrome, `C:\Program Files\Google\Chrome\Application\chrome.exe`, product version 156.0.8078.12, named by `ARCFORGES_CHROMIUM_PATH`. No browser was downloaded. The test passed: 1 of 1, 4.5 s. Host Windows 11, Microsoft.Playwright 1.62.0 driving the installed binary by path (D10). The match between this Chrome build and the Chromium that Playwright 1.62.0 expects was not verified, so it is recorded as a mismatch risk. Claimant-reported; date 2026-10-09. - Not claimed: the no-script reading of the deployed proof origin (blocked on CLOUD.85, not proven). +## Asset budgets, file count and WA-08 costing (U6) + +The AL-06 re-baseline of the asset baseline (`eng/policy/profile-budgets.json`), the static-asset file count against the Workers platform limit, and the WA-08 costing of the non-virtualised chat list are recorded in `docs/prf-11-budgets.md`. The asset baseline is the measured local publish (174 served files, 116 precompressed; the platform limit is 20,000 files per Worker version on Free). The interaction budgets stay re-baseline-pending (owner PRF.11) until the deployed run (LS3, blocked on CLOUD.85). The AOT benchmark is not run (no `wasm-tools` workload installed). + ## Server-stream framing (U4) The binary and grpc-web-text server-stream framings are recorded as fixtures in `StreamFramingTests`, and the transport decision (binary first, grpc-web-text only if the observed run fails) is recorded in `docs/prf-11-stream-transport.md`. That decision is open until the local opt-in observation (LS2) is made. No deployed stream is claimed. diff --git a/eng/policy/profile-budgets.json b/eng/policy/profile-budgets.json index 36bcb6d..5d47ed8 100644 --- a/eng/policy/profile-budgets.json +++ b/eng/policy/profile-budgets.json @@ -3,24 +3,24 @@ "regressionPercent": 10, "profiles": { "account": { - "initialRequests": 57, - "htmlBytes": 719, - "initialCssGzip": 2245, - "initialJsGzip": 125944, - "initialWasmGzip": 3227235, + "initialRequests": 58, + "htmlBytes": 766, + "initialCssGzip": 2380, + "initialJsGzip": 126011, + "initialWasmGzip": 3236688, "initialDataGzip": 848189, "initialOtherGzip": 238, - "totalGzip": 4203851 + "totalGzip": 4213506 }, "chat": { - "initialRequests": 57, - "htmlBytes": 719, - "initialCssGzip": 2245, - "initialJsGzip": 125944, - "initialWasmGzip": 3227235, + "initialRequests": 58, + "htmlBytes": 766, + "initialCssGzip": 2380, + "initialJsGzip": 126011, + "initialWasmGzip": 3236688, "initialDataGzip": 848189, "initialOtherGzip": 238, - "totalGzip": 4203851 + "totalGzip": 4213506 } }, "interactionBudgets": { From db38e2cfe8f3a83f680eb5f55e0b0e8965d8b259 Mon Sep 17 00:00:00 2001 From: sammiller Date: Fri, 9 Oct 2026 20:07:32 +0100 Subject: [PATCH 07/21] [PRF.11] Live proof specs and runbook (local opt-in, not executed) The live PRF.11 specs are test-only and skip without the explicit opt-in, an https proof origin and a non-CI host; the cloud parts also skip as blocked on CLOUD.21 and CLOUD.22 until the cloud switch is set. The inline-style audit of the Razor components in use is clean. A local emulation of the served CSP logs a base-uri 'none' violation that blocks the shells' base href. That is a Ui policy decision outside the PRF.11 write scope, so the proof stops for a decision and no policy is changed here. Co-Authored-By: Claude Haiku 5.5 --- docs/prf-11-profile-proof.md | 11 +- docs/prf-11-runbook.md | 53 ++++++ .../LivePrf11OptIn.cs | 63 +++++++ .../LivePrf11OptInTests.cs | 82 +++++++++ .../LivePrf11Specs.cs | 162 ++++++++++++++++++ 5 files changed, 370 insertions(+), 1 deletion(-) create mode 100644 docs/prf-11-runbook.md create mode 100644 tests/browser/ArcForges.Web.Browser.Tests/LivePrf11OptIn.cs create mode 100644 tests/browser/ArcForges.Web.Browser.Tests/LivePrf11OptInTests.cs create mode 100644 tests/browser/ArcForges.Web.Browser.Tests/LivePrf11Specs.cs diff --git a/docs/prf-11-profile-proof.md b/docs/prf-11-profile-proof.md index b00650a..c73deb9 100644 --- a/docs/prf-11-profile-proof.md +++ b/docs/prf-11-profile-proof.md @@ -72,7 +72,7 @@ The emitted policy is the one string in `WasmContentSecurityPolicy` for the host `script-src` is exactly `'self'` and `'wasm-unsafe-eval'` (the host page has no inline script, so no hash is added). `style-src` is `'self'`. No `'unsafe-inline'`, `'unsafe-eval'` or `'unsafe-hashes'` appears in the policy or the headers file. The bundle test builds the profile bundle from a publish whose shell is the real host page, then reads the served `account/index.html` and `chat/index.html` (identical bytes, one `` each) and the `_headers` file, which names the exact policy on `/account/*` and `/chat/*`. -Open point for the in-browser check (U7 and the local opt-in run, not yet executed): `base-uri 'none'` is a CSP directive that restricts the URLs a `` element may use. Under CSP Level 3 a `` may then be blocked, so the document base would stay at `/account/` or `/chat/` and root-relative framework loads would resolve under the shell path. The offline tests cannot show this. It is not claimed as proven or as a defect. If the in-browser check confirms it, the fix is in the Ui policy, which is outside the PRF.11 write scope, so the task stops for a new decision (brief 5.13). +Open point, now confirmed as a CSP violation in a local emulation (see the U7 section): `base-uri 'none'` in the served policy blocks the `` of the shells. The browser logs `Setting the document's base URI to ... violates the following Content Security Policy directive: "base-uri 'none'". The action has been blocked.` The fix is a change to the Ui policy (`WasmContentSecurityPolicy`), which is outside the PRF.11 write scope, so the proof stops for a new decision (brief 5.13). The offline tests pin the current string and do not claim it is correct. ## Exact values and CON.92 limits (U3) @@ -93,6 +93,15 @@ The CON.92 registry bounds are read from `WireLimits.Bytes` and pinned: unary an - The one local opt-in run: the Site output was served from `artifacts/site` on `127.0.0.1` (a local static server, not a deployed origin). The browser was the installed Google Chrome, `C:\Program Files\Google\Chrome\Application\chrome.exe`, product version 156.0.8078.12, named by `ARCFORGES_CHROMIUM_PATH`. No browser was downloaded. The test passed: 1 of 1, 4.5 s. Host Windows 11, Microsoft.Playwright 1.62.0 driving the installed binary by path (D10). The match between this Chrome build and the Chromium that Playwright 1.62.0 expects was not verified, so it is recorded as a mismatch risk. Claimant-reported; date 2026-10-09. - Not claimed: the no-script reading of the deployed proof origin (blocked on CLOUD.85, not proven). +## Inline-style audit, in-browser CSP and live specs (U7) + +- Inline-style audit (static, offline): no `style=` attribute and no `