Files
qwen-code/docs/design/2026-10-02-managed-agent-query-amplification.md
T
Shaojin Wenandwenshao 98b0255f9d fix(managed-agent): stop database amplification on session hot paths (#13217)
* fix(managed-agent): stop database amplification on session hot paths

Four hot paths in the Managed Agent Runtime Broker amplified database
work far beyond request volume, two of them while holding row locks:

- materializeNextBatch rewrote the whole items_json snapshot after every
  <=200-event batch under the session row lock. The snapshot is now
  rewritten at creation, on a terminal event, every
  SNAPSHOT_REFRESH_EVENTS (1000) covered events, or on catch-up once the
  previous snapshot is SNAPSHOT_REFRESH_MILLIS (5s) old; a session whose
  snapshot lags its drained progress is re-selected by
  findMaterializationTargets once the snapshot ages out, so an idle
  session still converges instead of staying stale forever.
- SSE fan-out ran a read-grant SQL check before nearly every event per
  subscriber. Streams now recheck the grant at most once per
  events.read-grant-recheck-interval (default 5s; PT0S restores
  per-event checks); session.deleted still terminates immediately.
- listPublicSessions/listWebShellSessions ran 2-4 extra queries per row.
  Pages are now assembled from a constant number of grouped batch
  queries that project only the turn summary columns, with the approval
  mode riding the page's own SELECT *, plus one workspace-close batch
  read when the page holds bound sessions; single-session views share
  the same assembly, and the singular store reads delegate to the batch
  twins so each selection rule has one spelling.
- Publication authorization rescanned the session journal back to the
  last activation.changed per revision under the publication lock on
  every publish/seal/prefix/finish/verifyDispatch. Migration V34 carries
  the current activation on the journal head, maintained by commit()
  inside the parse pass every commit already runs; authorization reads
  the head row it already locks, reserve/renew read the tool.intent at
  its own revision, and pre-migration heads get one legacy scan plus a
  backfill. Because a rolling fleet can briefly run a binary that does
  not maintain the columns, trusting them is gated behind
  tool-publication.journal-head-authorization (default off) until the
  fleet drains.

Related: artifact downloads re-verified content access per 64 KiB chunk;
the re-verification is now throttled to
artifacts.read-revalidation-interval (default 5s), which also defers the
stage-1 DELETING observation to the window's end (stage-2 retirement
still aborts per chunk through the read lease). Migration V35 adds an
event-type index so latest-turn lookups read index ranges instead of a
session's event history.

Query budgets per endpoint are pinned by Issue13181QueryBudgetTest (with
the artifact revalidation window pinned in
ManagedArtifactReadIntegrationTest), the windowed/frozen-clock semantics
are pinned by ManagedEventStreamServiceTest, and the fencing behavior
across the head columns, the legacy scan, and the rolling-deploy gate is
pinned by ToolPublicationStoreTest.

Fixes #13181

* fix(managed-agent): pin the head-path proof in the fencing tests

Round-3 adversarial follow-ups (no Criticals):

- expiredActivationFencesThroughTheHead now proves the fences came from
  the head columns: renew reads exactly the intent's revision (3
  statements), and verifyDispatch performs zero locked journal reads.
- renewReadsTheIntentAtItsOwnRevision additionally pins zero locked
  journal reads, so a lock-mode upgrade on the intent read cannot slip
  past the statement-count pin.
- Drop the unreachable null-check after queryForObject in
  requireLegacyActivation (the query throws on a missing row, and
  record_bytes is NOT NULL).

* fix(managed-agent): pin the per-row retention flags on bound pages

Round-4 adversarial follow-up (no Criticals): the 20-row bound-page loops
asserted titles and actions but never the sessionArchive/Unarchive/Delete
flags, so a regression computing retention once per page would have passed.
One bound session now carries a completed CLOSE row and both surfaces
assert sessionDelete per row.

* fix(managed-agent): keep the text-order snapshot test deterministic

CI (Runtime Broker and Managed Agent MariaDB) caught
preservesTextOrderAcrossToolsAndReasoningInSnapshots racing the 10ms
materializer: a scheduler tick can create the snapshot mid-sequence, and a
non-terminal catch-up drain inside the 5s floor then legally leaves it
behind. The trailing event is now terminal so the explicit drain always
rewrites; the content-ordering assertion is unchanged.

