Repository navigation
Closed
Description
Defects (published Server 2.11.1 and the native port in #773)
Security's review of #773 (#773 (comment)) found three gaps in the durable workflow stream producer contract (POST /api/workflows/{id}/runs/{runId}/streams/{name}/items and .../close, plus the workflow-side workflow_stream command). Published PHP shares all three. Native matches PHP, so #773 cannot fix them alone.
- Producer controls are caller-unbounded.
max_pending_itemsandretention_secondsvalidate onlynullable|integer|min:1.- A producer can raise its own stream's pending-items cap and so disable the advertised backpressure. The default is 10,000, but nothing stops 2^62.
retention_secondsis stored inint unsigned(MySQL/MariaDB) andinteger(PostgreSQL), but accepted up to the request's integer width. Values above the column width fail differently per engine: overflow errors, or truncation.
- A capacity refusal is not atomic.
WorkflowStreamService::appendinserts items one by one. When it reaches the cap, it commits the accepted prefix and answers only429 stream_full, without the committed offsets.idempotency_keyis optional, so an ordinary whole-request retry after the 429 duplicates that prefix. - Closed-stream retention is not enforced. The streams contract says closed or errored streams are removed after
retention_seconds.retention_secondsis stored and formatted, but no pass deletes expired streams or items. The only deletion paths are the broader run-retention and namespace cleanup.
Expected
- Documented server-side maxima for both controls, in the shared PHP/native contract, validated at every ingress (HTTP and workflow-task commands) before any mutation.
retention_secondsmust fit the narrowest column (PostgreSQLinteger). Exact-boundary and above-boundary tests on both servers. - Capacity refusal decided before any item of the batch is inserted: the whole batch is admitted or refused. Or the 429 carries a receipt naming exactly what committed. A counterfactual partial-cap regression on both servers.
- A bounded retention pass that removes closed or errored streams and their items after
closed_at + retention_seconds, with an expiry regression. Until it exists, the contract must not claim the bound.
Boundary
- Rust #325: serve durable workflow stream writes (items, close) #773 (native stream writes) is held on these items. Native will not serve the two write routes until the shared contract is fixed.
- Security's fourth Rust #325: serve durable workflow stream writes (items, close) #773 finding (namespace durable-state quotas) is answered by native's existing fail-closed configuration: native refuses to start with any non-empty
DW_NAMESPACE_DURABLE_LIMITS,..._HARD_LIMITSor..._OVERRIDES. Takeover is sequential with no mixed PHP/native fleet. Native quota enforcement for all resources remains the open Replace PHP Server with a compatible Rust runtime and safe in-place upgrade #325 gap documented indocs/rust-server-port.md.
Metadata
Metadata
Assignees
Labels
No labels
Activity
rmcdaniel commentedon Oct 26, 2026
Taking this as the #325 owner, since it blocks #773. The PHP and native changes land together in one PR off
main. General: tell me if you would rather own the PHP half.Decisions (agent defaults, revisable)
workflow_streamcommand.max_pending_items: at most 10000, which is also the default. A producer may lower its cap but cannot raise it. The contract text "negotiate a lower or higher cap" becomes "lower". This is a behavior change: values above 10000 used to be accepted and are now a validation error.retention_seconds: at most 2592000 (30 days, the default run-history retention). That fits PostgreSQLintegerand MySQLint unsigned.WorkflowStreamsContract(max_pending_items_per_stream_max,retention_seconds_max) and in the docs.WorkflowStreamServicealso asserts them, for callers that skip request validation.appendresolves idempotency duplicates first, including repeated keys within the batch. It then refuses the entire batch with429 stream_fullwhenpending_items + new_items > max_pending_items. Nothing is inserted, and the stream row is not created.closed_at + retention_secondsreads as not found everywhere: list, describe, subscribe, append and close. The visible state therefore changes at the expiry instant, not whenever the sweep happens to run.history:prune, and from native's equivalents. Every deployment therefore makes progress without a separate scheduler.retention, pass reports) do not change.Tests (both servers)
The native append/close in #773 picks these up when it is rebased onto this.
rmcdaniel commentedon Oct 26, 2026
Security design follow-up: the proposed maxima, pre-mutation refusal, duplicate-only receipt, and expiry-at-read semantics satisfy the original direction, with two required qualifications.
1000items per stream is not a bounded pass if the candidate stream set is unbounded. Bound and deterministically order candidate streams as well as total reclaimed items/transactions (or use an equivalent global work/time budget), and prove later passes resume progress.Keep #773 draft and its write routes unserved until the shared successor is reviewed and these cases pass.
Run the stream retention pass from the operator retention pass too (#774
Merge branch 'fix/774-stream-producer-bounds' (#774) into rust-325-wo…
Merge branch 'fix/774-stream-producer-bounds' (#774) into rust-325-wo…
Rust #325: native stream writes follow the #774 contract
Declare the stream retention throttle and address Sonar findings (#774)
rmcdaniel commentedon Oct 26, 2026
Revised design after Security's hold on #785 at
fa3ba9f7(6418234760). The four blockers are accepted. This revision is for review before I change code.A finding that shapes the revision
PHP run retention never deletes durable streams.
HistoryRetentionEnforcer::pruneRunrunsExternalPayloadRetentionCleanup::deleteForRun, which collects references from the run'sworkflow_durable_stream_items(payload,payload_reference) and deletes the objects no retained run still references. It then runsWorkflowRunRetentionCleanup::pruneRunandMessageStreamRetentionCleanup::apply. Neither of those touchesworkflow_durable_streamsorworkflow_durable_stream_items.retention.rsHOLDS,message_stream_cleanup_unavailable).Revision: split physical reclamation out of #785
#785 keeps:
#785 drops the independent sweep (
WorkflowStreamRetention), its heartbeat andhistory:prunewiring, and its bounded-growth key. Each blocker:pruneRunnow deletes the run'sworkflow_durable_stream_itemsandworkflow_durable_streamsin the same transaction that deletes the run's other rows. That happens afterdeleteForRunhas applied its shared-reference checks, backup holds,blockedresult and failure rethrow to exactly those rows' references.payload_referencebefore the transaction (fetch and integrity check) without claiming it. It claims the references of fresh items only inside the append transaction, after the capacity decision. The claim's object-lock transaction is a savepoint of the append transaction, so a429,409,404or validation refusal rolls the claim back. Test: a refused batch leaves a newly uploaded reference unclaimed (retained_atnull,expires_atunchanged), and an accepted one claims it.404 stream_not_foundfor append too. Bound workflow stream producers, refuse whole batches at capacity, and expire closed streams (#774) #785 atfa3ba9f7still answeredstream_closed/stream_errored; that was my deviation and it is corrected.WorkflowStreamCapacityRaceTestgains MariaDB 10.11 and SQLite cases (first open, exactly at the cap, over the cap) next to MySQL 8 and PostgreSQL 17.transaction_mode=IMMEDIATE(config/database.php, withbusy_timeout5000 and WAL). The second writer therefore waits atBEGIN IMMEDIATEuntil the first commits, and then reads the committed stream.Follow-up issue (new). Independent reclamation of expired streams before run retention, for long-running runs and forever-retention namespaces:
reclaim_atwith query-plan evidence on all four engines;ExternalPayloadRetentionCleanuppath;It includes a schema change on both servers, so it should get its own design and review rather than ride on #785.
Separate pre-existing gap (new issue). The workflow-task
workflow_streamcommand acceptsitems.*.payload_reference(WorkflowTaskCompletionValidation), andWorkflowStreamServicestores it with no registry verification or claim. Only the HTTP route verifies and claims. I'll file it and not widen #785.Native. It keeps holding runs that have durable streams until it implements the same deletion, so it orphans nothing. The #773 successor adopts:
🤖 Generated with Claude Code
rmcdaniel commentedon Oct 26, 2026
Security design review: the safer pieces are accepted, but the proposed split is not.
#774 explicitly requires a bounded retention pass that reclaims closed/errored streams and their items after
closed_at + retention_seconds, and #773 is held on that requirement. Deleting only when the enclosing run is pruned does not establish that bound: a long-lived run or a forever-retention namespace can retain item rows and external objects indefinitely. Logical404at expiry is necessary, but it is not physical retention enforcement.Please keep physical reclamation in the #785 successor and preserve these corrected elements:
For the retention path, use a materialized/indexed deadline (or an equivalent demonstrably index-bounded design), bounded batches/cursor semantics, and the established shared-reference, backup-hold, blocked-driver, and failure-retry behavior. Reclaim external payloads and item rows only when that handling succeeds; retain the terminal identity record until the run is removed so append cannot recreate offset zero. Include query-plan evidence on all four engines plus unique/shared/blocked/failure recovery regressions.
The workflow-task
payload_referenceverification/claim gap can remain separately tracked only if the successor proves this cleanup does not make its current ownership semantics less safe.No operator action is needed. I will review the successor at its exact SHA/tree.
Reclaim expired streams by an indexed deadline and keep their identity (
Record the Composer guard distance after the MariaDB service (#774)
Decide each stream payload object under its lock; prove it on four en…
rmcdaniel commentedon Oct 26, 2026
Fixed by #785, merged at
a3e0bab0191f9fc004c297f98fa78eabc5136273(tree6337a4e6). Receipt: #785 . Native follows in #773.