A request's state is persisted after its answer is streamed; one of the
instance that served the last request may finish after the expiry and
close that copy itself (the record is gone). The reaper is required to
close the copy of the instance that did not serve it.
A reaper pass that cannot read the store keeps the copy by design, so the
Redis case reaps until the copy is gone and still requires exactly one
close per instance; both copies are asserted before the expiry.
- check-phase19.7.sh: workspace_check (framework to this checkout, the OAuth
server plugin's user plugin to USER_PLUGIN) in the plugins, consumers and
docker stages; consumer_with_user_plugin builds fonoteka.go and sm-bm-app
against USER_PLUGIN through a scratch go.work; demo_pins accepts
unreleased commits its canonical checkout holds and refuses any drift
with PHASE197_RELEASED=1; self-test cases for each, one PASS line each
- TestSessionReaper pins A-CR-01, A-IN-01 and A-IN-06 (memory and Redis
stores, store errors, the GET stream re-check, the ephemeral label)
- TestAuthTime pins auth_time at mint and through every refresh path
- GO_TESTS and GO_TAGGED_TESTS require them with the plan 06/07 smokes
- pact.MCPSubjectChanged and pier.Event gain TokenID and ClientID; a
narrowed revoked event closes only matching sessions and stateless servers
- cabana fires the admin token revoke with the token row id
- towel.WithClientIP/ClientIP; pier puts the address (surf applies
http.trusted_proxies) on the resolve context and ResolveBackendToken logs it
- pact.MCPPrompt.Writes (additive; the zero value keeps today's listing)
- pier's allowsPrompt hides a prompt declared with Writes from an identity
without write, as it hides a tool that is not read-only (C-IN-01
framework part, P-28)
- a path field must be required in the reflected schema (boot error), and
a missing value, an empty, . or .. segment, or a / inside one segment is
a tool error before dispatch (A-WR-01, A-WR-02)
- a failed guard dispatch logs its cause and answers a fixed tool text;
only an invalid token and an unknown or deactivated account keep their
own fixed texts (A-WR-08)
- path and query scalars decode with UseNumber, so integers above 2^53
keep their digits (A-IN-04)
- the dispatched request carries the outer Host, X-Forwarded-Proto and
X-Forwarded-Host (A-IN-05)
- an embedded struct on a GET or DELETE tool fails boot (A-IN-07)
- a dispatch whose guard list or principal kind contradicts the tool's
guard is refused (A-IN-08)
- Identity.fingerprint covers scopes, both subjects, the ceiling and the
token kind; a stateful lookup or stateless hit whose fingerprint changed
re-filters the server with the event diff under its own mutex (A-WR-04)
- Mount warms a Redis store in the background with a 1 s to 30 s backoff
and every request kicks a pending open without waiting, so stateless-only
instances subscribe to fan-out (A-WR-05)
- a stateless subscriptions/listen POST re-verifies its bearer every
recheck interval; an LRU-evicted stateless server closes its sessions
(A-WR-06)
- resolvePersonalToken refuses a subject with TokenID 0, and the
pact.MCPPersonalTokenResolver contract says one resolver per application,
false means unauthorized and TokenID keys the binding (A-WR-09, A-IN-03)
- an unexported reserver (Reserve, Release) next to updater: the memory
store reserves under its mutex, the Redis store in one Lua script
(prune, SCARD, SET PX plus SADD); serveNew reserves instead of Count
then Put and releases on a failed connect or initialize (A-WR-03)
- serveNew, lookup's rehydration and the stateless getServer build the
SDK server with no router-wide lock held and insert or adopt under the
lock afterwards, so a slow guard or database check no longer stalls
the instance (A-WR-07)
- a reaper started at Mount checks every local session against the store
every min(idle_ttl/2, 1m) and closes each copy whose record is gone,
logging the expired step (A-CR-01, A-IN-06)
- the GET stream re-check ends the stream when the record is gone (A-IN-01)
- tool calls served without a session on the stateful path log the
session as ephemeral (A-IN-06)
- TestPierReaperSmoke and the ReapNow, LocalSessions, StopReaper seams
- TestEndpointGuards: a foreign browser Origin is 403 before the bearer is
read, a body over the endpoint cap is 413, a long GET stream ends once
its grant stops verifying (T-19.7-18, T-19.7-19)
- TestDispatch: an argument key the input struct does not declare is
refused before dispatch (T-19.7-17)
- the gate requires TestEndpointGuards (untagged and tagged) and runs the
demo's deploy check (T-19.7-37)
- pier: TestCatalog, TestDispatch, TestGuardRules, TestSessions (Redis
across two instances and the memory store), TestFanOut; the store clock
and stateless-server seams in export_test.go
- surf: TestStreamingRoute (throttle, CORS profile and access-log pattern on
a stream route) and TestPierMount in both builds; the streaming smoke
restores the mount hook it replaces
- wristband TestMCPScopes; cabana TestAccountContributions (Postgres part:
transaction and unchanged My account payloads), TestRecordActionPrompt,
TestScopeLimitForMethods; TestRecordActionPromptRun pins the empty Value
of an action without a prompt
- vitest: 403 and failed-load cases for My account contributions, the
cancelled prompt
- gate: every name required (tagged runs included), account.go unchanged
since 51d088a, admin stage live
- persisting a session's protocol state after a request now rewrites an
existing record only (Redis SET XX, the memory store likewise) and closes
the local copy when the record is gone
- before, a request that finished after DELETE, a revoked or deleted subject
event or the idle expiry put the record back, and the next request
rehydrated the ended session
- found by plan 19.7-05's TestFanOut (revocation and deletion cases, about
one run in a hundred)
- MCP's tools/call arguments are optional; pier validated a JSON null
against the input schema and refused every call of a tool without
required input that an agent sent without arguments
- found by plan 19.7-05's TestDispatch
- scripts/check-phase19.7.sh: fail-closed multi-repo gate (self-test, go,
marker; plugins, consumers, docker, admin and docs are stubs until the
later tasks fill them)
- modules/pier/helpers_test.go: the plan 02 fixtures moved out of the smokes
plus fakeGuard, fakeVerifier, fakeLinkResolver, fakePersonalResolver,
routePlugin, newApp, twoInstances, sdkClient and fireSubjectChanged
- TestToolsList (pier), TestDispatchMarker (bouncer, cabana with a Postgres
part through the screens harness, which now keeps its app)
- personal tokens only behind mcp.allow_personal_tokens (401 without a
lookup otherwise); an admin token yields a backend-only identity with
its scope limit per dispatched method, a user token a frontend-only one
- read/write ceiling and prompts proven by the tracer's subtests; the
audit line asserted to carry the tool and never the arguments or token
- the mounted *pier.Pier is published on the application (test seams in
export_test.go)
- docs/services/mcp.md with the tested acme.blog example, site.yaml note,
cross-links from authentication.md, registration.md and the surf README
- store_redis.go: records with PX TTLs, per-binding sets, pub/sub events,
opened lazily from redis.url under cooler's prefix (nothing dials at mount)
- events.go: pact.MCPSubjectChanged republished through the store; every
instance recomputes the subject's sessions and stateless servers
(list_changed) or ends them on revocation; the link cache is dropped
- stateless.go: the SDK's stateless handler over per-identity servers for
2026-07-28 clients, routed by the protocol header or server/discover
- prm.go: RFC 9728 metadata at both well-known paths; long GET streams
re-verify their bearer every minute
- TestPierSessionsSmoke, TestPierFanOutSmoke, TestPierStatelessSmoke on
miniredis; README features, configuration and operational notes
- modules/pier: Host contract, mcp.* config, catalog compiled from
pact.HasMCP (prefixing, route binding, guard resolution, schema
reflection, annotations), per-request identity through the published
verifier and resolvers, built-in backend guard provider, in-process
dispatch with the bouncer marker, per-identity SDK server with the
per-request re-filter, stateful session router with rehydration over a
memory store, audit lines
- modules/surf/pier_on.go: the summercms_mcp hook adapting the router to
pier.Host; the untagged hello binary carries no SDK
- go.mod: go-sdk v1.8.0 and jsonschema-go v0.4.3 (decision note committed
before this change)
- TestPierTracerSmoke: SDK client lists and calls acme_echo end to end
through surf's compiled guard chain; README and root table row