Repository source: docs/plans/app_sessions/app_sessions_step4_implementation_summary.md
Debug mode: enabled (debug log)
Effort study: railspike-effort / v3/app_sessions (ledger); standing enrollment, protocol P2
Extended estimates: enabled
Cycle estimate: 0.95
Branch: feat/app-sessions from main e6493e2f. Planning commit eafa09b5.
Step 4 entry checkpoint
2026-11-03 UTC, after the planning commit and before implementation: whole-cycle 0.95 reaffirmed (Step 3 value), scope v1/Option A, baseline
e6493e2f, R1/P2. Claude (claude-opus-5-5), single exposed rater.Stage-work originals copied from Step 3, not re-rated: S1 0.15, S2 0.10, S3 0.10, S4 0.25, S5 0.07, S6 0.15, S7 0.08. Not additive.
Baseline full suite: running on a detached
mainworktree; result recorded in Stage 1.
Enhancement requests
Step 1 workaround inventory (2026-11-03): server-side items in scope (Stages 2–4); client simplifications, Deploy Sign in button, lobby-gate log removal and operator-delegation reintegration remain separate work.
— Claude · 2026-11-03 UTC · Model: claude-opus-5-5
Stage 1 - Session store, resolver and per-scheme cookie
Original estimate: 0.15
Retrospective stage work: 0.12
Changes:
src/ForumRewrite/Session/:ViewerSessionStore(token hashes only; 400-day lifetime with no idle expiry; same-identity sign-in keeps the token; a replaced token forwards 30 s, matching the decision insession_rotation_race_decision.md; sign-out keeps the Lobby identity; single-use sign-in challenges with a 10,000 open-challenge cap; activity written at most hourly),ViewerSession,ViewerSessionCookie(__Host-v3_sessionover https,v3_sessionover http; legacyPHPSESSIDexpiry),ViewerSessionDatabaseConfig(state/private/viewer_sessions.sqlite3, WAL, 5 s busy timeout).PrivateConfigSchema:VIEWER_SESSION_DATABASE_PATH(path, not in the template, like the other store paths).tests/ViewerSessionStoreTest.php, registered intests/run.php. Not yet wired into the app.
Verification:
php tests/run.php ViewerSessionStoreTest: 14/14 passed, including idle 30 days, copied cookie after sign-out, forwarding window, scheme isolation and 12 concurrent processes on one file (no lock errors).php tests/run.php PrivateConfigCommandTest PrivateConfigSchemaTest: 13/13 passed.Export/backup risk: no archive, backup, checkout or deploy code reads
state/private/wholesale (searchedsrc/,scripts/,v3);state/is gitignored. Passed.php -l,git diff --check: passed.
Notes:
Challenges are no longer tied to a browser. A captured challenge and signature can be used once by whoever submits first; over http that attacker could equally take the cookie, so no new exposure. Recorded for the concept entry.
An unknown or expired token resolves to signed out without clearing the cookie, so a stale response can never delete a newer cookie (the original race).
Estimate details: original scope unchanged; retrospective 0.12 (store simpler than expected; concurrency check worked first time).
— Claude · 2026-11-03 UTC · Model: claude-opus-5-5
Stage 2 - Shared signed-in test helper and LocalAppSmokeTest
Original estimate: 0.10
Retrospective stage work: 0.10
Changes:
tests/Support/TestViewerSession.php:signIn(),lobbyOnly(),signedOut(),challenge(),identityId(),nextRequest(),reset(). Tests now say who is signed in; the helper decides how. For now it drives PHP sessions as the tests did.tests/LocalAppSmokeTest.php: all session setup and teardown moved to the helper (24 insertions, 120 deletions). The two tests that check the PHP mechanism itself (session_status(), PHP cookie parameters) are left for Stage 4, where the mechanism changes.
Verification:
php tests/run.php LocalAppSmokeTeston the branch and onmain(e6493e2fworktree): both 162 run, 159 passed, 3 failed, with the same three failures (testPrivateLobbyOnlyExposesLobbyAccountAndAuthenticationSurfaces,testPrivateForteRoutesRecoverExpiredSessionsInsteadOfReturningFalseNotFound,testRepositoryArchiveDownloadFilenamesIncludeReadableTimestamp). Behavior-neutral. Passed.php -l: passed.
Notes:
Finding: the runner writes to STDOUT, so
headers_sent()stays false and in-process tests do start real PHP sessions. Once the application has started one,use_strict_modestays on for the whole process, and a self-chosen session ID is silently replaced. The old tests passed only because they never sent a cookie and the application reused the replacement ID PHP had generated, which is another way pre-filled sessions masked real behavior. The helper lets PHP generate the ID.Baseline full suite still running at commit time; recorded in Stage 3.
Estimate details: original scope unchanged; retrospective 0.10 (the strict-mode discovery cost what the mechanical replacement saved).
— Claude · 2026-11-03 UTC · Model: claude-opus-5-5
Stage 3 - Remaining tests use the helper
Original estimate: 0.10
Retrospective stage work: 0.10
Changes:
Converted 10 PHP test files and one browser test:
DeployPageTest,VisitorStatisticsPageTest,ApprovalSeedCommandTest(child-process fixtures with real cookies);BackupPageTest,FeatureFlagChangePageTest,SourceRouteCommitMapTest,RootApprovalPageTest,UserAccessTest,WriteApiSmokeTest,PrivateMessageFeatureFlagTest(in-process fixtures);tests/browser/testing_self_approval.mjs(now gets its weak Lobby-only cookie from the newtests/Support/test_viewer_session_cookie.php).Helper additions:
holdOpen(),useStateDirectory(),cookie(). The child-process sign-in now empties$_SESSIONafter writing, so the application must load the session through the cookie.No test outside
SessionRotationTest(deleted in Stage 4) touches$_SESSIONorsession_*directly (grep).
Verification:
Baseline full suite on
maine6493e2f(detached worktree): 1825 run, 1810 passed, 15 failed. Failures: 8FrmClientTest, 3LocalAppSmokeTest(as in Stage 2),ApprovalSeedCommandTest::testUnreadableArtifactDeletionCannotReportSuccess,ReactionRecordFormatParityTest::testMixedFormatsBuildTheSameReadModelAsLegacy,ReadModelBuilderTimingTest::testProgressSeparatesParsingDerivationAndWritingWithBoundedCounts,TerminalOperatorUiCommandTest::testNonInteractiveInputPrintsCliFallbackWithoutWork.The 10 converted classes on the branch: 235 run, 234 passed; per-class pass counts identical to the baseline; the one failure is the baseline
ApprovalSeedCommandTestfailure. Passed.node tests/browser/testing_self_approval.mjs(headless Chrome via the declaredplaywright-core1.58.1, installed with--no-save --no-package-lock; no manifest change): passed.
Notes:
holdOpen()exists because the in-process fixtures opened one PHP session up front and changed it in memory, so later requests saw the change regardless of route. Switching them to cookie-loaded sessions now would expose routes outside the session allowlist (the Deploy class of bug) before Stage 4 can fix them. It is a named, temporary shim; Stage 4 removes it.
Estimate details: original scope unchanged; retrospective 0.10.
— Claude · 2026-11-03 UTC · Model: claude-opus-5-5
Stage 4 - Cutover to app-owned sessions
Original estimate: 0.25
Retrospective stage work: 0.25
Changes:
ViewerSessions(new, request-scoped): the one answer to "who is signed in" and the one place that signs in or out.Application::handle()creates it fresh for every request from that request's cookies;RouteServices::viewerSessions()exposes it to controllers.Readers switched:
Application(viewer, lobby, Access historycanSignIn, lobby-gate diagnostics),RouteServices::securityReviewAllowed,DeployController,UserAccessController.AuthApiController: challenges from the store; a challenge is used up only by a verified signature, so a failed attempt can retry; sign-in issues or reuses the session and sets the cookie. Response text unchanged.IdentityHintController::clearIdentity: sign-out ends the session on the server and keeps the Lobby identity.A request still carrying
PHPSESSIDgets it expired.Removed:
SessionRotation, the session-start route allowlist,shouldResumeViewerSession/resumeViewerSession/hasViewerSessionCookie, every PHP session call;SessionRotationTest,session_rotation_http.mjs. Static/docs checks no longer look at$_SESSION(cookie presence alone, as before).Test helper now creates real store sessions and cookies (temporary store via
VIEWER_SESSION_DATABASE_PATH, inherited by child processes);holdOpen()removed. TwoLocalAppSmokeTestmechanism tests rewritten (no session for keyless readers; 400-day session lifetime); two new tests (idle three hours, stalePHPSESSID, http cookie refused over https, copied cookie dead after sign-out; challenge needs no session and survives a failed attempt).docs/reference/agent_api.md: the session cookie now arrives at sign-in, not at the challenge. No shipped client (frm.py, scripts) relied on the old timing.
Verification:
grepfor$_SESSION,session_*()andSessionRotationinsrc/andtemplates/: none (comments updated). Passed.Full suite: 1836 run, 1818 passed, 18 failed. All 15 baseline failures reproduced. Three new:
WriteApiSmokeTest::testOperatorSecurityReview…andRootApprovalPageTest::testPrivateSeedPage…: conversion mistakes in the tests (setting$_COOKIEafter sign-in discarded the session cookie). Fixed by ordering; both classes rerun in full: 200/200 passed.CheckoutsCommandTest::testRealGitOperatorAcceptance: no session or cookie involvement; passes alone (59 s). Load-induced timing under the full run. Not attributed.
DeployPageTestsigned-in POST in a fresh single-request process (the 2026-11-02 failure): passed.Browser (headless Chrome):
testing_self_approval.mjsandbackground_sign_in.mjspassed;access_history.mjsfails identically onmain(pre-existing "Temporary authentication failure" wait).
Notes:
Plan deviation: the split trigger fired (about 200 added source lines, 128 in the new
ViewerSessions). Not split, because readers cannot move to the store before sign-in writes to it, so either half alone would leave sign-in broken between commits. Same outcome as planned.public/assets/private_site_auth.jsstill says "PHP sessions hold one challenge"; its queueing is harmless. Left for the deferred client-simplification follow-up rather than changing a fingerprinted asset here.
Estimate details: original scope unchanged; retrospective 0.25.
— Claude · 2026-11-03 UTC · Model: claude-opus-5-5
Stage 5 - Testing Approve cookie per scheme
Original estimate: 0.07
Retrospective stage work: 0.05
Changes:
RouteServices::testingSelfApprovalCookieName():testing_self_approvalover http,__Host-testing_self_approval(Secure) over https.testingSelfApprovalToken()and the/api/test_self_approvalcheck read and set the scheme's own name. The browser script is unchanged (the cookie is HttpOnly and only checked on the server).TestingSelfApprovalTest::testApprovalCookieNameDiffersPerScheme.
Verification:
php tests/run.php TestingSelfApprovalTest: 2/2 passed.node tests/browser/testing_self_approval.mjs(headless Chrome, http): passed.
Notes: over https the double-submit token is now a
__Host-cookie, which also stops subdomains from planting it.Estimate details: original scope unchanged; retrospective 0.05.
— Claude · 2026-11-03 UTC · Model: claude-opus-5-5
Stage 6 - Replay of the reported failures over http and https
Original estimate: 0.15
Retrospective stage work: 0.15
Changes:
tests/browser/app_sessions_http_https.mjs: headless Chrome against onephp -Sserved over http and, through a local TLS proxy (Node's built-inhttps, self-signed certificate fromopenssl), over https on the same host and different ports, so the browser shares cookies between them like the iPhone did. Uses the made-up hostapp.testmapped to 127.0.0.1, because Chrome treatslocalhost/127.0.0.1as secure and would send Secure cookies over http, hiding the bug; Chrome runs with--no-proxy-serverbecause this environment's outbound proxy settings would otherwise capturehttps://app.test.tests/Support/app_sessions_browser_router.php: marks proxied requests as https, then defers to the existing invite router. PHP's own file-session cleanup is set to 2 seconds, so "idle past cleanup" takes seconds; app-owned sessions ignore it.Seven checks: https sign-in; http sign-in after an https visit; still signed in on reload; a browser holding a Secure
PHPSESSIDsigns in over http; an https visit retires thatPHPSESSID; operator Deploy POST after idle cleanup (over https, where every version signs in, so a refusal can only mean a lost session); a copied cookie is dead after sign-out.
Verification:
On
maine6493e2f(files copied into the detached worktree): 1 of 7 passed. http sign-in after https and the old-PHPSESSIDbrowser failed with the server's reply "error=Authentication challenge is missing or expired." (the iPhone error, twice: first try and the client's retry); Deploy after idle cleanup refused with 403 "Deploying requires a signed-in root-approved operator."; the remaining failures follow from these or are new behavior. The replay reproduces the reported failures. Passed.On the branch: 7 of 7 passed, twice. Passed.
Notes:
Chromium evidence only. The real iPhone Safari check over http after an https visit remains the acceptance item for the maintainer (Stage 7 records the steps).
Harness lessons kept in the test's header comment: loopback hosts hide Secure-cookie shadowing; Playwright's
context.requestresolves names in Node, so checks fetch from inside a page.
Estimate details: original scope unchanged; retrospective 0.15 (proxy and loopback issues cost what was budgeted as uncertainty).
— Claude · 2026-11-03 UTC · Model: claude-opus-5-5
Stage 7 - Documentation, concept entry and closeout
Original estimate: 0.08
Retrospective stage work: 0.08
Changes:
docs/reference/authentication_modes.md: session section rewritten for app-owned sessions (lifetime, per-scheme cookies, challenges, sign-out, switching from PHP sessions); evidence links point to the new sources and tests.Superseded notes on
docs/plans/session_reauthentication_reference.mdanddocs/plans/session_rotation_race_decision.md(the 30-second forwarding decision carries over inside the store).New concept entry
docs/concepts/viewer_session.md.Study ledger closeout for
v3/app_sessions.
Verification:
Concept entry and reference links: every local link target resolves. Passed.
Final full suite: 1839 run, 1824 passed, 15 failed, exactly the 15 baseline failures; no new failures, none fixed. Passed.
Dependency delta: zero. No manifest or lock changes;
playwright-core1.58.1 was already declared and was installed locally with--no-save --no-package-lockonly to run browser tests. Passed.
Estimate details: original scope unchanged; retrospective 0.08.
— Claude · 2026-11-03 UTC · Model: claude-opus-5-5
Closeout
Retrospective cycle scope: 0.95
Delivered: Option A as approved. Every request resolves "who is signed in" from its scheme's cookie through one store; no PHP session code, route list or rotation workaround remains; sign-out ends sessions on the server; the reported failures replay and pass.
Commits: planning
eafa09b5; stages435d1a61,414a2bd6,8959db42,9c1b9ad0,78ed137d,eebd9fd3, and this Stage 7 commit. 1 + 7 commits, as required.Checks:
Passed: store unit tests (14), cutover tests, full suite against baseline, signed-in Deploy POST in a fresh process, http/https replay in Chrome (7/7; fails 6/7 on
mainwith the reported errors), testing Approve and background sign-in browser tests.Failed on
mainand branch alike (pre-existing):access_history.mjs("Temporary authentication failure" wait); the 15 baseline suite failures.Pending: real iPhone Safari check (steps below); production deployment; maintainer comfortable-fit judgment.
Not checked: other browser tests not touched by this cycle; Apache (
tests/*_apache_test.py) runs; production PHP/FastCGI.
Deviations: Stage 4 not split despite its trigger (a split would leave sign-in broken between commits); Stage 3 used a temporary
holdOpen()test shim, removed in Stage 4. No new requests; no splits or follow-up cycles created.Follow-ups (separate work, recorded in Step 1): Deploy page Sign in button; remove the lobby-gate diagnostic log; simplify the client challenge queue and its "PHP sessions hold one challenge" comment; bring
feature/operator-privilege-delegationup to date (its session-resume commit9f3f9563is now unnecessary) and moved7f14336's revoked-key check intoViewerSessionsif it merges.Release note: deploying this signs every browser out once. Browsers with a saved key sign back in on their next page load; an operator's first Deploy or other POST after the switch may need one page load first.
iPhone Safari acceptance (maintainer)
On the iPhone that failed, open the site over https and confirm Account shows you signed in.
Open the same site over http (same host name). Account should show you signed in within a few seconds, without "Authentication challenge is missing or expired".
Reload over http; still signed in. Then reopen over https; still signed in.
Optional, an operator account: after an idle evening, use Tools › Deploy › Check for updates over either scheme without signing in again.
Record the result here (passed, or what you saw); until then this item stays pending.
Study closeout
Entry 0.95 reaffirmed; stage originals 0.15/0.10/0.10/0.25/0.07/0.15/0.08; stage retrospectives 0.12/0.10/0.10/0.25/0.05/0.15/0.08; not summed. Retrospective cycle scope 0.95, judged separately by the same exposed rater (Claude, claude-opus-5-5). R1 hashes re-verified at closeout. No measured labor. Maintainer comfortable-fit judgment and acceptance pending; no accepted-outcome increment.
— Claude · 2026-11-03 UTC · Model: claude-opus-5-5
Merge with main for review - 2026-11-03
User authorization: "yes please <3" (push the branch and open a pull request). main had moved 22 commits to fd4d0e02, including key retirement. Merged main into the branch (no rebase; stage commits unchanged).
Conflicts resolved:
AuthApiController: kept both. The challenge is used up first, thenwithActiveIdentity()(main's publication lock and retired-key check) wraps the app-ownedsignIn(), so a retired key still gets main's 403 and no session.RouteServices::securityReviewAllowed: app-owned session reader plus main'sisActiveOperator.Study ledger: both cycles' appended records kept.
Adapted main's two new retirement tests in
WriteApiSmokeTest, which pre-filled$_SESSION: they now sign in through the helper. The retired-key sign-in check asserts that no session row was added (newTestViewerSession::sessionCount()), and an existing session from before retirement is shown to stop working.No
$_SESSIONor PHP session call came back in frommain(grepsrc/andtests/).Verification on the merged tree:
Focused:
ViewerSessionStoreTest TestingSelfApprovalTest RootApprovalPageTest UserAccessTest DeployPageTest ApprovedUserKeyResolverTest PrivateMessageHistorySyncTest63/63 passed;WriteApiSmokeTest LocalAppSmokeTest363 run, 360 passed, the 3 long-standingLocalAppSmokeTestfailures.Browser (headless Chrome):
key_retirement.mjs(main's new journey),app_sessions_http_https.mjs7/7,background_sign_in.mjspassed.Full suite on the merged tree (after push): 1847 run, 1832 passed, 15 failed; exactly the 15 failures of the original
mainbaseline, none new.
Study: merge integration only; scores, retrospectives and acceptance count unchanged.
— Claude · 2026-11-03 UTC · Model: claude-opus-5-5