Recording the full mcp-lifecycle fixture against real isolated PHP
(08-09-PLAN.md Task 2) uncovered three byte-level gaps between wristband's
assumed contract and actual production PHP behavior:
- Every explicit "Cache-Control: no-store" PHP sets is actually delivered
as "no-store, private" (Laravel's session-cookie default merges "private"
onto any explicit value); wristband's own default for unheadered JSON
error responses is "no-cache, private" (matching the house convention
already used elsewhere), not empty.
- PHP's redirect responses (authorize success and every error redirect)
render Symfony's default HTML redirect body with Content-Type
"text/html; charset=utf-8"; Go's bare 302 with no body never matched.
wristband/redirect_html.go ports that exact byte template, including
PHP's htmlspecialchars(ENT_QUOTES) escaping (Go's html.EscapeString uses
different quote entities).
tide/normalize.go: isIDKey now also masks "_ids" plural array fields
(e.g. collection_ids), a latent parity-corpus gap no prior fixture had
exercised with a literal, non-empty, non-placeholder array value.
rotateRefreshToken ports OAuthCodeManager::rotateRefresh: a fresh refresh
token rotates atomically (revoke old access token, mint successor, link
rotated_to_id) while a replayed (already-rotated) token instead revokes the
whole lineage and commits that kill before Token maps it to invalid_grant
outside the transaction (T-08-REFRESH-REPLAY). Token also runs the D-17
expiry sweep (DeleteExpiredCodes/DeleteExpiredRefreshTokens) before grant
processing. Server.Revoke is the new cascade-revoke seam a connected-app
controller uses instead of touching refresh rows directly.
TestPhase8RedLifecycleFramework drives exchange -> rotate -> replay against
the real (in-memory-backed) Server.Token and fails while rotateRefreshToken
is 08-04's invalid_grant placeholder (PHASE8_RED:lifecycle-framework,
verified fail-closed via scripts/check-phase8-red.sh). Extends the
RefreshTokenStore/AuthCodeStore interfaces with the store seams Task 2's
implementation needs (ByAPITokenIDForUpdate, MarkRotated,
DeleteExpiredCodes, DeleteExpiredRefreshTokens) and updates the framework's
in-memory test double to satisfy them.
- Server.Token: JSON rejection before ParseForm, body-over-query precedence,
Basic-over-form client auth, exact invalid_request/unsupported_grant_type/
invalid_client/invalid_grant bodies, Cache-Control/Pragma on success only
- authenticateClient: public/confidential dispatch, constant-time secret
compare (T-08-SECRET-TIMING)
- exchangeAuthorizationCode: single WithinTx lock/consume/mint/refresh-create
covering code/client/redirect/resource/PKCE binding and single-use replay
(T-08-CODE-REPLAY), sequential and concurrent proofs
- rotateRefreshToken: grant_type=refresh_token dispatches per PHP validity
but is a deliberate invalid_grant placeholder; full rotation is 08-06
- full token_test.go behavior matrix appended alongside the RED anchor