* fix(managed-agent): keep singleAppendsContinueTheTextPartBeforeThem deterministic

Same scheduler race as the text-order test fixed in b0d357ae9c, caught by
the MariaDB CI job on the following run: a 10ms-tick snapshot created
mid-sequence plus a non-terminal catch-up inside the 5s floor legally lags.
The trailing reasoning delta is now terminal so the explicit drain always
rewrites. The contentPartId assertions are unchanged.

* fix(managed-agent): close out the second review round on #13181

Two correctness findings and the accepted suggestions from the second
review round of the query-amplification fix:

- ManagedExtensionRecords.millisLenient rejects a JSON number or
  numeric string wider than a long before materializing the BigInteger,
  so an exponent-form expiresAt no longer costs a giant allocation.
- The journal head's activation columns carry activation_head_revision,
  the journal revision they reflect; every commit re-stamps it, both
  authorization gates require stamp equality, and the backfill
  re-stamps, so a rolling fleet still running pre-V34 writers detects
  the skew and rescans instead of certifying stale columns.
- requireEvidence answers a fenced activation from the locked head
  before touching the journal: a fenced renew now pays zero journal
  statements, pinned in the budget test.
- Deferred snapshot rewrites are recorded with a snapshot_stale_since
  marker (V36) that findMaterializationTargets re-selects once aged, so
  a drained trickle converges instead of idling stale forever.
- transcript tails past the snapshot are bounded by the caller's limit,
  keeping the newest events and reporting the truncation for paging.
- The latest-environment-event batch reads one row per session via a
  MAX(sequence_id) derived table over the (session, turn) pairs.
- Workspace close state has a single spelling (the singular predicate
  delegates to the new batch query), and the approval mode rides the
  page's SELECT * onto SessionRecord instead of a per-row probe.
- The publication journal fixture is now shared between
  ToolPublicationStoreTest and Issue13181QueryBudgetTest as
  PublicationJournalFixture, ending the drift between the two copies.

* fix(managed-agent): close the audit round on the R2 fixes

Undirected plus adversarial audits of the R2 commit found one Critical
and three Minor issues:

- A commit carrying no activation change re-stamped the carried-forward
  head columns with the new journal revision, laundering a skew left by
  a pre-V34 writer into a fresh-looking head. The stamp now advances
  only when the preserved columns were current at the previous
  revision, keeping the skew detectable until the rescan heals it;
  pinned by noChangeCommitDoesNotRestampASkewedHead, and the
  always-advance mutant goes red.
- The millisLenient width pre-check overflowed int on extreme
  exponent-form strings; the comparison now widens to long, pinned by
  the 1e2147483647 arm.
- sameEpochReleasePreventsReserveAndRenew is parameterized over the
  journal-head-authorization flag, pinning the release fence on the
  shipped legacy scan and the head fast path alike; the suite default
  stays false, matching production.
- The application.yml mirror test also asserts the flattened keys
  exist, so a renamed or dropped key cannot false-pass on the Java
  defaults.
- The design doc (both languages) records that a transcript backward
  walk can repeat the first page's inlined control events at the page
  seam and that clients deduplicate on the event sequence.

* fix(managed-agent): close the second audit round on the R2 fixes

The round-2 undirected and adversarial audits both found the same Major:
the millisLenient pre-check guarded only the negative-scale direction,
so a 12-byte "1e-100000000" expiresAt still forced toBigIntegerExact to
expand 10^100000000 before dividing (measured ~56s of CPU). The scale
is now bounded too (at most 19 decimal places, which a representable
long never needs), and a test arm pins the input class.

Two test-coverage Minors from the same round:

