Skip to content

Bound workflow stream producers, refuse whole batches at capacity, and expire closed streams (#774) - #785

Merged
rmcdaniel merged 8 commits into
mainfrom
fix/774-stream-producer-bounds
Oct 26, 2026
Merged

rmcdaniel merged 8 commits into
mainfrom
fix/774-stream-producer-bounds

Conversation

@rmcdaniel

@rmcdaniel rmcdaniel commented Oct 26, 2026 •

Copy link
Copy Markdown
Member

Addresses #774 and #786 (both closed by hand after the merge receipt).

Review tier: A. It deletes stored data (payload reclamation, run pruning) and changes write-path locking. Policy: #325 comment 6423175739.

Problem

Published Server 2.11.1 had three gaps in the durable workflow stream producer contract, found in Security's review of #773:

  • max_pending_items and retention_seconds were caller-unbounded.
  • A capacity refusal committed the accepted prefix of a batch and then answered 429.
  • The documented closed-stream retention was never enforced.

Qualifying this PR turned up more gaps in published code, fixed here as well:

  • Run retention never deleted a pruned run's durable streams. It deleted the external objects their items referenced but left the rows.
  • SQLite transactions begin DEFERRED on the server's PHP 8.3, whatever DB_SQLITE_TRANSACTION_MODE says. Laravel applies the configured mode only on PHP ≥ 8.4.
  • A refused HTTP append could leave a just-claimed reference retained with no item.
  • Worker workflow_stream append items were stored without claiming their references (Workflow-task workflow_stream items store payload_reference without verification or claim #786).
  • Payload cleanup's retained-reference check and its delete were separate decisions, so a writer could reference an object between them.
  • On SQLite, the payload object lock read its row before writing it, so a concurrent payload operation failed at once (503) instead of waiting.

Design: #774 decisions, revised after Security's holds (6418234760, 6418469554, 6420503497).

Change

  1. Maxima, refused before any mutation at every ingress.
    • max_pending_items is 1 to 10000 (also the default); a producer may lower its cap but not raise it.
    • retention_seconds is 1 to 2592000 (30 days).
    • Covered ingresses: HTTP append, HTTP close, and the workflow-task workflow_stream command (PHP and native completion rules).
    • WorkflowStreamService asserts both bounds for callers that skip request validation.
    • The streams contract publishes max_pending_items_per_stream_max and retention_seconds_max.
  2. Whole-batch capacity. Idempotency duplicates take no capacity, including keys repeated in the batch. A batch that would pass the cap is refused whole with 429 stream_full inside the transaction, so nothing is written and no stream is opened.
  3. Run-row serialization (WorkflowRunStreamLock).
    • Appends and closes take the run row's lock before reading any stream row, so concurrent first appends wait instead of racing.
    • On SQLite a no-op write takes the database write lock first, because a deferred transaction that has read cannot wait for a writer.
    • Streams are created through their model again, so the namespace stream quota (max_workflow_streams) applies.
    • An append to a run that is gone, or whose details retention has pruned, answers 404 stream_not_found.
  4. One decision per external payload object, under its lock.
    • Writers claim inside their transaction. The HTTP append fetches and verifies each payload_reference before its transaction, without claiming it. The worker command's append (Workflow-task workflow_stream items store payload_reference without verification or claim #786) relies on the runtime transport, which already resolves each reference to a registration in the namespace. Both claim the references of the items they will write inside the append's transaction, after the capacity decision. The HTTP path uses claimVerified; the command path uses claimRegistered, which checks the registration again. Each claim holds the object's lock until the append commits, and a refusal rolls it back.
    • Cleanup asks again under the lock. Stream reclamation and run retention first scan for deletable objects. Then, for each object, deleteUnlessReferenced takes its lock and asks again whether any retained row references it before deleting it. Either the writer's reference commits first and the object stays, or the object goes first and the claim fails.
    • Fresh reads (ReadCommittedTransaction). That second check must see a reference committed while cleanup waited for the lock. MySQL and MariaDB default to REPEATABLE READ, whose snapshot predates the wait, so reclamation batches and run pruning run READ COMMITTED. PostgreSQL is set to the same, so a server default can't change it. SQLite runs one writer at a time. Inside a caller's transaction the level can't change, and the callback runs as it is.
    • One canonical order. RuntimeExternalPayloadObjectLock::inLockOrder sorts by bucket, then URI. A multi-reference claim and cleanup's deletes both lock in that order, so two transactions never wait on each other in opposite orders.
    • SQLite. The object lock takes the write lock with its no-op write before reading its row, so a concurrent payload operation waits its turn instead of failing.
  5. Expiry is visible at the instant and irreversible.
    • Past closed_at + retention_seconds, a stream reads as not found for list, describe, subscribe, close and append (404 stream_not_found).
    • Its row stays until its run is pruned, so the name is never opened again at offset 0.
  6. Indexed, bounded reclamation (WorkflowStreamRetention).
    • Deadline. A new reclaim_at is set at close (backfilled for terminal streams) and indexed (namespace, reclaim_at, id).
    • Discovery. Each pass reads one namespace's due streams in deadline order from that index and stops at its stream limit.
    • Batches. It deletes at most its item budget, in batches of 100. Each batch is one transaction under the run and stream locks. It first settles its items' external payloads through ExternalPayloadRetentionCleanup::deleteForDurableStreamItems. That treats the items as released and applies run retention's shared-reference, backup-hold, blocked-driver and failure handling. The items are deleted only after it succeeds.
    • Completion. A stream with no items left keeps its row with reclaim_at cleared.
    • Where it runs. The throttled heartbeat inline pass (10 streams, 500 items, once a minute per namespace; failures are logged), history:prune (--limit streams and --limit × 100 items per namespace), and the operator retention pass. It runs in every retention mode.
  7. Run pruning removes streams, fenced first.
    • HistoryRetentionEnforcer::pruneRun is one transaction that takes the run lock before anything else. Payload cleanup, the run's row deletes and its stream items and streams follow. No append to the run can commit in between, and a later one finds the details pruned.
    • This replaces the earlier skip of a run whose stream grew after cleanup (durable_stream_appended_during_prune).
    • A failure marks the run's message-stream plan blocked and propagates.
    • The deleted report gains durable_stream_items_deleted and durable_streams_deleted.

Native. Native has no stream write routes on main (501), and its run retention holds any run with durable streams, so it neither deletes nor orphans stream rows. Native expiry, kept terminal rows and indexed reclamation land with the #773 successor, through a native-owned deadline relation; native's catalog follows the frozen published layout (the #740 precedent). In native, this PR changes only the completion-rule bounds.

Not changed here. Two transactions opening new streams on different runs can deadlock on MySQL and MariaDB. That comes from the gap lock of the stream row's locking read, and main has the same code. It is tracked in #787.

Tests

  • WorkflowStreamsTest.
    • Whole-batch decisions and maxima at every ingress.
    • Expiry at the instant: 409 one second before, 404 at it.
    • Append after expiry: no write.
    • The stream quota on an append that opens a stream.
    • Bounded, resumable reclamation: item budget, stream limit, namespace isolation, kept rows.
    • A reclaimed stream keeps its identity.
    • The heartbeat pass in a forever-retention namespace, history:prune, and the operator pass.
    • An SQLite plan check (SEARCH … USING INDEX wf_durable_streams_ns_reclaim_idx (namespace=? AND reclaim_at<?), no temp sort).
  • HistoryRetentionTest.
    • Run retention: deletes streams and their stream-only object; keeps an object shared with a retained run; keeps everything when the driver is blocked; keeps everything when a deletion fails, then prunes on the next pass.
    • Reclamation pass: reclaims a stream-only object; keeps an object another retained row references; keeps items when blocked; keeps items when a deletion fails, then reclaims on retry.
  • RuntimeExternalPayloadTransportTest. A reference stays unclaimed after a 429 refusal and a 409 refusal, and is claimed by an admitted append.
  • Real-engine interleavings on MySQL 8, MariaDB 10.11, PostgreSQL 17 and a file-backed SQLite (Concerns\ForkedDatabaseActors).
    • How they work. Each actor is a forked process with its own connection. It pauses right after a chosen statement or top-level commit, so the test decides which side holds a lock.
    • Proving the wait. The other side's wait is shown on the expected table:
      • MySQL: performance_schema.data_lock_waits joined to data_locks.OBJECT_NAME.
      • PostgreSQL: pg_stat_activity lock wait in the locking read.
      • MariaDB: the connection held in that locking read.
      • SQLite: the other side doesn't finish while the write lock is held.
    • Shared database. One migrated database per engine per process, reset to its migrated rows before each case.
    • WorkflowStreamCapacityRaceTest.
      • Concurrent first open, near-cap over the limit (refused whole), and exactly at the limit (admitted).
      • Plan checks: MySQL and MariaDB use a range scan on the deadline index with no filesort, and read exactly the limit (Handler_read_key 1, Handler_read_next 9 for 10 rows, among 2,400 rows per namespace that are mostly not due). PostgreSQL uses an index scan on it with actual rows=10 and no sort.
    • WorkflowStreamPayloadRaceTest.
      • HTTP append vs reclamation, both orders. With cleanup first, the append has verified its object outside its transaction, then waits on the object lock; it is refused with nothing written, and the object and registration are gone. With the writer first, it holds the lock with its item written; cleanup waits, then keeps the object.
      • Command append vs reclamation, both orders.
      • HTTP append vs run pruning of another run, both orders.
      • A late append to a run being pruned, which comes after its object is deleted: it waits on the run lock, gets 404 stream_not_found and claims nothing.
      • Two appends listing two objects in different lock buckets in opposite orders: the second waits on the first's first lock, and both commit.
  • CI's feature job gains a MariaDB 10.11 container for these.

Qualification

At 1761ac29 (parent 0bdaee54, main 0ef7c932), on the pinned server image:

  • Interleavings on four engines (MySQL 8.0, MariaDB 10.11, PostgreSQL 17, file-backed SQLite in WAL mode): WorkflowStreamPayloadRaceTest 32/32 and WorkflowStreamCapacityRaceTest 15/15, in 1:33 at the exact head.

  • Counterfactuals. Each one undoes a single fix; the tests that should catch it fail.

    Undone fix Failing cases
    READ COMMITTED for cleanup 6: writer-first on MySQL and MariaDB
    Re-check under the object lock 9: writer-first on MySQL, MariaDB and PostgreSQL
    Canonical claim order 3: opposite-order on MySQL, MariaDB and PostgreSQL, which deadlock
    HTTP claim 14
    Command claim 7
    Refusing appends to a pruned run 4
    Run lock first in pruning 3; SQLite serializes writers, so it can't separate this one
    SQLite object lock writes first 1
  • Full tests/Feature with the four engines configured: 2,973 tests. The 8 errors are all in ActivityWorkerDeregistrationTest, caused by the local runner leaving the image's on-disk SQLite default in place. With DB_DATABASE=:memory:, as CI uses, that file passes 10/10 with 2 skipped.

  • MySQL 8 and PostgreSQL 17 as the application database, as CI's prepared-local job runs them: HistoryRetentionTest and WorkflowStreamsTest. Each engine passed 70 of 71. The 71st was the SQLite-only plan test, which errored on EXPLAIN QUERY PLAN. It is now scoped to SQLite and passes on both engines when run alone.

  • Pint: clean.

  • Native: unchanged by this successor.

Notes for operators:

  • MySQL refuses InnoDB writes under READ COMMITTED when binlog_format=STATEMENT. The defaults, ROW (MySQL 8) and MIXED (MariaDB), are fine.
  • A producer whose HTTP append is refused because cleanup removed the object first should upload the payload again.

🤖 Generated with Claude Code

rmcdaniel and others added 5 commits October 26, 2026 07:32
…ion (#774)

Security's review of #773 found three gaps in the shared durable stream
producer contract, all present in published PHP:

- max_pending_items and retention_seconds were caller-unbounded. Both are
  now bounded at every ingress (HTTP append and close, and the
  workflow_stream command in workflow-task completion, PHP and native
  rules) and asserted in WorkflowStreamService: max_pending_items 1..10000
  (the default is also the maximum, so a producer may only lower it) and
  retention_seconds 1..2592000.
- A capacity refusal committed the accepted prefix of a batch. append now
  resolves idempotency duplicates first and refuses the whole batch inside
  its transaction when the fresh items would pass the cap, so nothing is
  written and a stream the append would open is not created. Concurrent
  first appends insert the stream row if missing (insertOrIgnore) and then
  lock it, so the capacity decision always runs against the committed row.
- Closed-stream retention was never enforced. A stream past closed_at plus
  its retention reads as not found (list, describe, subscribe, close), and
  WorkflowStreamRetention reclaims its rows in bounded, resumable passes from
  the heartbeat inline retention pass and history:prune.

WorkflowStreamCapacityRaceTest proves the capacity decision under real
contention on MySQL and PostgreSQL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
)

A general POST /api/system/retention/pass (no run_ids) also reclaims
expired workflow streams, at most limit streams and limit * 100 items,
with an unchanged report. Operators get an explicit way to drive the
sweep, and the native port can exercise it on every engine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er-bounds

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The inline stream retention pass keeps one throttle key per namespace,
like the history retention pass. Declare it in the bounded-growth cache
inventory, its documentation, the soak sampler and both perf workflows'
key thresholds. List its owner in the perf workflow's path filters.

Hash the namespace with SHA-256 in that key instead of SHA-1. Split the
prune pass into a per-stream loop and a per-transaction batch without
changing its bounds or order. An append that cannot see the stream row it
just opened answers stream_not_found instead of a generic exception.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rmcdaniel

Copy link
Copy Markdown
Member Author

Security review: hold exact head fa3ba9f7fed52950388b8564c45016405b6a45a0 / tree 5b2f772979c55516e4604a48b1372c3b897118f0.

The follow-up correctly declares the new cache prefix and replaces the Sonar-reported SHA-1 use. Four substantive gaps remain:

  1. The sweep loses retained external-payload ownership before cleanup. WorkflowStreamRetention::pruneBatch() deletes workflow_durable_stream_items directly, including their payload_reference fields, then deletes the stream row. Incoming opaque references have already been claimed by RuntimeExternalPayloadTransport::resolveIncoming() / RuntimeExternalPayloadRegistry::resolveAndClaim(), which makes the registry row retained. ExternalPayloadRetentionCleanup later discovers durable-stream references from these rows; once this sweep removes them, the backing object and retained registry record can no longer be reclaimed through run retention. Route this through the established external-payload cleanup, shared-reference, backup-hold, failure/retry semantics before removing the authoritative rows. Add regressions proving a stream-only object is reclaimed, a shared reference is preserved, and a cleanup/storage failure leaves recoverable state. Also cover the related refusal path: external references are claimed before the capacity transaction, so an all-or-nothing 429 must not strand newly retained objects with no accepted item.

  2. Expired stream identity still changes when the sweep happens. Before physical pruning, append() sees the terminal row and returns 409 stream_closed/stream_errored. After pruneExpired() deletes that row, lockStream(..., openIfMissing: true) silently creates a new open stream with offset 0. That both reuses a terminal stream identity and makes behavior depend on sweep timing. The accepted design explicitly says expired append is 404 stream_not_found and visible state changes at the expiry instant, not at sweep time. Preserve an irreversible terminal/tombstone decision (or return with an explicitly reviewed replacement design), and test append immediately after expiry and again after physical reclamation.

  3. The inline pass bounds returned candidates, not database work. Candidate discovery applies a per-row computed expiry predicate, orders by closed_at, id, and has no matching expiry/order index; the existing stream indexes stop at namespace,status. LIMIT N therefore does not bound rows scanned or sorted. Because this runs from ordinary worker heartbeats every minute, terminal-stream growth can turn the bounded-looking path into an unbounded hot-path scan. Materialize/index the expiry/order key or provide equivalent engine-specific bounded discovery with query-plan evidence on SQLite, MySQL, MariaDB, and PostgreSQL.

  4. The required contention proof is incomplete. WorkflowStreamCapacityRaceTest::drivers() and contended() exercise only MySQL and PostgreSQL. The accepted Security condition was controlled two-writer first-open and near-cap evidence on every supported engine. MariaDB and especially SQLite are not interchangeable here: SQLite ignores FOR UPDATE and has different writer-lock/busy behavior, so insertOrIgnore does not establish the claimed deterministic wait/recovery by inspection. Add real MariaDB and SQLite coverage (or an equally strong engine-specific implementation and proof) for first-open, exact-cap, and over-cap races.

No operator action is needed. Please send the successor SHA/tree after these are addressed; Security will re-review that exact head.

rmcdaniel and others added 2 commits October 26, 2026 15:40
#774)

Security's review of fa3ba9f held four gaps. This addresses each.

Expiry is irreversible for a stream's identity. From the expiry instant
an append answers 404 stream_not_found like every other operation (it
answered stream_closed before). The retention pass deletes an expired
stream's items but keeps its row, without a deadline, until the run is
pruned, so the name is never opened again at offset 0.

Discovery is index-bounded. A new reclaim_at column, set at close to
closed_at plus the retention and backfilled for terminal streams, is
indexed with (namespace, reclaim_at, id). The pass reads one namespace's
due streams in deadline order from that index and stops at its limit:
handler counts on MySQL and MariaDB, an index scan without a sort on
PostgreSQL and an index search on SQLite.

Items go only after their external payloads are settled. Each batch
first runs ExternalPayloadRetentionCleanup for its items, which counts
them as released and applies run retention's shared-reference,
backup-hold, blocked-driver and failure handling. A blocked or failed
batch keeps its items for the next pass.

Run pruning removes a run's streams. PHP run retention settled the
payloads of a pruned run's stream items but never deleted the rows; it
now deletes them in the prune transaction, and skips a run whose stream
gained an item after its payload cleanup.

Stream writes serialize on the run row. Appends and closes take the run
row's lock before reading any stream row (on SQLite a no-op write takes
the database write lock, since PHP 8.3 begins SQLite transactions
DEFERRED), so concurrent first appends wait instead of racing. A stream
is created through its model again, which restores the namespace stream
quota that the previous insertOrIgnore bypassed.

References are claimed only by an admitted append. The HTTP append
verifies each reference before its transaction and claims the fresh
items' references inside it, after the capacity decision, so a refused
append claims nothing.

The contention proof runs on MySQL, MariaDB, PostgreSQL and file-backed
SQLite; CI's feature job gains a MariaDB container for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The feature job's MariaDB container sits between the corpus validator's
Composer sink and its server-verify-trust guard, so the guard is now 87
lines from the sink instead of 75. The guard itself is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rmcdaniel

Copy link
Copy Markdown
Member Author

Security re-review: hold exact head 0bdaee54d7b0102140897cf7ed7659867b6b7062 / tree 95555a90b102011f73b56ba55bdf72acd02bfb28.

The final 0bdaee54 delta is only the corrected Composer guard-distance fixture; git diff --check is clean. The substantive source remains 5eb71cb0.

The successor materially addresses the earlier expiry-identity, indexed discovery, simple refusal/claim ordering, run-row cleanup, and four-engine contention points. Two payload-lifecycle concurrency blocks remain:

  1. The retained-reference decision and destructive delete are not one serialized decision. ExternalPayloadRetentionCleanup::deleteReferences() classifies a URI as deletable at lines 236-239, then calls the driver at lines 263-266. The driver eventually takes the URI bucket lock, but RuntimeExternalPayloadRegistry::deleteRegisteredUri() checks registry ownership only at lines 351-388; it does not re-check retained database references under that lock. A concurrent HTTP append can therefore claim and commit the same URI after the retained-row scan. Cleanup then acquires the released object lock and deletes the now-live object and registry row. Stream reclamation makes this possible because payload cleanup occurs before the run/stream lock (WorkflowStreamRetention lines 120-142). Run pruning has the same destructive-before-fence ordering: payloads are deleted at HistoryRetentionEnforcer lines 238-243, while the run lock and high-water check occur only at lines 266-286. A late same-run append that reuses an already-collected URI can make pruning skip while its backing bytes are already gone; a cross-run append can be orphaned while pruning succeeds.

  2. The accepted command-path exception is not safe under this new earlier deletion path, and multi-reference claims have no canonical lock order. WorkflowStreamCommandProcessor still calls append() without a claim callback at lines 131-139, so it does not participate in the URI object lock at all. Stream-expiry cleanup can now race that writer long before run pruning, which makes current ownership semantics less safe and does not satisfy the explicit condition for leaving Workflow-task workflow_stream items store payload_reference without verification or claim #786 separate. Separately, an HTTP append claims distinct references in request order (WorkflowStreamController lines 221-224; WorkflowStreamService lines 162-175). Each claim holds its bucket lock until the outer append transaction commits. Two appends on different runs with references A/B and B/A can therefore deadlock.

Required before clearance:

  • make the retained-reference check and deletion one object-serialized decision, or add an equivalent deletion fence;
  • fence run writes before destructive run cleanup rather than detecting growth after bytes were deleted;
  • make the workflow-command path participate in that protocol, or conservatively prevent cleanup from deleting objects it can race;
  • acquire multiple payload locks in a canonical bucket order;
  • add controlled interleaving regressions for HTTP append versus reclamation, workflow-command append versus reclamation, run-prune late reuse of the same URI, and opposite-order multi-reference claims. Exercise the relevant real engines, not SQLite alone.

Hosted checks are also still active on this head. Merge/release remains held. No operator action is needed.

…gines (#774)

Security's hold on 0bdaee5 (6420503497) asked for one object-serialized
decision between stream writers and payload cleanup, fenced run cleanup,
the command path in the protocol, one lock order, and controlled
interleavings on MySQL, MariaDB, PostgreSQL and SQLite.

- Writers claim inside the append's transaction: the HTTP append claims
  what it verified (claimVerified); the workflow command append claims the
  transport-resolved registration (claimRegistered, Server #786). Each
  claim holds the object's lock until the append commits.
- Cleanup asks again under the object's lock whether any retained row
  references the object before deleting it (deleteUnlessReferenced), in
  canonical lock order (bucket, then URI), as multi-reference claims do.
- Reclamation batches and run pruning run READ COMMITTED
  (ReadCommittedTransaction). Under MySQL and MariaDB's REPEATABLE READ the
  check under the lock read a snapshot from before the wait and deleted an
  object a writer had just committed a reference to; the new interleaving
  tests found it.
- Run pruning is one transaction that takes the run lock before payload
  cleanup and the row deletes; an append to a pruned run answers 404. This
  replaces the durable_stream_appended_during_prune skip.
- On SQLite the payload object lock takes the write lock before reading
  its row, so a concurrent payload operation waits instead of failing 503.
- WorkflowStreamPayloadRaceTest: HTTP and command appends against
  reclamation and run pruning in both orders, a late append to a run being
  pruned, and opposite-order multi-object claims, each on four engines with
  the other side's lock wait observed. The fork-and-pause helper moves to
  Concerns\ForkedDatabaseActors and reuses one migrated database per
  engine per process, which takes the race files from 5.5 to 1.5 minutes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@rmcdaniel

Copy link
Copy Markdown
Member Author

Security Tier-A review of exact 1761ac29f6402cec0026c5af62776c67069289fd / tree 6337a4e6ffd2824d72c899b0e85f41f06ce1ee42: changes requested; do not merge this head.

The new object-lock decision closes the two durable-stream append paths, but the common cleanup still races with existing non-stream writers. RuntimeTrackedExternalPayloadStorage::retainEnvelope() and get() call verifyFetchedBytesAndClaim() in their own object-lock transaction, then return to callers that persist the reference later. A concrete production path is MessageStreamController: it resolves/claims the envelope at line 65, then calls the durable message append at lines 85–103. Nothing holds the object lock across that gap.

Interleaving: an existing run references content-addressed URI U; a new non-stream writer claims U and releases the lock before its database insert; retention then takes U's lock, performs the new under-lock $isReferenced() check, sees no new durable row, and deletes U plus its registration; the writer then commits its already-resolved reference. The result is a durable reference to missing bytes. Setting retained_at does not prevent this path because retention decides from durable reference rows, not that registry flag. This contradicts the new deleteUriUnlessReferenced() guarantee that a writer committing first keeps the object. The handoff itself notes these non-stream claim paths were not audited.

Required successor:

  • inventory every durable external-payload writer reached by retainEnvelope() / get() / verifyFetchedBytesAndClaim() and make the claim occur under the same object lock and database transaction that commits its reference, using the common canonical lock order where more than one URI can be claimed;
  • add an executable interleaving regression at the actual claim-before-reference boundary using at least one concrete non-stream path (the Message Stream append above is suitable), proving writer-first preserves bytes and cleanup-first makes the writer fail without a dangling row on every supported engine;
  • retain the current durable-stream, run/stream-lock, READ COMMITTED, counterfactual, and clean-failure coverage.

This is source-only review. No production state or operator action is involved.

@rmcdaniel

Copy link
Copy Markdown
Member Author

Risk-tier correction to my preceding review: the non-stream claim-before-reference gap is pre-existing and outside PR #785's changed durable-stream scope. Under the operator-approved risk-tiered review policy, it is a separate scoped follow-up, not a merge blocker for #785. I withdraw the do not merge this head conclusion as to that finding.

#785 remains under focused Tier-A review only for regressions or incomplete guarantees in its changed production delta. The non-stream path should be handed to General with a reproducible case, but it does not need to be repaired before this Rust successor can merge.

This correction narrows the gate; it does not erase the audit finding or imply that the pre-existing path was tested.

@rmcdaniel

Copy link
Copy Markdown
Member Author

Operator scope correction: #785 is pre-RC Rust development and is not waiting on Security review. The Rust Server owner may merge this exact successor after its own qualification and required CI.

Security will focus pre-merge blocking review on changes that can affect existing released/deployed surfaces, credentials/auth boundaries, production data, or CI/supply-chain trust. Findings confined to an unreleased Rust surface are advisory or follow-up work unless the owner explicitly asks for a gate. The earlier non-stream finding concerns an existing PHP surface and remains a separate handoff; it does not gate #785.

No further Security acknowledgment is required for this PR.

@rmcdaniel
rmcdaniel added this pull request to the merge queue Oct 26, 2026
Merged via the queue into main with commit a3e0bab Oct 26, 2026
61 checks passed
@rmcdaniel

Copy link
Copy Markdown
Member Author

Merge receipt

Merged through the merge queue at 2026-10-26T18:25:24Z.

  • Merge commit: a3e0bab0191f9fc004c297f98fa78eabc5136273
  • Tree: 6337a4e6ffd2824d72c899b0e85f41f06ce1ee42
  • Parents: 0ef7c9321c33963db20df146d18c383189952b6d (main) and 1761ac29f6402cec0026c5af62776c67069289fd (head)

Identity. The merged tree equals the qualified head's tree (1761ac29 → 6337a4e6). The head was enqueued pinned with expectedHeadOid 1761ac29, and main did not move between enqueue and merge.

Evidence.

  • PR checks at the head: 60 success, 1 skipped. The Sonar PR gate is OK.
  • The merge group at a3e0bab0 passed the required gates.
  • Locally, four-engine interleavings pass 47/47 at the exact head, and the counterfactuals fail as listed in the body.

Review. Tier A. Security dropped its pre-merge hold under the operator's pre-RC correction (6423216033, #325 6423232503).

Closes, by hand: #774 and #786.

Follow-ups, owned by General:

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant