Repository navigation
Rust #325: serve bridge adapter webhooks - #776
Conversation
…ed 2.11.1 Refusals, start_workflow (new, duplicate, reject_duplicate, derived ID, unconfigured type), and signal_workflow and update_workflow (unknown, malformed, accepted, deduplicated). Recorded three times, identical. One published defect is bounded: bridge updates skip external update admission.
POST /api/bridge-adapters/webhook/{adapter} validates the adapter name and
event, then starts, signals or updates a workflow with PHP's bridge command
context, deduplicating signals and updates by adapter, action, idempotency
key and target name. Starts check PHP's draining gate, then the reserved type
and namespace, before starting. Every answer is the bridge outcome envelope.
An update is admitted as the regular update route admits it, not refused as
published PHP refuses it (Server #747); the fixture bounds that defect. The
fixture also gains the reserved type and namespace steps, recorded three
times against published 2.11.1.
…ust-325-bridge-adapters The runtime, router access table, comparator and probe keep both sides' entries. Pull requests run without a path filter on main, so the old pull_request paths list goes. Bridge adapter webhooks leave main's unserved route list (native serves 139 of 175) and its missing list. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main (worker session reads) and this branch (bridge adapter webhooks) each added one family and each raised the table's length from 8 to 9; it now holds 10. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ust-325-bridge-adapters Conflicts kept both sides: the router, delegates, modules, parity contract, probe and workflow test list carry bridge adapters with the workflow imports and record_local_activity. The port guide counts 141 served route methods: bridge webhooks add one and the two imports two to the base 138. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The bridge-adapters fixture expects a corrected bridge update to be
admitted as POST /api/workflows/{id}/update/bump admits it (Server #747).
Its worker declared update `bump` without `update_validators`, so the run's
durable contract never states whether `bump` has a validator, and published
2.11.1's regular route refuses it 409 update_validator_contract_missing.
Native already admits bridge updates through the regular route, so it
answered that refusal instead of the expected 202.
The worker now declares `update_validators: []`. On published 2.11.1 the
bridge path still records the #747 defect unchanged (the fixture passes on
PHP through its published_defects branch), and the regular route accepts the
same update with 202. Native passes on SQLite, PostgreSQL 17, MySQL 8 and
MariaDB 10.11. No expectation changed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e-adapters The port guide counts 144 served route methods: bridge webhooks add one and cancellation scopes three to the base 140. The workflow test list carries both contract tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e-adapters Kept both sides: delegates, parity contract, probe and workflow test lists carry bridge adapters with Nexus operation reads, and the port guide lists both as served. It counts 146 served route methods: the base 140 plus bridge webhooks, cancellation scopes and Nexus reads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t most duplicateBridgeCommand read every accepted command of the kind for the workflow, with its context, and matched adapter, action, idempotency key and target name in memory, so each bridge signal or update scanned all of a workflow's accepted commands. PHP filters on those JSON paths in SQL and takes the latest. Native now does the same with a per-dialect statement (PostgreSQL ->>, SQLite json_extract, MySQL/MariaDB binary JSON_UNQUOTE(JSON_EXTRACT())), ordered as before and limited to one row. The comparison stays exact and case-sensitive, as the in-memory match was. The bridge-adapters fixture's duplicate and other-adapter cases pass on SQLite, PostgreSQL 17, MySQL 8 and MariaDB 10.11. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Security hold on exact head The route/auth/body-boundary wiring and bound-parameter JSON queries are sound, but this head cannot activate the bridge route yet:
Sequential parity fixtures and green CI cannot cover these races/boundary leaks. Publish a successor SHA/tree and complete delta; do not rerun or merge this head. This is source/merge review only, not deployment authority. |
|
Security follow-up on the internal bridge identity:
These are part of the successor acceptance criteria. |
…e-adapters Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tity Security's review of #776 held four gaps; three are fixed here and the fourth is #775, merged into this branch. - A start for an instance id another namespace holds is refused as unavailable, checked before the type, so neither that instance's type nor its namespace is named. Published PHP names the reserved type; the bridge-adapters fixture bounds that answer as a published defect. - A signal or update is deduplicated inside its own transaction, under the transition lock, instead of by a pool read before it. Each bridge command is recorded with a fixed-length identity as its request id: dw-bridge:v1: and sha256 over the length-prefixed adapter, action, raw key, target field and target name. The lookup is the identity first, then the metadata of commands PHP recorded before takeover. An identity whose command records another tuple fails closed, and the unique (instance, command type, request id) index backs the check. The raw key stays in the command metadata. - The regular signal and update routes, PHP and native, refuse caller request ids in the dw-bridge: namespace, so no caller can claim a bridge identity first. - The adapter name is at most 64 bytes, so a derived workflow id fits 128. The shared PHP defects are Server #777. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e-adapters Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e-adapters Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e-adapters Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Security hold on exact head 867a2d5, tree eb56b2635504fec182ea46815eb043a85999ab1e.\n\nThe successor correctly adds length-prefixed bridge identities, current-ingress prefix reservation, a 64-byte native adapter bound, under-transition-lock duplicate checks, exact accepted-command metadata matching, takeover fallback, and the 60-second wait ceiling. Two takeover/race paths remain blocking:\n\n1. A rejected update already stored under the derived identity is replayed without checking its bridge tuple. bridge_receipt checks metadata only for status=accepted. admit_update then looks up any existing update by the identity, checks only update_name and run_id, and returns the existing rejected receipt. Before this reservation shipped, a regular caller could choose the predictable internal prefix. A preexisting row with the same identity and target name/run but different adapter/key metadata must fail bridge_identity_conflict, not be reused. Verify the exact adapter/action/raw-key/target tuple for every command found under an internal identity, accepted or rejected. Add a seeded pre-takeover rejected-row counterexample and prove 409, no effect, and no receipt reuse on SQLite, PostgreSQL 17, MySQL 8, and MariaDB 10.11.\n\n2. The foreign-namespace start refusal is still time-of-check/time-of-use. Event::start reads instance_reservation outside the transition transaction. A foreign instance inserted after that read is detected under lock by Store::start_in, but that error is propagated as workflow_id_reserved_in_namespace with an explicit another-namespace message instead of the generic bridge unknown_target/unavailable response. Translate the durable under-lock refusal to the same generic bridge envelope, or make the full decision in one transaction. Add a controlled race/counterfactual on every supported database.\n\nSecurity-conflict exits must retain the documented bridge envelope: schema/version, accepted=false, outcome=rejected, reason=bridge_identity_conflict, caller-derived redacted fields only, and no stored metadata or raw error text. The current unhandled RuntimeError::Refused path emits only the generic reason/message object.\n\nKeep the head immutable and unmerged. Send a minimal successor SHA/tree/delta plus exact-head counterfactuals and hosted results. No credential values were used in this review. |
|
Security addendum for exact held head The start-race fix must cover the whole under-lock decision, not only a foreign namespace.
Please add controlled race/counterfactual coverage for foreign namespace, same-namespace different type, and queue-drain state changing between precheck and the serialized start, on SQLite/PostgreSQL/MySQL/MariaDB. The rejected-identity exact-tuple/envelope hold remains unchanged. Separate audit result: declared-validator concurrency adds no hold. |
…e under the lock Security held 867a2d5 on two paths (#776). A command already stored under a bridge identity answered the bridge without its tuple being checked when it was not accepted: a rejected update was replayed, and a rejected signal reached the unique index and came back as a bare error. A caller could have chosen the predictable identity before the prefix reservation. Now every command under an identity, accepted or rejected, must record the same adapter, action, raw key and target, in the identity lookup and in the update's receipt lookup. Anything else answers 409 bridge_identity_conflict in the bridge envelope, with the caller's fields only, from both the in-transaction check and the lost-race exit. A start read the draining gate and the instance reservation outside the transition lock, so a change before the start could leak a foreign namespace, return an instance of another type as a duplicate, or bypass the envelope for a queue that began draining. The store now decides all three under the lock before anything is written (Store::bridge_start), in the bridge's order, and the bridge answers each with the envelope the precheck used to give. Tests seed each preclaimed identity and each start state; a race test runs competing starts through a second pool. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Security review of the published successor commit 34134f5 / tree 829350deaaff4dafde8212fb39e166f6e0862628 (PR head remains held 867a2d5): the exact-tuple checks for accepted and rejected identities, bridge-envelope conflict mapping, and the under-transition-lock start decision close the two held source defects semantically. One evidence gap remains before the PR pointer may move. The added racing_starts_never_leak_a_namespace_or_reuse_another_type loop has no deterministic barrier, accepts either winner, and does not require the competitor to win; the draining case is only pre-seeded, not changed between the old precheck and serialized start. Those tests can pass the held implementation without exercising its TOCTOU. Provide a controlled counterfactual for all three transitions—foreign namespace, same-namespace different type, and queue starts draining—showing held 867a fails and the successor passes on SQLite, PostgreSQL 17, MySQL 8, and MariaDB 10.11. Keep the current PR head immutable until that evidence or a deterministic test successor is ready. Source semantics are otherwise clear; hosted exact-head qualification is still required. |
|
Implementation note for the remaining deterministic counterfactual: Reuse the real cross-backend transition-lock pressure pattern already present in For each foreign-namespace, wrong-type, and drain transition:
On held head Assert the exact bridge result, durable state, and competitor result—do not accept either winner. This provides a real-DB, no-mock old-fails/new-passes proof suitable for SQLite, PostgreSQL 17, MySQL 8, and MariaDB 10.11. |
Security asked for a controlled interleaving (#776). The test holds the real transition lock; a bridge start queues on it, then a second test-held lock, then a competitor: a start of the same id in another namespace, a start of it for another type, or a drain of the bridge's queue. Released, the bridge is granted first, and the second hold keeps the competitor out until any read the bridge made outside the lock has finished. PostgreSQL's advisory lock and InnoDB's row lock grant waiters in order, so there the schedule is forced: a start decided outside the lock meets the competitor's state at its write, and a start decided in one serialized transaction has started its own instance first. SQLite's busy retry has no queue, so there the test asserts only that the bridge is accepted with its own type or answers that state in its envelope. The worker pressure lock holder becomes an owned future both tests use. The opportunistic racing-starts test is replaced. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Security review of exact proof successor One assertion gap remains before clearance. In
Keep the current hold until those assertions are present, exact held/mutant/successor counterfactual outcomes are published, and hosted checks pass on the resulting exact head. The production source semantics remain accepted; this is the final proof completeness gap found in |
… win Where SQLite's busy retry grants the competitor first (#776), the test now asserts the competitor's exact success and the durable state: the foreign start created echo in "other" and the default namespace cannot see it; the other type's start created it; the drain answered draining and the bridge's id was never started. The instance holds only the competitor's command, so the bridge recorded nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
Security final clearance — exact head
Security clears this exact SHA/tree for owner merge. Any head change requires re-review. |
|
Merged as
#777 stays open for the published PHP bridge defects. 🤖 Generated with Claude Code |
…2.14.0 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>



Part of #325. Serves
POST /api/bridge-adapters/webhook/{adapter}like published Server 2.11.1, fixture first.Fixture (
40a55cd5e, recorded from published 2.11.1 three times, identical).bridge-adapterscovers:start_workflow: new, duplicate,reject_duplicate, derived ID, unconfigured type, a type reserved for another workflow, another namespace;signal_workflowandupdate_workflow: unknown, malformed, accepted, deduplicated.One published defect is bounded rather than copied (Server #747). PHP's bridge update calls the control plane directly and skips external update admission, so an update to an SDK-worker workflow is always rejected
workflow_definition_unavailable. The fixture records that answer underpublished_defectsand expects the corrected one.Native (
176a5ef70):durable-workflow.v2.bridge-adapter-outcome.contractenvelope.5877358942) runs in the database: a per-dialect statement filters the command context on adapter, action, idempotency key and target name, as PHP's JSON-path query does, and reads at most one row. It no longer scans every accepted command of the workflow.Fixture correction (
ae97a3dd0). The fixture's worker now declaresupdate_validators: []. Without it, published 2.11.1's regular route answers 409update_validator_contract_missingfor this run, so the expected 202 was not the regular route's answer. With it, the regular route answers 202 and the bridge path still records the #747 defect unchanged. Evidence: #747 comment 6408344149. No expectation changed.Security hold successor (held at
587735894; Security cursor 2097 and rulings at 2105). Commitb480d7f9, with #775 (PR #778) merged in:unknown_targetas "Workflow instance [id] is unavailable.", naming neither the type nor the namespace. Published PHP checks the type first and names it, so the fixture bounds the published answer as a second published defect (Server Bridge adapters: foreign-instance type disclosure, non-atomic dedupe and colliding update keys, unbounded adapter name #777).request_id:dw-bridge:v1:plus sha256 over the length-prefixed adapter, action, raw key, target field and target name. The duplicate check runs inside the command's own transaction, under the transition lock: the identity first, then the metadata of commands PHP recorded before takeover. An identity whose command records another tuple fails closed (409 bridge_identity_conflict). The unique (instance, command type, request id) index backs it, and a write that loses to it is answered only by the exact matching command. The raw key stays in the command metadata. The pool pre-check is gone, so a codec refusal is answered before a duplicate, as in PHP.dw-bridge:(case-insensitive), so no caller can claim a bridge identity first.unsupported_routingbefore anything is persisted.wait_timeout_secondsat most 60).Second Security hold (867a2d5; Security cursors 2187, 2188, 2191 and 2197; PR comments 6414283246 and 6414404528), fixed in
34134f5e:409 bridge_identity_conflictcomes with the schema and version,accepted: false,outcome: rejected, and only the caller's own fields: adapter, action, key, redacted target and correlation. No stored metadata or error text is included. The in-transaction check and the lost-race exit give the same answer.Store::bridge_startdecides all three under the lock before anything is written, in the bridge's order: a draining queue, then an instance another namespace holds, then one reserved for another type. Each gives the envelope the old precheck gave (422task_queue_drainingorunknown_target). So a foreign instance, another type, or a queue that starts draining between a read and the write can no longer leak the namespace, return the other type as a duplicate, or bypass the envelope. The unknown-target status stays 422 because the published 2.11.1 fixture records 422 for it.Tests:
202, seven duplicates naming the same run).dw-bridge:request id is refused.Served routes: the webhook leaves
unserved-php-routes.v2.11.1.txt. With current main merged in, native serves 146 of the 175 published route methods.Bounds. The bridge adds no new storage or limits. Starts, signals and updates go through the same admission paths as the regular routes, and the update wait is bounded by #775.
PHP source.
BridgeAdapterController::UNRESERVED_REQUEST_ID(not_regex:/^\s*dw-bridge:/i) is added to therequest_idrule of the regular signal and update routes inWorkflowController. Nothing else in PHPapp/changes here; the update wait ceiling came with #778.Qualification:
Source head
34134f5e(the second-hold fixes on867a2d53, which merges main68345fa5);bfd0ac6dande29ca324add only tests:bridge-adapters,update-validation,state-updates,visibility-current-runs,one-signal,repeated-signal,signals-before-workerandmessage-streams.bridge_adapters,command_requests,update_validation,update_admission,signal_claimsandstart_request.run_not_active) on SQLite and PostgreSQL.{reason, message}without the envelope.workflow_id_reserved_in_namespace409 on SQLite and PostgreSQL.e29ca324's test file:867a2d53: all three fail on PostgreSQL, MySQL and MariaDB. The foreign case gives a raw 409workflow_id_reserved_in_namespace, the type case a 200 duplicate of the other type, and the drain case a raw 409 draining response.Combined head
e20bb446b(before the second hold), PHP, in the pinned server image:BridgeAdapterControllerTest10,WorkflowControlPlaneTest54,ServiceOperationUpdateWaitTest4 andUpdateWaitConfigurationTest3 pass. The second-hold fixes change no PHP.Successor
23dfe993(before #775's last two commits): PostgreSQL 17, MySQL 8 and MariaDB 10.11 pass 95/95 acrossbridge_adapters,command_requests,update_validation,update_admission,signal_claimsandstart_request.Published PHP: published 2.11.1 records the fixture (with the corrected worker contract) and passes it through its
published_defectsbranch, which bounds both published defects (#747 and #777). Its regular update route answers 202 for the same run.Hosted CI runs at
e29ca324.🤖 Generated with Claude Code