- transcriptTailIsBoundedByTheCallerLimit now pins the load-bearing
  "newest kept" property with exact sequences (the tail ends at the
  session's last event; olderCursor names the oldest returned one) —
  the oldest-kept mutant goes red.
- deferredSnapshotConvergesOnTheAgedOutReselection now ages only the
  deferral marker for the selection arm and pins that the tick's scan
  references snapshot_stale_since without touching
  managed_agent_snapshot, so a regression to per-row snapshot probing
  fails twice over.

* test(managed-agent): correct the publish positive-control comment's lock attribution

* test(managed-agent): pin the creator-submit capability's batched page reads

* docs(managed-agent): state the conditional creator/grant batches in the page budgets

* test(managed-agent): exercise the can_create grant term across two workspaces

* fix(managed-agent): close out the third review round on #13181

Four Criticals and the accepted Suggestions from the third review:

- Restore the uncapped cursor-less transcript tail (merge-base
  behaviour): capping it at the caller's limit falsified the published
  webShellTranscript contract and broke the prefix invariant the stream
  resume relies on (a lagging snapshot's middle band was served by
  neither page nor stream), and the new backward walk re-served
  already-projected delta rows past the seam. The contract sentence is
  now pinned in ManagedAgentApiContractTest, and the budget suite pins
  the full tail (every event past the snapshot, hasMore=false, no
  cursor) instead of the cap.
- The commit-side activation capture applies the same sessionKey/v
  scope check both read paths enforce, so a foreign-scoped
  activation.changed is rejected at commit instead of being promoted
  into the durable head columns a later flag flip would trust.
- The head path's contiguity count carries the legacy walk's
  byte-length tripwire, and the single-revision locate is pinned
  against holes and overlapping ranges; both intent reads reject
  duplicate lines at one sequence.
- Pins added: the phase arm of the commit width guard, the deferral
  marker surviving a fresh empty tick, the per-event attributes read in
  the materialization budget (plus a whole-batch statement total), the
  idle-stream revocation closing at the next wake for both stream
  loops, the Range path's in-window exposure at the shipped 5s default,
  the configuration-to-store flag wiring, and the four-column
  last-activation-wins rotation.
- Housekeeping: the dead args field dropped from the extracted
  fixture's consumer, the provably dead width guard dropped from
  backfillActivation (its callers always pass contract-validated
  values), the yml-mirror test now neutralizes the ambient
  QWEN_MANAGED_AGENT_* environment, the properties test keeps the
  flattened-key assertions, and the event fixture gained a scope/version
  overload.
- Docs: the admission design's recheck sentence now states the windowed
  behaviour and the real closure bound (window plus one poll interval)
  in both languages; §2 qualifies path 4's relief as opt-in until the
  fleet runs the V35 code, with the operator flip tracked as #13295; §6
  states the locate's row cost honestly (constant statement count,
  distance-proportional rows, strictly lighter than the legacy walk);
  §8 composes the bound-page budgets; §9 records the chain proof's
  narrowed parse scope; the README's revalidation row states the real
  in-window exposure and its env-var table names all three new knobs.

* fix(managed-agent): align the commit-side scope check with the read paths

Audit of the R3 wave found one Minor: the new commit-side check compared
the sessionKey field by field, so a key carrying extra fields passed the
commit but reads as foreign to both read paths' closed-key comparison.
The check now builds the closed three-field key and deep-equals it,
exactly the read-side semantics; the commit-rejection test gains the
extra-fielded arm. Also narrows the duplicate-intent test's comment to
the revisions a path actually reads (a stray out-of-range claim is
invisible to the head path's locate and unreachable through the commit
validation).

* fix(managed-agent): close out the fourth review round on #13181

The Critical plus the accepted Suggestions from the fourth review:

- readIntent now answers a damaged journal with the session store's
  corruption fault (500 managed_session_journal_corrupt) instead of a
  client request fault (400): the locate, the contiguity count, and the
  verified-page checks all throw journalCorrupt, widened to
  package-private for the shared spelling. The fence tests assert the
  code and the 5xx status; the legacy path answered 500 already.
- requireLegacyActivation applies the closed-key scope check before
  promoting a scanned activation into the head columns, so a
  pre-existing journal carrying a foreign-scoped activation.changed is
  refused instead of laundered into trusted state.
- The commit-side scope check now covers every enveloped
  managed_session_event_v1 line except domain.committed ones (whose
  requireEnvelope keeps its pinned messages) — a misscoped or
  unknown-version event line is refused at commit instead of reaching
  the journal; bare event-subtype lines with no envelope stay inert and
  tolerated as before.
- tailEvents pages at SNAPSHOT_REFRESH_EVENTS (the gate's lag bound),
  so a maximally lagging snapshot's transcript tail is one event read
  instead of ten; the full-tail contract pins hold with the wider page.
- The empty materialization tick is pinned whole (4 statements), and
  the Range revocation test uses the shared readingPolicy helper.

The nine deferred observations from the review are recorded in the PR
thread.

* docs(managed-agent): state the widened commit-side scope check in section 9

* fix(managed-agent): close the audit round on the R4 fixes

The adversarial audit of the R4 wave found two Suggestions:

- readIntent answered a binding naming a never-committed intent
  sequence with the 500 corruption fault under the head path, where the
  legacy walk answers 400. The locate now splits the cases: an
  ambiguous range or a hole inside the committed span is corruption
  (500), while a sequence beyond the committed span is the requester's
  fault (400 "Committed publication evidence is missing"), matching the
  legacy walk. Pinned by aNeverCommittedIntentSequenceIsAClientFault
  under both flag settings (red without the split, verified).
- The commit-side scope check's domain.committed carve-out also exempt
  unknown-domain lines that requireEnvelope never sees; those now get
  the closed-key check too (pinned by unknownDomainLinesAreRejectedAtCommit,
  red under the kind-based carve-out, verified).

Plus the tail-read test now exercises the multi-page arm: a tail longer
than one 1000-event page is served completely across two reads.

---------

Co-authored-by: wenshao <[email protected]>
2026-10-04 13:56:41 +00:00

19 KiB

Managed Agent Query Amplification Fix

English | 简体中文

1. Status and problem

Proposed; tracks GitHub issue #13181 (audit of main at a7deb01bcb).

Four hot paths in the Managed Agent Runtime Broker amplify database work far beyond request volume, two of them while holding row locks:

  1. ManagedAgentStore.materializeNextBatch rewrites the whole managed_agent_snapshot.items_json after every ≤200-event batch, inside the requireSessionForUpdate row lock that event ingestion also needs. Write volume grows quadratically with session length.
  2. ManagedEventStreamService runs requireReadGrant → ManagedWorkspaceRegistry.canRead (SQL) before nearly every event, per subscriber.
  3. ManagedAgentService.listPublicSessions / listWebShellSessions run 2-3 per-row queries (findActiveTurn / findSnapshotCoveredSequence / findLatestTurn / findLatestEnvironmentEvent / approvalMode) on top of the page query.
  4. ToolPublicationStore.producerBindingLocked rescans the session journal backwards to the last activation.changed (one SELECT ... FOR UPDATE per revision) on every publish/seal/prefix/finish, while holding the publication and tenant locks.

Related: ManagedArtifactService.content re-runs a multi-table permission check per 64 KiB chunk, and seal/finish re-read and re-hash the whole stream on the HTTP request thread.

2. Scope and invariants

In scope: the four numbered paths plus the artifact download check. The public API shapes, the event/journal record formats, and fail-closed authorization semantics do not change. The accepted relaxations, both explicitly sanctioned by the issue, are bounded staleness: permission decisions may be reused within a revalidation window (5 seconds by default, operator-configurable with no enforced upper bound; PT0S restores per-event checks), and the snapshot may lag the projection during bursts but converges once ingestion pauses (bounded by the same 5-second snapshot cadence).

Out of scope: splitting ManagedAgentStore, and moving the seal/finish byte-rehash off the request thread. The rehash is an integrity commitment over mutable object storage; making it asynchronous changes the seal API contract (clients would have to poll operationStatus). The database side of that path is fixed here but opt-in at runtime: its heartbeat re-authorization becomes O(1) via Section 6 once an operator enables journal-head-authorization after the fleet fully runs the V36 code (the rollout is Section 9; the flip itself is tracked by the follow-up issue #13295); until then the heartbeat pays the legacy journal scan, unchanged from before. The contract change is tracked by the follow-up issue #13242.

3. Snapshot rewrite gating

materializeNextBatch keeps per-event projection into the item tables and the consumer-progress update exactly as today, but rewrites the snapshot only when one of the following holds:

  1. no snapshot row exists yet (the insert path, unchanged);
  2. the batch carries a terminal event — a Turn always ends rewritten, so reads converge at Turn boundaries;
  3. the projection advanced at least SNAPSHOT_REFRESH_EVENTS (1000) events since the snapshot's covered_sequence;
  4. the batch caught up with session.last_sequence (the session row is locked FOR UPDATE for the whole batch, so this comparison is stable) AND at least SNAPSHOT_REFRESH_MILLIS (5000) elapsed since the last snapshot write — the trickle case (a batch smaller than one scheduler tick's EVENT_LIMIT) must not rewrite per tick.

A drained batch that rule 4 defers leaves the snapshot behind the projection while the consumer progress already covers it, so the deferral is recorded on the progress row: the batch's progress update sets snapshot_stale_since to the snapshot's updated_at (migration V38), and findMaterializationTargets re-selects a session whose marker is at least SNAPSHOT_REFRESH_MILLIS old; the re-selected (empty) tick converges the snapshot and clears the marker. Without the reselection an idle caught-up session would never be revisited and the snapshot would stay stale forever; the marker keeps the tick's scan free of a per-row snapshot lookup.

All snapshot readers (listPublicItems, transcript, SSE resync frames, advanceReplayFloor) already key off the snapshot's own covered_sequence, so a staler snapshot is self-consistent; transcript additionally tails events past the snapshot (the cursor-less page serves every event after the snapshot, as the published contract promises, so the stream can resume at the reported watermark with no band unserved), so a WebShell stream's event view does not lose freshness — its items array is the snapshot's and lags by the same bound Section 9 states for listPublicItems.

4. SSE read-grant recheck window

Each stream caches its read grant and rechecks at most once per qwen.managed-agent.events.read-grant-recheck-interval (new ManagedAgentProperties.Events duration, default PT5S, PT0S restores per-event checks). The initial requireReadableSession on subscription is unchanged. A revoked subscriber may receive events for up to one window; a session.deleted event still terminates the stream immediately, and a failed recheck completes the stream as today. The recheck runs when the stream loop iterates, so an idle stream's closure additionally waits for its next loop wake — at most one events.poll-interval (5s by default) past the window. Per-subscriber cost drops from up to two canRead queries per event to one per window.

5. Session list batch assembly

The page query stays as is; per-row lookups move into grouped batch queries added to AgentStateStore / ManagedAgentStore:

  • findActiveTurns(tenant, sessionIds) — one query, first row per session after ORDER BY created_at DESC (ties remain unspecified, as today).
  • findSnapshotCoveredSequences(tenant, sessionIds) — one IN query.
  • findLatestTurns(tenant, sessionIds) — one query joining the turn rows to each session's latest turn.accepted event (MAX(sequence_id) derived table), preserving the single-session findLatestTurn semantics including the turn-row existence join. Migration V37 adds managed_agent_event (tenant_id, session_id, event_type, sequence_id) so it reads index ranges instead of a session's whole event history.
  • findLatestEnvironmentEvents(tenant, turns) — one query selecting each session's latest environment event on its latest Turn in SQL (a MAX(sequence_id) derived table filtered to the (session, turn) pairs), so exactly one row per session is read.
  • completedWorkspaceCloses(tenant, sessionIds) — one IN query over managed_agent_operation, fetched only when the page holds workspace-bound sessions, feeding the archive/unarchive/delete capability flags without a per-row read.

Both turn reads project only TURN_SUMMARY_COLUMNS (session_id, turn_id, status, created_at, completed_at, error_code) — exactly what the public and WebShell views expose — so a page never Jackson-parses or retains a prompt graph per row. The approval mode needs no query of its own: every sessionMapper read is a SELECT * over managed_agent_session, so the mapper now maps approval_mode onto SessionRecord and hasActions reads it from the row the page already fetched.

A page of unbound sessions costs at most 3 queries on either surface, and a page of workspace-bound sessions costs one more for the close-state batch; a WebShell page carrying submit-shaped sessions adds one creator-marker batch, and one grant batch when it also holds creator-owned ones — all independent of page size. The single-session views (publicSession, webShellSession) delegate to the same assembly with singleton inputs, so there is one code path. The single-session store reads (findActiveTurn / findLatestTurn / findLatestEnvironmentEvent, findSnapshotCoveredSequence) had no production callers left and are removed, so each selection rule has exactly one spelling; the close-state predicate likewise has one spelling, the singular hasCompletedWorkspaceClose delegating to the batch twin.

6. Publication authorization O(1)

Migration V36 adds five nullable columns to qwen_managed_session_journal_head: activation_id, activation_phase, activation_event_epoch, activation_expires_at, and activation_head_revision. ManagedSessionStore.commit collects the last in-range activation.changed payload during the extension-record parse pass that every commit already runs (no extra decode of the record bytes) and writes its fields into the head in the same single head UPDATE that bumps journal_revision; the stamp activation_head_revision = journal_revision is written on every activation-carrying commit, and on any other commit only when the preserved columns were current at the previous revision — a commit that keeps columns whose stamp already lags leaves the stamp lagging, so a skew left by a pre-V36 writer is never re-stamped into looking fresh. The head row therefore carries the journal's current activation state under the same lock discipline, and the stamp says which journal revision the columns reflect: a commit from a binary that does not maintain the columns still bumps journal_revision, so its head fails the stamp equality check and authorization rescans the journal — the rolling window is self-healing, since the scan's backfill rewrites the columns and re-stamps them. expiresAt may be absent from the payload (timeOrNull); a NULL column then fails the freshness check exactly like the journal scan reading the value as absent does. All three readers of the payload's expiresAt (the commit extraction and the two backfill scans) share one lenient helper — an integral number or an integral numeric string of at most 19 integer digits and at most 19 decimal places, else absent, with width and scale pre-checks before any BigInteger materialization so an exponent-form string costs nothing in either direction (1e+N needs the giant integer, 1e-N expands 10^N before dividing) — so the scan and the head columns can never disagree about representability.

While a rolling fleet can still run a pre-V36 binary — which commits without maintaining the columns — the head is not yet trustworthy, so qwen.managed-agent.tool-publication.journal-head-authorization (default false) keeps authorization on the journal scan. Once every writer runs the V36 schema's code, an operator flips the flag and:

ToolPublicationStore.producerBindingLocked checks the head columns it already read FOR UPDATE — phase is active, the id and event epoch match the binding, activation_expires_at is in the future, and the stamp matches the head's journal_revision — instead of the backward journal scan. When the columns are NULL or the stamp lags (journals written before the migration, journals that never committed an activation change, a payload wider than the columns, which the commit blanks rather than rejecting, or residue from a pre-V36 commit), it runs the legacy scan once and backfills the head from the found event — skipping the write when the columns already hold exactly those values — so every session becomes O(1) after its first post-migration authorization or its next activation change. verifyDispatch and the seal/prefix/finish heartbeats all flow through producerBindingLocked, so they all become O(1).

ToolPublicationStore.requireEvidence (reserve/renew) takes the activation state from the already locked head via the extended PublicationWriter record — before reading anything else, so a fenced activation pays no journal statement at all — and reads the tool.intent at its own revision directly: the binding carries intentSequence, so one indexed range read over last_sequence (migration V39) resolves the revision, one verified page fetches it, and one indexed count (bounded by the same byte-length tripwire the legacy walk applied per revision) proves the chain from that revision up to the locked head is gap-free. The statement count is constant — four journal statements regardless of journal depth — while the locate's row cost grows with the distance between the intent and the head, exactly as the legacy walk's did (it paid the same distance in full locked row reads, so the head path is strictly lighter, not asymptotically constant). Legacy rows keep the previous combined scan, backfilling the head on success.

7. Artifact download revalidation throttle

ManagedArtifactService.content keeps its per-call read-timeout guard but re-runs requireContentAccess (session row + workspace grant + policy) at most once per qwen.managed-agent.artifacts.read-revalidation-interval (new duration, default PT5S). The initial check before streaming starts is unchanged, so revocation latency is bounded by one window instead of being re-evaluated per 64 KiB chunk.

The window throttles the whole of requireContentAccess, which includes the session-lifecycle gate: a session that enters stage-1 DELETING mid-download is likewise observed only at the next window boundary, not at the next chunk (stage-2 retirement still aborts per chunk through the read lease). This deferral is accepted for the same bounded-staleness reason as the grant recheck; the read-timeout guard still caps any download at read-timeout. ReadLease.check() deliberately does not take over the lifecycle check: it is shared by the retention and producer paths that must keep reading the rows of a session being deleted.

8. Validation and acceptance

  • Issue13181QueryBudgetTest's QueryLedger (a DataSource proxy counting prepared statements) pins per-endpoint query budgets: a 20-session page of listPublicSessions ≤ 3 queries (4 for workspace-bound rows, adding the close-state batch) and listWebShellSessions ≤ 3 (a bound page whose submit-shaped sessions include creator-owned ones costs 6 — page, latest turns, environment events, close-state, creator-marker, grant; 5 when the page carries no Turns and the environment-event batch is skipped), with the batch turn reads asserted to project the summary column list and the admission-order selection rules exercised through the page (a two-Turn session admitted out of created_at order with inverted environment-event sequences). materializeNextBatch rewrites the snapshot at most once per 1000 covered events during a burst, at most once per SNAPSHOT_REFRESH_MILLIS on a drained trickle, always on a terminal event, and a deferred snapshot is pinned to converge on the aged-out reselection. verifyDispatch/publish after an activation commit read qwen_managed_session_journal_tx zero times (seal/prefix/finish share the same producerBindingLocked path), and a fenced renew pays no journal statement at all. renew reads the intent at its own revision, a constant 4 journal statements regardless of filler depth (the range read, the chain-contiguity count, and the verified page). The artifact revalidation window is pinned separately by policy-call counts in ManagedArtifactReadIntegrationTest, including the deferred stage-1 DELETING observation.
  • ManagedEventStreamServiceTest keeps the revocation test at a zero window and gains windowed tests counting canRead invocations across events: the window covering a delivery, a revocation landing when it lapses, a successful recheck re-anchoring the window, and the shipped 5-second default exercised through the null-sentinel path (the value itself pinned in ManagedAgentPropertiesTest).
  • Existing suites must pass unchanged otherwise: ToolPublicationStoreTest (activation fencing now exercises the head columns through sessions.commit, and the intra-record ordering of both backward journal scans), ManagedAgentApiIntegrationTest, ManagedAgentMySqlIT, RuntimeBrokerFlywaySchemaTest. ManagedArtifactReadIntegrationTest gained the window tests and a shared setup extraction for them. ManagedAgentServerIntegrationTest is the deliberate exception: the materializer's 10ms tick races its explicit drains, so its transcript catch-up wait budget rose to 15s (the deferred snapshot converges within one SNAPSHOT_REFRESH_MILLIS), and two snapshot-content tests (preservesTextOrderAcrossToolsAndReasoningInSnapshots, singleAppendsContinueTheTextPartBeforeThem) mark their trailing fixture event terminal so the explicit drain always rewrites — both assertions are unchanged.
  • Acceptance: the pinned budgets hold, no test outside the deliberately updated revocation semantics changes its expectations, and the legacy fallback still authorizes a pre-migration journal (covered by a test that nulls the new columns).

9. Risks and follow-up

  • Bounded-staleness relaxations (Sections 4 and 7) are deliberate; both intervals default to 5 seconds and are operator-configurable durations with no enforced upper bound (PT0S restores the strict per-event behaviour), so a deployment may widen the accepted staleness by configuration.
  • A snapshot that lags during bursts delays listPublicItems freshness by up to 1000 covered events; reads converge at Turn boundaries and, for a drained trickle, within SNAPSHOT_REFRESH_MILLIS via the reselection rule.
  • The legacy scan fallback in Section 6 keeps the old cost for pre-migration journals until their first authorization or activation change; this is intentional to avoid a data migration over journal bytes.
  • Rolling deployment: a pre-V36 binary commits without maintaining the head activation columns, so the columns can go stale while old binaries still write. The activation_head_revision stamp records which journal revision the columns reflect, so such a head fails the stamp check and authorization rescans the journal and re-backfills — a premature flag flip self-heals per session instead of certifying stale state. The journal-head-authorization flag (default off) still ships as the deliberate operator switch; enable it once the fleet fully runs the V36 code, knowing residual skew is detected and repaired rather than trusted.
  • The head path's chain proof (Section 6) is a count with the legacy walk's byte-length tripwire, not a per-line parse of the intermediate revisions: a line carrying a foreign scope in a journal written before this change would be caught by the legacy scan but not re-read after the head is backfilled. Commits now reject any misscoped or unknown-version event line (an activation.changed line is the only one whose payload the head persists), so the residual is confined to journals already written by a misbehaving writer — and such a writer could equally have written correctly scoped forgeries.
  • Follow-up: move the seal/finish stream rehash off the request thread (requires an asynchronous seal contract — issue #13242), and consider splitting ManagedAgentStore as noted in the issue.