Repository: cloudnative-pg/cloudnative-pg

Issue:
[Feature]: Check for synchronous replication quorum before a failover
### Version

trunk (main)

### What happened?

i'm reviewing the code in `reconcileTargetPrimaryForNonReplicaCluster()` and it might be missing some logic?

i'm thinking automatic promotion as part of self-healing is only safe (ie. no data loss) in conjunction with sync replication. i'm not sure we should fully trust any methodologies based on trying to flush WAL.

*Edit (4-May-2025): can skip this initial design proposal below the line here, and just go straight to [the design proposal in the first comments](https://github.com/cloudnative-pg/cloudnative-pg/issues/7481#issuecomment-2849060324).*

-----

* Quorum-Based: assuming that standbyNamesPre/Post and maxStandbyNamesFromCluster are unset, only consider promoting the first N entries in instancesStatus[], where N is `synchronous.number - unreachable_replicas`.
  * *(if synchronous.number == 0 then we should not auto promote anyone. possibly we should default synchronous.number to `1` on all cnpg clusters with at least one replica? and should `dataDurability` default to `preferred` with 1 replica and `required` with 2+ replicas?)*
  * still need to work out the formula when standbyNamesPre/Post or maxStandbyNamesFromCluster are present
  * still need to think through `preferred` durability a bit more
  * with `synchronous.number=1` even a single unreachable replica would mean no promotion, but i think this is likely the behavior we want? would it make sense to have the default to up to `2` if there are 3+ replicas? i think we always want at least one async replica available for pod disruption budgets during maintenance.
* Priority-Based: only consider promoting entries in instancesStatus[] that are known sync replicas?
  * might be a race condition where an unavailable higher-priority replica has just come back online, and postgres has just demoted the final `sync` replica back to `potential`, but cnpg is not yet aware of this fact. if cnpg promotes the replica that it thought was `sync` then there could be data loss.

this is fairly complex to reason through, so would be good to discuss further

### Code of Conduct

- [x] I agree to follow this project's Code of Conduct

Hints from issue comments:
@dosubot: <!-- Answer -->
The `reconcileTargetPrimaryForNonReplicaCluster()` function currently does not explicitly verify quorum during automatic promotion. It selects the most advanced instance for promotion based on the sorted list of instances, without considering the quorum or synchronous replication settings <sup>[[1]](https://github.com/cloudnative-pg/cloudnative-pg/blob/main/internal/controller/replicas.go)</sup>. 

Your suggestion to incorporate quorum-based and priority-based logic for automatic promotion is valid, especially in scenarios where data consistency and safety are critical. Implementing a check for `synchronous.number` and adjusting the promotion logic based on the number of reachable replicas could enhance the reliability of the self-healing process. Additionally, ensuring that only known synchronous replicas are considered for promotion aligns with best practices for maintaining data integrity.

This approach would require modifications to the current logic to account for synchronous replication settings and potentially defaulting `synchronous.number` to `1` in clusters with replicas, as you suggested. This would prevent automatic promotion when no synchronous replicas are available, reducing the risk of data loss.

Discussing this further with the team and considering these changes could improve the robustness of the automatic promotion mechanism in CloudNativePG.


<!-- Dosu Comment Footer -->
*To continue talking to [Dosu](https://dosu.dev), mention @dosu.*

---
<sup>Help Dosu learn! Give it feedback: [Great Response](https://app.dosu.dev/response-feedback/00e4019d-333c-4f09-9d06-04a7a47ee46f?feedback_type=great_response) | [Irrelevant Answer](https://app.dosu.dev/response-feedback/00e4019d-333c-4f09-9d06-04a7a47ee46f?feedback_type=irrelevant_answer) | [Incorrect Sources](https://app.dosu.dev/response-feedback/00e4019d-333c-4f09-9d06-04a7a47ee46f?feedback_type=incorrect_sources) | [Too Verbose](https://app.dosu.dev/response-feedback/00e4019d-333c-4f09-9d06-04a7a47ee46f?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/00e4019d-333c-4f09-9d06-04a7a47ee46f?feedback_type=hallucination) | [Bug Report](https://app.dosu.dev/response-feedback/00e4019d-333c-4f09-9d06-04a7a47ee46f?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/00e4019d-333c-4f09-9d06-04a7a47ee46f?feedback_type=other)</sup>
@ardentperf: interesting background reading:
* https://github.com/patroni/patroni/issues/664
* https://github.com/patroni/patroni/pull/672
* https://github.com/patroni/patroni/pull/2668
* https://github.com/patroni/patroni/issues/3194
* https://www.binwang.me/2024-12-02-PostgreSQL-High-Availability-Solutions-Part-1.html
@ardentperf: FYI

some interesting patroni code/logic for automated failover with quorum based sync replication is at https://github.com/patroni/patroni/blob/8f22fd255efc5710e4fd1853f12c34abf893abe6/patroni/quorum.py#L55-L64

took me a bit longer to go through the patroni logic for priority repl. from my read of the code right now, i don't think they ever allow a value for `synchronous.number` which is less than the count of entries in `synchronous_standby_names` and i think this is how they avoid the race condition i was concerned about above, but it also results in the following caveat mentioned in the patroni docs:

> When synchronous_mode is on and a standby crashes, commits will block until next iteration of Patroni runs and switches the primary to standalone mode (worst case delay for writes ttl seconds, average case loop_wait/2 seconds).

https://patroni.readthedocs.io/en/latest/replication_modes.html#synchronous-mode

in fact i think the number is always equal to the count except for when there are no entries and patroni->synchronous_mode_strict is enabled, in which case `synchronous.number` is set to `1` to intentionally block writes until a replica becomes available.

key files are patroni/ha.py and patroni/postgresql/sync.py

by default, i think patroni uses a single sync replica (`synchronous_node_count=1`) and the HA cycle which runs every `loop_wait` seconds (default 10) can potentially update `synchronous_standby_names` to a different node on each cycle, though there's logic to reduce churn on this (cf https://patroni.readthedocs.io/en/latest/replication_modes.html#maximum-lag-on-synchronous-node )@ardentperf: i have an idea for cnpg which i think might solve for both quorum and priority replication, with both required and preferred durability. i think this naturally fits into cnpg's code/design and supports its flexibility, while providing a correct & strong guarantee that cnpg will never lose data by promoting an out-of-date replica - even with esoteric combinations of multiple failures on complex distributed topologies.

1. stop including the primary in `synchronous_standby_names`
2. update `reconcileTargetPrimaryForNonReplicaCluster()` to perform a simple quorum check and to only promote a new primary (use existing code; no changes) if the quorum check succeeds 

### Quorum Check Algorithm

the algorithm below is along similar lines to distributed systems theory around quorum based consistency, often expressed something like `R + W > N == strong consistency`.  in our case:
* N = the total group of NODES is the set of entries in `synchronous_standby_names`
* W = the WRITE factor (NUM_SYNCWRITERS) is one of two values: either `synchronous.number` or zero. the zero value effectively blocks promotions during intermediate states with changes to `synchronous_standby_names` (before the number value takes effect and before postgres begins blocking writes with insufficient levels of sync replication)
* R = the READ factor (NUM_HEALTHY) determines how many reachable promotable members we need from `synchronous_standby_names` where we can know the latest applied LSN, to guarantee at least one has the latest writes

cluster configuration state changes need to be handled correctly, this is a tricky part. i've taken a swing at handling them correctly in the design below. Alexander's algorithm in `patroni/quorum.py` is also worth continued study to see if the thinking seems to line up, particularly around postgres internals and catalogs.

**to guarantee safety during state changes or with stale information: N can be too large, but it can never be too small. R and W can be too small, but they can never be too large.**

### Design Specifics

* the quorum check knows the most recently set elements in `synchronous_standby_names`. the value of `synchronous_standby_names` already reflects the effects of `dataDurability=preferred` and `maxStandbyNamesFromCluster` (if either is in effect).
  * store the value of `synchronous_standby_names` - if we can't reach the writer then we'll use the stored value. it is ok if a saved N value is too large but it can't be too small. i think i have these rules right:
    * when decreasing the size, set it on the primary before updating the stored value.
    * when increasing the size, update the stored value before setting it on the primary.
  * any time we change this, first set the stored value of NUM_SYNCWRITERS to zero, make the change, sleep briefly if needed, then update from `pg_stat_replication`. if a primary crashes, we can get a NUM_SYNCWRITERS value that's too small, but we can't get one that's too large.
* for each element of this list corresponding to a local cluster replica instance (do NOT include elements from `standbyNamesPre`/`standbyNamesPost`), increase a counter NUM_HEALTHY if the replica instance has a `PostgresqlStatusList` entry in the `status` variable indicating it's reachable and promotable.
  * the core purpose of NUM_HEALTHY is simple: just tell me the count of replicas where i can know what's the latest commit (LSN) that was applied
  * if a replica can't be promoted for any reason, then we could stop including it in NUM_HEALTHY and re-run reconciliation. this is an easy way to let the operator automatically promote another candidate. since the operator would reevaluate the entire quorum check without the non-promotable replica, it would only choose another replica if it remained safe to do so.
  * NUM_HEALTHY is not a guarantee that it has latest commits, just that it's a "candidate" for having latest commits in the core postgres sync replication code. this doesn't only include `sync_state` of `quorum` and `sync` - but also includes `potential` because postgres can change between `potential` and `sync` by itself. *(though i'm not sure we actually need to explicitly check these values in the algorithm; we can generally just look for all values in `synchronous_standby_names`.)*
* for each element of this list, look at `pg_stat_replication` on the writer and finds its corresponding entry. if `state=streaming` then increase a counter NUM_SYNCWRITERS. once we reach the value of `synchronous.number`, stop checking (don't allow a larger value).
  * the core purpose of NUM_SYNCWRITERS is simple: just tell me the count of replicas guaranteed to have latest commits. the tricky part to navigate is changes to postgres replication config itself - mainly synchronous_standby_names. if a replica drops, postgres blocks writes if it needs to. all we really care about is at what moment does postres start blocking commits if they aren't on NN remote places.
  * given the core purpose, it makes sense to set this variable to either the value of `synchronous.number` or the value zero. use the `pg_stat_replication.state` check to decide when it's safe to move from zero back up to the value of `synchronous.number`.
  * should we continually check `pg_stat_replication.state`?  probably we only need to check this after changes to `synchronous_standby_names` or after promoting a new writer. postgres should block writes if they aren't on enough replicas". i think the key question (and patroni had to solve this too) is "how do you know the moment when the new `synchronous_standby_names` is applied. i think patroni looks at `pg_stat_replication.state` for this; that seems like reasonable logic to me.  continuing to check pg_stat_replication should not be needed once `synchronous_standby_names` is confirmed applied. possibly we only zero it out when first changing cluster config, and stop checking once we're 100% sync repl config is applied.
  * postgres itself probably sends a SIGHUP to all of its backends to tell them to re-read config. i think this is async and i bet you need to know every single connection has re-read config to be sure about the number value in synchronous_standby_names. this may be a case where a simple delay is the best approach, to provide extra safety buffer around postgres backends picking up settings.
* the quorum check succeeds if two conditions are BOTH true: 
  * NUM_HEALTHY + NUM_SYNCWRITERS  > count_items(`synchronous_standby_names`) 
  * NUM_HEALTHY > 0
* if the quorum check succeeds then the already existing code can proceed, which will sort replicas by LSN and promote the most advanced one.

### FAQ

**Don't you need some kind of consensus, voting, or election algorithm to safely choose writers?**

CNPG (already) relies on kubernetes primitives to guarantee that two reconciliation loops cannot run concurrently. Kubernetes itself needs these same guarantees to ensure that only one instance of controller-manager and scheduler are actively running, so these primitives need to be correct for the safety of kubernetes itself. This is a well-trod problem space in the kubernetes community and CloudNativePG's philosophy is to directly integrate kubernetes native consensus and leader election.

A few good sources of more information are https://kubernetes.io/docs/concepts/architecture/leases/ and https://kubernetes.io/docs/concepts/workloads/controllers/replicaset/

More specifically, the CNPG operator relies on controller-runtime which explicitly implements a leader election algorithm based on kubernetes leases. cf https://pkg.go.dev/sigs.k8s.io/controller-runtime/pkg/manager#Options

**What about stale values being used in quorum checks during rolling maintenance or multiple compounding failures?**

This design guarantees safety while avoiding the need for voting, election, or two-phase promotions with the rules about reducing NUM_SYNCWRITERS before any cluster config operations and careful ordering of changes to `synchronous_standby_names`.

The key rule is that N can always be too big and R/W can always be too small.

This simple reasoning guarantees safety during any state change, as long as the code follows the rule.

**What are a few more things we need to think carefully about?**

* after setting `synchronous_standby_names` on a writer, at what exact moment does each individual postgres backend/connection start blocking commits if they aren't confirmed in NN remote places?
* what happens if an old writer re-appears
* when we compare LSNs of replicas we infer that the most up-to-date one is not missing any commits. are we using LSNs from the same point in history? are there any circumstances where it's possible to have write workload still streaming to replicas concurrently with the operator retrieving LSNs to choose a new writer?
* is the LSN that we read from replicas (sent/write/flush/replay) exactly the LSN that's used for postgres COMMIT logic to succeed? does the ordering of this LSN between multiple replicas provably match the ordering with which COMMITs succeeded on the writer? If the writer is unavailable, we rely on LSN ordering to choose the correct replica. (Example: truncate table can slow down replay; as a result the "replay" LSNs could put the replicas in a different order than the "write" LSNs and you could promote the wrong replica, losing data from the most recent commits.)
* how do we monitor the health and responsiveness of the reconciliation loop, and whether it's keeping up with events?
* CNPG has the capability of automatically re-creating replicas after a pod and its volumes are deleted. can this be triggered for a replica when the writer is unhealthy? what happens if the pod and volumes are deleted for a replica that's part of `synchronous_standby_names`? Is any special action needed to make sure NUM_HEALTHY remains an accurate representation of candidates for latest commits/LSNs?
* does the `kubectl cnpg promote` implementation provide a provable guarantee of safety?
* are there any special considerations for choosing a "primary" instance on replica clusters, which utilize postgres cascading replication? (i don't think so?)

**Does this provide the same compromise/caveat as patroni's priority algorithm, where failures might cause writes to hang until the next reconciliation loop?**

This algorithm guarantees safety & durability while supporting a lot of flexibility in allowed configurations.

When configured the same as patroni, the same caveats generally apply. For example, the following configuration might result in postgres suspending writes after sync replica failure until the next operator reconciliation loop:
* dataDurability=preferred and `synchronous.number=1` and a single replica (`instances=2`)
* dataDurability=preferred and maxStandbyNamesFromCluster is unset/default
* dataDurability=required and `maxStandbyNamesFromCluster=synchronous.number`

Patroni's recently added quorum algorithm has an ability to avoid the "suspended writes" caveat. Similarly, CNPG with this design would never have writes hang for NN seconds waiting for a reconciliation loop if:
* `synchronous.number` is less than BOTH the replica count (instance count minus one) and also `maxStandbyNamesFromCluster`
* configurations involving `standbyNamesPre`/`standbyNamesPost` that ensure `synchronous.number` is less than the total count of entries in `synchronous_standby_names` set by CNPG

Generally speaking, writes hang if there aren't enough healthy replica instances, which is a behavior that's easy to understand and reason about.

**How do you get similar behavior to patroni's `synchronous_mode_strict`?**

I think the `placeholderInstanceNameSuffix` logic in `explicitSynchronousStandbyNamesDataDurabilityRequired()` provides the same behavior with `dataDurability=required`, but this should be verified.

**Does this still guarantee safety/durability with complex topologies like sync replica/remote clusters and async local replicas?**

Yes. A couple examples are worked out below.

**Should we reject configurations where automatic replica promotion would never safely be possible as a self-healing mechanism, because it's impossible to ever determine a candidate for promotion that doesn't lose data?**

This would include any configuration with multiple instances and without sync replication configured. It also includes some configurations with remote replica clusters in `standbyNamesPre`/`standbyNamesPost`. In traditional deployments where failovers are always managed manually by admins, these configurations are very common. Given CloudNativePG's tight coupling to kubernetes, I think it's reasonable to reject configurations which cannot support any automatic replica promotion. Whether there should be a way to override this might be an interesting discussion.

Configurations with a single instance (writer) and no replicas at all should not be rejected.

**What if safety/durability means there's no way to automatically promote a replica after a failure? Can an admin manually push a configuration that might cause data loss?**

Even if we block provisioning of configurations that break automatic promotion, it's unavoidable that some failures will lead to this situation.

One obvious example is losing both the writer and a sync reader. There are times where CNPG cannot guarantee/prove that it has a replica with the latest commits. If this happens, there is no safe way to automatically promote.

If we ever get into a state where we can't safely promote a new writer, then we probably need to label it very clearly as a "possible data loss" state - and make sure any manual administrator-executed corrective actions are clearly labeled as "perform_this_action_with_possible_data_loss".

For example, an administrator may decide the data loss risk is acceptable and try to lower the value of `synchronous.number` or remove entries from `standbyNamesPre`/`standbyNamesPost`.

### example 1: complex topology with sync remote replica clusters
one writer with 6 replicas (instances=7) and `synchronous.number=3` and `maxStandbyNamesFromCluster=3` and 2 replica clusters in `standbyNamesPre` and priority replication (`FIRST`) and `dataDurability=required`.

write0 [ pre1 pre2 sync3 sync4 sync5 ] nosync6 nosync7 nosync8 ... `synchronous_standby_names=FIRST 3 [ p1 p2 s3 s4 s5 ]`

NUM_HEALTHY is never more than 3, because [p1 p2] are always unknown.

if any of [s3 s4 s5] is unavailable when a writer fails, then the (NUM_HEALTHY + NUM_SYNCWRITERS > 5) check fails and we cannot safely promote. under a quorum model, the unavailable local instance [s5] could have been the only local instance with the latest commit - the quorum model can't guarantee otherwise. if [nosync8] becomes unavailable then it's fine to promote any local instance with the latest LSN - even if somehow [nosync6] had jumped ahead of [s3 s4 s5]. this shouldn't happen - but reconcileTargetPrimaryForNonReplicaCluster only needs its already-existing simple LSN sort, once the quorum check succeeds.

### example 2: simple two-instance cluster with preferred durability
one writer with 1 replica (instances=2) and `synchronous.number=1` and `dataDurability=preferred`

healthy: write0 [sync1] ... `synchronous_standby_names=[ s1 ]`

when the replica is healthy, it is included in NUM_HEALTHY. the (NUM_HEALTHY + NUM_SYNCWRITERS > 1) check passes and we can promote the replica if the writer fails.

unhealthy: write0 [] nosync1 ... `synchronous_standby_names=[ ]`

when the replica is unhealthy, the ( NUM_HEALTHY > 0 ) check fails and we cannot promote the replica if the writer fails.

### example 3: standard three-instance cluster with a sync remote replica cluster, misconfigured
one writer with 2 replicas (instances=3) and `synchronous.number=1` and one replica cluster in standbyNamesPost and quorum replication (`ANY`) and `dataDurability=required`

write0 [ sync1 sync2 sync3 post4 ] ... `synchronous_standby_names=ANY 1 [ s1 s2 s3 p4 ]`

NUM_HEALTHY is never more than 3, because [p4] is always unknown.  the (NUM_HEALTHY + NUM_SYNCWRITERS > 4) check always fails and this configuration can never safely promote any replicas.

this is correct behavior to avoid data loss, because the replica cluster might be the only instance with the latest writes - we can't guarantee otherwise.

### example 4: standard three-instance cluster without maxStandbyNamesFromCluster, network partition
one writer with 2 replicas (instances=3) and `synchronous.number=1` and `dataDurability=required`

write0 [ sync1 sync2 ] ... `synchronous_standby_names=ANY 1 [ s1 s2 ]` or `FIRST 1 [ s1 s2 ]`

**network partition isolates writer from both replicas:** writer will stop accepting writes and the operator cannot communicate with the writer. NUM_HEALTHY is 2 (replicas).  as long as no cluster reconfigurations were in progress, stored value of NUM_SYNCWRITERS is 1. the (NUM_HEALTHY + NUM_SYNCWRITERS > 2) check passes and we safely promote the replica with the most advanced LSN.

**network partition isolates one replica from [writer and other replica and CNPG operator]:** writer will continue accepting writes and the operator cannot communicate with the single partitioned replica. NUM_HEALTHY is 1 (replica).  NUM_SYNCWRITERS is 1. if a writer failure occurs during the partition, then the (NUM_HEALTHY + NUM_SYNCWRITERS > 2) check fails and we cannot safely promote the replica.

**network partition isolates [writer and one replica] from [CNPG operator and other replica]:** *(nb. this is not possible in a public cloud region with three AZs and three instances and correctly configured anti-affinity.)* writer will continue accepting writes. the operator cannot communicate with the writer. the operator evaluates NUM_HEALTHY to be 1 (replica).  as long as no cluster reconfigurations were in progress, stored value of NUM_SYNCWRITERS is 1. the (NUM_HEALTHY + NUM_SYNCWRITERS > 2) check fails and we cannot safely promote any replicas. this prevents the operator from promoting a replica which might have been missing writes.

*summary: a network partition never causes the writer to pause writes while waiting for a reconciliation loop. however we can't tolerate writer failure during a network partition even with a sync replica that's available & healthy & promotable. it might be possible to fix this with further future enhancements.*

### example 5: standard three-instance cluster with maxStandbyNamesFromCluster set, network partition
one writer with 2 replicas (instances=3) and `synchronous.number=1` and `dataDurability=required` and `maxStandbyNamesFromCluster=1`

write0 [ sync1 ] nonsync2  ... `synchronous_standby_names=[ s1 ]`

**network partition isolates writer from both replicas:** writer will stop accepting writes and the operator cannot communicate with the writer. NUM_HEALTHY is 1 (replica).  as long as no cluster reconfigurations were in progress, stored value of NUM_SYNCWRITERS is 1. the (NUM_HEALTHY + NUM_SYNCWRITERS > 1) check passes and we safely promote the replica with the most advanced LSN.

**network partition isolates sync replica from [writer and nonsync replica and CNPG operator]:** writer will stop accepting writes. the operator cannot communicate with the partitioned sync replica. in the next reconciliation cycle, operator's already-existing code will detect sync1 is unhealthy and will reconfigure `synchronous_standby_names=[ s2 ]` (changing the second replica from nonsync to sync). after the reconfiguration, the writer will begin accepting writes again. if a writer failure occurs before this reconfig, then the (NUM_HEALTHY + NUM_SYNCWRITERS > 1) check fails.  if a writer failure occurs after this reconfig, then the (NUM_HEALTHY + NUM_SYNCWRITERS > 1) check succeeds and we can promote.

**network partition isolates nonsync replica from [writer and sync replica and CNPG operator]:** writer will continue accepting writes. the operator cannot communicate with the partitioned nonsync replica. if a writer failure occurs, then the (NUM_HEALTHY + NUM_SYNCWRITERS > 1) check succeeds and the sync replica can be promoted.

**network partition isolates [writer and sync replica] from [CNPG operator and nonsync replica]:** *(nb. this is not possible in a public cloud region with three AZs and three instances and correctly configured anti-affinity.)* writer will continue accepting writes. the operator cannot communicate with the writer. the operator evaluates NUM_HEALTHY to be 0 (replicas).  as long as no cluster reconfigurations were in progress, stored value of NUM_SYNCWRITERS is 1. the (NUM_HEALTHY + NUM_SYNCWRITERS > 1) check fails and we cannot safely promote any replicas. this prevents the operator from promoting the replica which might have been missing writes.

**network partition isolates [writer and nonsync replica] from [CNPG operator and sync replica]:** *(nb. this is not possible in a public cloud region with three AZs and three instances and correctly configured anti-affinity.)* writer will stop accepting writes. the operator cannot communicate with the writer. the operator evaluates NUM_HEALTHY to be 1 (replica).  as long as no cluster reconfigurations were in progress, stored value of NUM_SYNCWRITERS is 1. the (NUM_HEALTHY + NUM_SYNCWRITERS > 1) check succeeds and the sync replica can be promoted.

*summary: a network partition sometimes causes writers to pause writes while waiting for a reconciliation loop, but we can handle writer failure during a network partition if a sync replica is available & healthy & promotable.*

### example 6: three-instance local cluster with quorum sync to two remote clusters in proximate (low latency) data centers, federated k8s
one writer with 2 replicas (instances=3) and two replica clusters in `standbyNamesPost` and quorum replication (`ANY`) and `synchronous.number=3` and `dataDurability=required`

write0 [ sync1 sync2 post3 post4 ]  ... `synchronous_standby_names=ANY 3 [ s1 s2 p3 p4 ]`

NUM_HEALTHY is never more than 2, because [p3 p4] are always unknown. if the writer fails, the (NUM_HEALTHY + NUM_SYNCWRITERS > 4) check passes and we can promote a new writer. if any of [s1 s2] fails, then writes are temporarily synchronous to both external clusters, and we can still promote a replica if there's a writer failure.

the two remote clusters might be able to be single-instance clusters with no replicas (as long as this doesn't disrupt maintenance operations) for a 3-1-1 topology. if a switchover is ever needed (demoting the primary cluster), instance counts and configurations can be adjusted at that time.

optionally, a third replica could be considered on the local cluster along with setting `maxStandbyNamesFromCluster=2` for a 4-1-1 topology. in this case `synchronous_standby_names` would be set identically to above - however if any of [s1 s2] fails, then CNPG operator will remove the unhealthy replica from `synchronous_standby_names` in the next reconciliation loop and add nonsync5 to `synchronous_standby_names`.

*summary: as illustrated in example 3, the value of `synchronous.number` must be greater than the total number of entries in `standbyNamesPre` and `standbyNamesPost`. otherwise we can never safely promote a new writer in the local cluster.*@ardentperf: *nb. using this (technically quorum-based) approach even for the priority config would have the advantage of avoiding the race problem i mentioned upthread around an instance becoming healthy before cnpg could detect it, and also the advantage that the whole durability model remains as a single unified approach that's easier to reason about*@ardentperf: think i figured out how to handle state changes

> synchronous.number is the WRITE factor

we just need to make sure not to overcalculate the WRITE factor.  at any given time, the value we use for WRITE needs to be <= the actual number of synchronous replicas that the postgres database has.

i think we just reduce this whenever we do any kind of cluster operation that we know will trigger a change in physical replication, and we only increase it by pulling proof from `pg_stat_replication` that replication is healthy and synchronous.

i'm going to edit the design proposal above (again <g>) and change literal `synchronous.number` to be NUM_SYNCWRITERS which reflects a minimum known value (the real number of sync writers can be higher but not lower)@ardentperf: chatted through the algorithm with @leonardoce on slack yesterday. we're currently thinking that counting healthy streaming replicas is not part of W (i also edited the design comment above last night after this discussion). all that matters for W is the fact that postgres will block COMMIT for any transaction which is not confirmed on W replicas; as long as COMMIT will be blocked then we know we can't lose data. (W is zero during state changes.)

@leonardoce sketched out this updated basic algorithm on a white board today, based on our discussion yesterday. I believe this is correct:

> R + W > N  ⇔  strong consistency
> 
> N  =  | sync_standby_names |
> W  =  sync_number OR ∅  {when we change something}
> R  =  | sync_standby_names ∩ promotable_replicas |
> 
> sync_standby_names does not include the primary
@gbartolini: In my opinion, this is more of a feature request than a bug. Other opinions?@fcanovai: @gbartolini I'm of the same opinion. And the linked PR is already a feat.@leonardoce: It's a feature for me, too. My PR already had the `feat:` tag :)@mnencia: I agree that this is a feature.@gbartolini: Given that we are still missing documentation and E2E tests, I suggest that we add this feature as experimental in 1.27 through an annotation (instead of an option in the CRD), and target 1.28 for the inclusion. This will allow us to get feedback from public, preferably in chaos engineering driven environments. Thoughts?@mnencia: To clarify that this is an experimental feature, we decided to use an annotation instead of a field in the CRD to enable it.@gbartolini: Bummer, we should have used the `alpha.cnpg.io/failoverQuorum` annotation.

Solution patch:
diff --git a/.wordlist-en-custom.txt b/.wordlist-en-custom.txt
index be82ff8c..64c796fa 100644
--- a/.wordlist-en-custom.txt
+++ b/.wordlist-en-custom.txt
@@ -166,6 +166,9 @@ ExtensionStatus
 ExternalCluster
 FQDN
 FQDNs
+FailoverQuorum
+FailoverQuorumSpec
+FailoverQuorumStatus
 Fei
 Filesystem
 Fluentd
@@ -371,6 +374,7 @@ ProbeTerminationGracePeriod
 ProbeWithStrategy
 ProbesConfiguration
 ProjectedVolumeSource
+Promotable
 PublicationReclaimPolicy
 PublicationSpec
 PublicationStatus
@@ -383,6 +387,7 @@ PushSecret
 QoS
 Quaresima
 QuickStart
+QuorumFailoverProtection
 RBAC
 README
 RHSA
@@ -470,6 +475,8 @@ SnapshotType
 Snapshotting
 Snyk
 Stackgres
+StandbyNames
+StandbyNumber
 StartupProbe
 StartupStrategyType
 StatefulSets
@@ -487,6 +494,7 @@ SynchronizeReplicas
 SynchronizeReplicasConfiguration
 SynchronousReplicaConfiguration
 SynchronousReplicaConfigurationMethod
+SynchronousStandbyNamesList
 Synopsys
 SystemID
 TCP
@@ -1167,6 +1175,7 @@ programmatically
 proj
 projectedVolumeTemplate
 prometheus
+promotable
 promotionTimeout
 promotionToken
 provisioner
@@ -1182,6 +1191,7 @@ pvcTemplate
 quantile
 queryable
 quickstart
+quorumFailoverProtection
 rbac
 rc
 readService
@@ -1308,8 +1318,10 @@ sslkey
 sslmode
 sslrootcert
 sso
+standbyNames
 standbyNamesPost
 standbyNamesPre
+standbyNumber
 startDelay
 startedAt
 stateful
diff --git a/PROJECT b/PROJECT
index 27b49f0b..9f0b527b 100644
--- a/PROJECT
+++ b/PROJECT
@@ -84,3 +84,12 @@ resources:
   kind: Subscription
   path: github.com/cloudnative-pg/cloudnative-pg/api/v1
   version: v1
+- api:
+    crdVersion: v1
+    namespaced: true
+  controller: true
+  domain: cnpg.io
+  group: postgresql
+  kind: FailoverQuorum
+  path: github.com/cloudnative-pg/cloudnative-pg/api/v1
+  version: v1
diff --git a/api/v1/cluster_funcs.go b/api/v1/cluster_funcs.go
index 8c1f7aa3..3ffecd58 100644
--- a/api/v1/cluster_funcs.go
+++ b/api/v1/cluster_funcs.go
@@ -24,6 +24,7 @@ import (
 	"fmt"
 	"regexp"
 	"slices"
+	"strconv"
 	"strings"
 	"time"
 
@@ -1560,3 +1561,19 @@ func (cluster *Cluster) GetEnabledWALArchivePluginName() string {
 
 	return ""
 }
+
+// IsFailoverQuorumActive check if we should enable the
+// quorum failover protection alpha-feature.
+func (cluster *Cluster) IsFailoverQuorumActive() (bool, error) {
+	failoverQuorumAnnotation, ok := cluster.GetAnnotations()[utils.FailoverQuorumAnnotationName]
+	if !ok || failoverQuorumAnnotation == "" {
+		return false, nil
+	}
+
+	v, err := strconv.ParseBool(failoverQuorumAnnotation)
+	if err != nil {
+		return false, fmt.Errorf("failed to parse failover quorum annotation '%v': %v", failoverQuorumAnnotation, err)
+	}
+
+	return v, nil
+}
diff --git a/api/v1/failoverquorum_types.go b/api/v1/failoverquorum_types.go
new file mode 100644
index 00000000..afa55cd0
--- /dev/null
+++ b/api/v1/failoverquorum_types.go
@@ -0,0 +1,83 @@
+/*
+Copyright © contributors to CloudNativePG, established as
+CloudNativePG a Series of LF Projects, LLC.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+SPDX-License-Identifier: Apache-2.0
+*/
+
+package v1
+
+import (
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// +kubebuilder:object:root=true
+
+// FailoverQuorumList contains a list of FailoverQuorum
+type FailoverQuorumList struct {
+	metav1.TypeMeta `json:",inline"`
+	// Standard list metadata.
+	// More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+	// +optional
+	metav1.ListMeta `json:"metadata,omitempty"`
+	// List of failoverquorums
+	Items []FailoverQuorum `json:"items"`
+}
+
+// +genclient
+// +kubebuilder:object:root=true
+// +kubebuilder:storageversion
+// +kubebuilder:subresource:status
+// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp"
+
+// FailoverQuorum contains the information about the current failover
+// quorum status of a PG cluster. It is updated by the instance manager
+// of the primary node and reset to zero by the operator to trigger
+// an update.
+type FailoverQuorum struct {
+	metav1.TypeMeta   `json:",inline"`
+	metav1.ObjectMeta `json:"metadata"`
+
+	// Most recently observed status of the failover quorum.
+	// +optional
+	Status FailoverQuorumStatus `json:"status"`
+}
+
+// FailoverQuorumStatus is the latest observed status of the failover
+// quorum of the PG cluster.
+type FailoverQuorumStatus struct {
+	// Contains the latest reported Method value.
+	// +optional
+	Method string `json:"method,omitempty"`
+
+	// StandbyNames is the list of potentially synchronous
+	// instance names.
+	// +optional
+	StandbyNames []string `json:"standbyNames,omitempty"`
+
+	// StandbyNumber is the number of synchronous standbys that transactions
+	// need to wait for replies from.
+	// +optional
+	StandbyNumber int `json:"standbyNumber,omitempty"`
+
+	// Primary is the name of the primary instance that updated
+	// this object the latest time.
+	// +optional
+	Primary string `json:"primary,omitempty"`
+}
+
+func init() {
+	SchemeBuilder.Register(&FailoverQuorum{}, &FailoverQuorumList{})
+}
diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go
index c6805684..2d601693 100644
--- a/api/v1/zz_generated.deepcopy.go
+++ b/api/v1/zz_generated.deepcopy.go
@@ -1361,6 +1361,84 @@ func (in *ExternalCluster) DeepCopy() *ExternalCluster {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *FailoverQuorum) DeepCopyInto(out *FailoverQuorum) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+	in.Status.DeepCopyInto(&out.Status)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FailoverQuorum.
+func (in *FailoverQuorum) DeepCopy() *FailoverQuorum {
+	if in == nil {
+		return nil
+	}
+	out := new(FailoverQuorum)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *FailoverQuorum) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *FailoverQuorumList) DeepCopyInto(out *FailoverQuorumList) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ListMeta.DeepCopyInto(&out.ListMeta)
+	if in.Items != nil {
+		in, out := &in.Items, &out.Items
+		*out = make([]FailoverQuorum, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FailoverQuorumList.
+func (in *FailoverQuorumList) DeepCopy() *FailoverQuorumList {
+	if in == nil {
+		return nil
+	}
+	out := new(FailoverQuorumList)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *FailoverQuorumList) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *FailoverQuorumStatus) DeepCopyInto(out *FailoverQuorumStatus) {
+	*out = *in
+	if in.StandbyNames != nil {
+		in, out := &in.StandbyNames, &out.StandbyNames
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FailoverQuorumStatus.
+func (in *FailoverQuorumStatus) DeepCopy() *FailoverQuorumStatus {
+	if in == nil {
+		return nil
+	}
+	out := new(FailoverQuorumStatus)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *ImageCatalog) DeepCopyInto(out *ImageCatalog) {
 	*out = *in
diff --git a/config/crd/bases/postgresql.cnpg.io_failoverquorums.yaml b/config/crd/bases/postgresql.cnpg.io_failoverquorums.yaml
new file mode 100644
index 00000000..91da8c58
--- /dev/null
+++ b/config/crd/bases/postgresql.cnpg.io_failoverquorums.yaml
@@ -0,0 +1,77 @@
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.18.0
+  name: failoverquorums.postgresql.cnpg.io
+spec:
+  group: postgresql.cnpg.io
+  names:
+    kind: FailoverQuorum
+    listKind: FailoverQuorumList
+    plural: failoverquorums
+    singular: failoverquorum
+  scope: Namespaced
+  versions:
+  - additionalPrinterColumns:
+    - jsonPath: .metadata.creationTimestamp
+      name: Age
+      type: date
+    name: v1
+    schema:
+      openAPIV3Schema:
+        description: |-
+          FailoverQuorum contains the information about the current failover
+          quorum status of a PG cluster. It is updated by the instance manager
+          of the primary node and reset to zero by the operator to trigger
+          an update.
+        properties:
+          apiVersion:
+            description: |-
+              APIVersion defines the versioned schema of this representation of an object.
+              Servers should convert recognized schemas to the latest internal value, and
+              may reject unrecognized values.
+              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
+            type: string
+          kind:
+            description: |-
+              Kind is a string value representing the REST resource this object represents.
+              Servers may infer this from the endpoint the client submits requests to.
+              Cannot be updated.
+              In CamelCase.
+              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+            type: string
+          metadata:
+            type: object
+          status:
+            description: Most recently observed status of the failover quorum.
+            properties:
+              method:
+                description: Contains the latest reported Method value.
+                type: string
+              primary:
+                description: |-
+                  Primary is the name of the primary instance that updated
+                  this object the latest time.
+                type: string
+              standbyNames:
+                description: |-
+                  StandbyNames is the list of potentially synchronous
+                  instance names.
+                items:
+                  type: string
+                type: array
+              standbyNumber:
+                description: |-
+                  StandbyNumber is the number of synchronous standbys that transactions
+                  need to wait for replies from.
+                type: integer
+            type: object
+        required:
+        - metadata
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}
diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml
index 6100960f..ec827bc3 100644
--- a/config/crd/kustomization.yaml
+++ b/config/crd/kustomization.yaml
@@ -13,6 +13,7 @@ resources:
 - bases/postgresql.cnpg.io_databases.yaml
 - bases/postgresql.cnpg.io_publications.yaml
 - bases/postgresql.cnpg.io_subscriptions.yaml
+- bases/postgresql.cnpg.io_failoverquorums.yaml
 
 # +kubebuilder:scaffold:crdkustomizeresource
 patches:
diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml
index f47a568f..7f6a70c7 100644
--- a/config/rbac/role.yaml
+++ b/config/rbac/role.yaml
@@ -184,12 +184,23 @@ rules:
   - postgresql.cnpg.io
   resources:
   - clusters/status
+  - failoverquorums/status
   - poolers/status
   verbs:
   - get
   - patch
   - update
   - watch
+- apiGroups:
+  - postgresql.cnpg.io
+  resources:
+  - failoverquorums
+  verbs:
+  - create
+  - delete
+  - get
+  - list
+  - watch
 - apiGroups:
   - rbac.authorization.k8s.io
   resources:
diff --git a/docs/src/cloudnative-pg.v1.md b/docs/src/cloudnative-pg.v1.md
index 92ca69e4..6ceff12c 100644
--- a/docs/src/cloudnative-pg.v1.md
+++ b/docs/src/cloudnative-pg.v1.md
@@ -11,6 +11,7 @@
 - [Cluster](#postgresql-cnpg-io-v1-Cluster)
 - [ClusterImageCatalog](#postgresql-cnpg-io-v1-ClusterImageCatalog)
 - [Database](#postgresql-cnpg-io-v1-Database)
+- [FailoverQuorum](#postgresql-cnpg-io-v1-FailoverQuorum)
 - [ImageCatalog](#postgresql-cnpg-io-v1-ImageCatalog)
 - [Pooler](#postgresql-cnpg-io-v1-Pooler)
 - [Publication](#postgresql-cnpg-io-v1-Publication)
@@ -160,6 +161,40 @@ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-
 </tbody>
 </table>
 
+## FailoverQuorum     {#postgresql-cnpg-io-v1-FailoverQuorum}
+
+
+**Appears in:**
+
+
+
+<p>FailoverQuorum contains the information about the current failover
+quorum status of a PG cluster. It is updated by the instance manager
+of the primary node and reset to zero by the operator to trigger
+an update.</p>
+
+
+<table class="table">
+<thead><tr><th width="30%">Field</th><th>Description</th></tr></thead>
+<tbody>
+<tr><td><code>apiVersion</code> <B>[Required]</B><br/>string</td><td><code>postgresql.cnpg.io/v1</code></td></tr>
+<tr><td><code>kind</code> <B>[Required]</B><br/>string</td><td><code>FailoverQuorum</code></td></tr>
+<tr><td><code>metadata</code> <B>[Required]</B><br/>
+<a href="https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.28/#objectmeta-v1-meta"><i>meta/v1.ObjectMeta</i></a>
+</td>
+<td>
+   <span class="text-muted">No description provided.</span>Refer to the Kubernetes API documentation for the fields of the <code>metadata</code> field.</td>
+</tr>
+<tr><td><code>status</code><br/>
+<a href="#postgresql-cnpg-io-v1-FailoverQuorumStatus"><i>FailoverQuorumStatus</i></a>
+</td>
+<td>
+   <p>Most recently observed status of the failover quorum.</p>
+</td>
+</tr>
+</tbody>
+</table>
+
 ## ImageCatalog     {#postgresql-cnpg-io-v1-ImageCatalog}
 
 
@@ -3008,6 +3043,55 @@ of WAL archiving and backups for this external cluster</p>
 </tbody>
 </table>
 
+## FailoverQuorumStatus     {#postgresql-cnpg-io-v1-FailoverQuorumStatus}
+
+
+**Appears in:**
+
+- [FailoverQuorum](#postgresql-cnpg-io-v1-FailoverQuorum)
+
+
+<p>FailoverQuorumStatus is the latest observed status of the failover
+quorum of the PG cluster.</p>
+
+
+<table class="table">
+<thead><tr><th width="30%">Field</th><th>Description</th></tr></thead>
+<tbody>
+<tr><td><code>method</code><br/>
+<i>string</i>
+</td>
+<td>
+   <p>Contains the latest reported Method value.</p>
+</td>
+</tr>
+<tr><td><code>standbyNames</code><br/>
+<i>[]string</i>
+</td>
+<td>
+   <p>StandbyNames is the list of potentially synchronous
+instance names.</p>
+</td>
+</tr>
+<tr><td><code>standbyNumber</code><br/>
+<i>int</i>
+</td>
+<td>
+   <p>StandbyNumber is the number of synchronous standbys that transactions
+need to wait for replies from.</p>
+</td>
+</tr>
+<tr><td><code>primary</code><br/>
+<i>string</i>
+</td>
+<td>
+   <p>Primary is the name of the primary instance that updated
+this object the latest time.</p>
+</td>
+</tr>
+</tbody>
+</table>
+
 ## ImageCatalogRef     {#postgresql-cnpg-io-v1-ImageCatalogRef}
 
 
diff --git a/docs/src/failover.md b/docs/src/failover.md
index e28da3d9..1c843ed6 100644
--- a/docs/src/failover.md
+++ b/docs/src/failover.md
@@ -92,3 +92,262 @@ expected outage.
 
 Enabling a new configuration option to delay failover provides a mechanism to
 prevent premature failover for short-lived network or node instability.
+
+## Failover Quorum (Quorum-based Failover)
+
+!!! Warning
+    *Failover quorum* is an experimental feature introduced in version 1.27.0.
+    Use with caution in production environments.
+
+Failover quorum is a mechanism that enhances data durability and safety during
+failover events in CloudNativePG-managed PostgreSQL clusters.
+
+Quorum-based failover allows the controller to determine whether to promote a replica
+to primary based on the state of a quorum of replicas.
+This is useful when stronger data durability is required than the one offered
+by [synchronous replication](replication.md#synchronous-replication) and
+default automated failover procedures.
+
+When synchronous replication is not enabled, some data loss is expected and
+accepted during failover, as a replica may lag behind the primary when
+promoted.
+
+With synchronous replication enabled, the guarantee is that the application
+will not receive explicit acknowledgment of the successful commit of a
+transaction until the WAL data is known to be safely received by all required
+synchronous standbys.
+This is not enough to guarantee that the operator is able to promote the most
+advanced replica.
+
+For example, in a three-node cluster with synchronous replication set to `ANY 1
+(...)`, data is written to the primary and one standby before a commit is
+acknowledged. If both the primary and the aligned standby become unavailable
+(such as during a network partition), the remaining replica may not have the
+latest data. Promoting it could lose some data that the application considered
+committed.
+
+Quorum-based failover addresses this risk by ensuring that failover only occurs
+if the operator can confirm the presence of all synchronously committed data in
+the instance to promote, and it does not occur otherwise.
+
+This feature allows users to choose their preferred trade-off between data
+durability and data availability.
+
+Failover quorum can be enabled by setting the annotation
+`cnpg.io/failoverQuorum="true"` in the `Cluster` resource.
+
+!!! info
+    When this feature is out of the experimental phase, the annotation
+    `cnpg.io/failoverQuorum` will be replaced by a configuration option in the
+    `Cluster` resource.
+
+### How it works
+
+Before promoting a replica to primary, the operator performs a quorum check,
+following the principles of the Dynamo `R + W > N` consistency model[^1].
+
+In the quorum failover, these values assume the following meaning:
+
+- `R` is the number of *promotable replicas* (read quorum);
+- `W` is the number of replicas that must acknowledge the write before the
+  `COMMIT` is returned to the client (write quorum);
+- `N` is the total number of potentially synchronous replicas;
+
+*Promotable replicas* are replicas that have these properties:
+
+  - are part of the cluster;
+  - are able to report their state to the operator;
+  - are potentially synchronous;
+
+If `R + W > N`, then we can be sure that among the promotable replicas there is
+at least one that has confirmed all the synchronous commits, and we can safely
+promote it to primary. If this is not the case, the controller will not promote
+any replica to primary, and will wait for the situation to change.
+
+Users can force a promotion of a replica to primary through the
+`kubectl cnpg promote` command even if the quorum check is failing.
+
+!!! Warning
+    Manual promotion should only be used as a last resort. Before proceeding,
+    make sure you fully understand the risk of data loss and carefully consider the
+    consequences of prioritizing the resumption of write workloads for your
+    applications.
+
+An additional CRD is used to track the quorum state of the cluster. A `Cluster`
+with the quorum failover enabled will have a `FailoverQuorum` resource with the same
+name as the `Cluster` resource. The `FailoverQuorum` CR is created by the
+controller when the quorum failover is enabled, and it is updated by the primary
+instance during its reconciliation loop, and read by the operator during quorum
+checks. It is used to track the latest known configuration of the synchronous
+replication.
+
+!!! Important
+    Users should not modify the `FailoverQuorum` resource directly. During
+    PostgreSQL configuration changes, when it is not possible to determine the
+    configuration, the `FailoverQuorum` resource will be reset, preventing any
+    failover until the new configuration is applied.
+
+The `FailoverQuorum` resource works in conjunction with PostgreSQL synchronous
+replication.
+
+!!! Warning
+    There is no guarantee that `COMMIT` operations returned to the
+    client but that have not been performed synchronously, such as those made
+    explicitly disabling synchronous replication with
+    `SET synchronous_commit TO local`, will be present on a promoted replica.
+
+### Quorum Failover Example Scenarios
+
+In the following scenarios, `R` is the number of promotable replicas, `W` is
+the number of replicas that must acknowledge a write before commit, and `N` is
+the total number of potentially synchronous replicas. The "Failover" column
+indicates whether failover is allowed under quorum failover rules.
+
+#### Scenario 1: Three-node cluster, failing pod(s)
+
+A cluster with `instances: 3`, `synchronous.number=1`, and
+`dataDurability=required`.
+
+- If only the primary fails, two promotable replicas remain (R=2).
+  Since `R + W > N` (2 + 1 > 2), failover is allowed and safe.
+- If both the primary and one replica fail, only one promotable replica
+  remains (R=1). Since `R + W = N` (1 + 1 = 2), failover is not allowed to
+  prevent possible data loss.
+
+| R | W | N | Failover |
+|:-:|:-:|:-:|:--------:|
+| 2 | 1 | 2 | ✅       |
+| 1 | 1 | 2 | ❌       |
+
+#### Scenario 2: Three-node cluster, network partition
+
+A cluster with `instances: 3`, `synchronous.number: 1`, and
+`dataDurability: required` experiences a network partition.
+
+- If the operator can communicate with the primary, no failover occurs. The
+  cluster can be impacted if the primary cannot reach any standby, since it
+  won't commit transactions due to synchronous replication requirements.
+- If the operator cannot reach the primary but can reach both replicas (R=2),
+  failover is allowed. If the operator can reach only one replica (R=1),
+  failover is not allowed, as the synchronous one may be the other one.
+
+| R | W | N | Failover |
+|:-:|:-:|:-:|:--------:|
+| 2 | 1 | 2 | ✅       |
+| 1 | 1 | 2 | ❌       |
+
+#### Scenario 3: Five-node cluster, network partition
+
+A cluster with `instances: 5`, `synchronous.number=2`, and
+`dataDurability=required` experiences a network partition.
+
+- If the operator can communicate with the primary, no failover occurs. The
+  cluster can be impacted if the primary cannot reach at least two standbys,
+  as since it won't commit transactions due to synchronous replication
+  requirements.
+- If the operator cannot reach the primary but can reach at least three
+  replicas (R=3), failover is allowed. If the operator can reach only two
+  replicas (R=2), failover is not allowed, as the synchronous one may be the
+  other one.
+
+| R | W | N | Failover |
+|:-:|:-:|:-:|:--------:|
+| 3 | 2 | 4 | ✅       |
+| 2 | 2 | 4 | ❌       |
+
+#### Scenario 4: Three-node cluster with remote synchronous replicas
+
+A cluster with `instances: 3` and remote synchronous replicas defined in
+`standbyNamesPre` or `standbyNamesPost`. We assume that the primary is failing.
+
+This scenario requires an important consideration. Replicas listed in
+`standbyNamesPre` or `standbyNamesPost` are not counted in
+`R` (they cannot be promoted), but are included in `N` (they may have received
+synchronous writes). So, if
+`synchronous.number <= len(standbyNamesPre) + len(standbyNamesPost)`, failover
+is not possible, as no local replica can be guaranteed to have the required
+data. The operator prevents such configurations during validation, but some
+invalid configurations are shown below for clarity.
+
+**Example configurations:**
+
+Configuration #1 (valid):
+```yaml
+instances: 3
+postgresql:
+  synchronous:
+    method: any
+    number: 2
+    standbyNamesPre:
+      - angus
+```
+In this configuration, when the primary fails, `R = 2` (the local replicas),
+`W = 2`, and `N = 3` (2 local replicas + 1 remote), allowing failover.
+In case of an additional replica failing (`R = 1`) failover is not allowed.
+
+| R | W | N | Failover |
+|:-:|:-:|:-:|:--------:|
+| 3 | 2 | 4 | ✅       |
+| 2 | 2 | 4 | ❌       |
+
+Configuration #2 (invalid):
+```yaml
+instances: 3
+postgresql:
+  synchronous:
+    method: any
+    number: 1
+    maxStandbyNamesFromCluster: 1
+    standbyNamesPre:
+      - angus
+```
+In this configuration, `R = 2` (the local replicas), `W = 1`, and `N = 3`
+(2 local replicas + 1 remote).
+Failover is not possible in this setup, so quorum failover can not be
+enabled with this configuration.
+
+| R | W | N | Failover |
+|:-:|:-:|:-:|:--------:|
+| 1 | 1 | 2 | ❌       |
+
+Configuration #3 (invalid):
+```yaml
+instances: 3
+postgresql:
+  synchronous:
+    method: any
+    number: 1
+    maxStandbyNamesFromCluster: 0
+    standbyNamesPre:
+      - angus
+      - malcolm
+```
+In this configuration, `R = 0` (the local replicas), `W = 1`, and `N = 2`
+(0 local replicas + 2 remote).
+Failover is not possible in this setup, so quorum failover can not be
+enabled with this configuration.
+
+| R | W | N | Failover |
+|:-:|:-:|:-:|:--------:|
+| 0 | 1 | 2 | ❌       |
+
+#### Scenario 5: Three-node cluster, preferred data durability, network partition
+
+Consider a cluster with `instances: 3`, `synchronous.number=1`, and
+`dataDurability=preferred` that experiences a network partition.
+
+- If the operator can communicate with both the primary and the API server,
+  the primary continues to operate, removing unreachable standbys from the
+  `synchronous_standby_names` set.
+- If the primary cannot reach the operator or API server, a quorum check is
+  performed. The `FailoverQuorum` status cannot have changed, as the primary cannot
+  have received new configuration. If the operator can reach both replicas,
+  failover is allowed (`R=2`). If only one replica is reachable (`R=1`),
+  failover is not allowed.
+
+| R | W | N | Failover |
+|:-:|:-:|:-:|:--------:|
+| 2 | 1 | 2 | ✅       |
+| 1 | 1 | 2 | ❌       |
+
+[^1]: [Dynamo: Amazon’s highly available key-value store](https://www.amazon.science/publications/dynamo-amazons-highly-available-key-value-store)
diff --git a/docs/src/replication.md b/docs/src/replication.md
index 09503efb..c4eb01e4 100644
--- a/docs/src/replication.md
+++ b/docs/src/replication.md
@@ -121,6 +121,11 @@ CloudNativePG supports both
     details on managing this behavior, refer to the [Data Durability and Synchronous Replication](#data-durability-and-synchronous-replication)
     section.
 
+!!! Important
+    The [*failover quorum* feature](failover.md#failover-quorum-quorum-based-failover) (experimental)
+    can be used alongside synchronous replication to improve data durability
+    and safety during failover events.
+
 Direct configuration of the `synchronous_standby_names` option is not
 permitted. However, CloudNativePG automatically populates this option with the
 names of local pods, while also allowing customization to extend synchronous
diff --git a/docs/src/samples/cluster-example-syncreplicas-quorum.yaml b/docs/src/samples/cluster-example-syncreplicas-quorum.yaml
new file mode 100644
index 00000000..1836d2ed
--- /dev/null
+++ b/docs/src/samples/cluster-example-syncreplicas-quorum.yaml
@@ -0,0 +1,17 @@
+apiVersion: postgresql.cnpg.io/v1
+kind: Cluster
+metadata:
+  name: cluster-example
+  annotations:
+    cnpg.io/failoverQuorum: "t"
+spec:
+  instances: 3
+
+  postgresql:
+    synchronous:
+      method: any
+      number: 1
+
+  storage:
+    size: 1G
+
diff --git a/internal/cmd/manager/instance/run/cmd.go b/internal/cmd/manager/instance/run/cmd.go
index a34f9410..758c5348 100644
--- a/internal/cmd/manager/instance/run/cmd.go
+++ b/internal/cmd/manager/instance/run/cmd.go
@@ -194,6 +194,9 @@ func runSubCommand(ctx context.Context, instance *postgres.Instance) error {
 					// we don't have the permissions to cache backups, as the ServiceAccount
 					// doesn't have watch permission on the backup status
 					&apiv1.Backup{},
+					// we don't have the permissions to cache FailoverQuorum objects, we can
+					// only access the object having the same name as the cluster
+					&apiv1.FailoverQuorum{},
 				},
 			},
 		},
diff --git a/internal/controller/cluster_controller.go b/internal/controller/cluster_controller.go
index 9f2fba3d..57cd107c 100644
--- a/internal/controller/cluster_controller.go
+++ b/internal/controller/cluster_controller.go
@@ -145,6 +145,8 @@ var ErrNextLoop = utils.ErrNextLoop
 // +kubebuilder:rbac:groups=snapshot.storage.k8s.io,resources=volumesnapshots,verbs=get;create;watch;list;patch
 // +kubebuilder:rbac:groups=postgresql.cnpg.io,resources=imagecatalogs,verbs=get;watch;list
 // +kubebuilder:rbac:groups=postgresql.cnpg.io,resources=clusterimagecatalogs,verbs=get;watch;list
+// +kubebuilder:rbac:groups=postgresql.cnpg.io,resources=failoverquorums,verbs=create;get;watch;delete;list
+// +kubebuilder:rbac:groups=postgresql.cnpg.io,resources=failoverquorums/status,verbs=get;patch;update;watch
 
 // Reconcile is the operator reconcile loop
 func (r *ClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
diff --git a/internal/controller/cluster_create.go b/internal/controller/cluster_create.go
index 76f459c2..c62bab33 100644
--- a/internal/controller/cluster_create.go
+++ b/internal/controller/cluster_create.go
@@ -101,6 +101,11 @@ func (r *ClusterReconciler) createPostgresClusterObjects(ctx context.Context, cl
 		return err
 	}
 
+	err = r.reconcileFailoverQuorumObject(ctx, cluster)
+	if err != nil {
+		return err
+	}
+
 	return nil
 }
 
diff --git a/internal/controller/replicas.go b/internal/controller/replicas.go
index 74117dc5..bad7f515 100644
--- a/internal/controller/replicas.go
+++ b/internal/controller/replicas.go
@@ -110,6 +110,24 @@ func (r *ClusterReconciler) reconcileTargetPrimaryForNonReplicaCluster(
 		return "", err
 	}
 
+	// If quorum check is active, ensure we don't failover in unsafe scenarios.
+	isFailoverQuorumActive, err := cluster.IsFailoverQuorumActive()
+	if err != nil {
+		contextLogger.Error(err, "Failed to determine if failover quorum is active")
+		isFailoverQuorumActive = false
+	}
+
+	if cluster.Status.TargetPrimary == cluster.Status.CurrentPrimary &&
+		cluster.Spec.PostgresConfiguration.Synchronous != nil &&
+		isFailoverQuorumActive {
+		if status, err := r.evaluateQuorumCheck(ctx, cluster, status); err != nil {
+			return "", err
+		} else if !status {
+			// Prevent a failover from happening
+			return "", nil
+		}
+	}
+
 	// The current primary is not correctly working, and we need to elect a new one
 	// but before doing that we need to wait for all the WAL receivers to be
 	// terminated. To make sure they eventually terminate we signal the old primary
diff --git a/internal/controller/replicas_quorum.go b/internal/controller/replicas_quorum.go
new file mode 100644
index 00000000..07943655
--- /dev/null
+++ b/internal/controller/replicas_quorum.go
@@ -0,0 +1,185 @@
+/*
+Copyright © contributors to CloudNativePG, established as
+CloudNativePG a Series of LF Projects, LLC.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+SPDX-License-Identifier: Apache-2.0
+*/
+
+package controller
+
+import (
+	"context"
+
+	"github.com/cloudnative-pg/machinery/pkg/log"
+	"github.com/cloudnative-pg/machinery/pkg/stringset"
+	apierrs "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	apiv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1"
+	"github.com/cloudnative-pg/cloudnative-pg/pkg/postgres"
+)
+
+// evaluateQuorumCheck evaluate the quorum check algorithm to detect if a failover
+// is possible without losing any transaction.
+// "true" is returned when there is surely a replica containing all the transactions,
+// "false" is returned otherwise.
+// When an error is raised, the caller should not start a failover.
+func (r *ClusterReconciler) evaluateQuorumCheck(
+	ctx context.Context,
+	cluster *apiv1.Cluster,
+	statusList postgres.PostgresqlStatusList,
+) (bool, error) {
+	contextLogger := log.FromContext(ctx).WithValues("tag", "quorumCheck")
+
+	var failoverQuorum apiv1.FailoverQuorum
+	if err := r.Get(ctx, client.ObjectKeyFromObject(cluster), &failoverQuorum); err != nil {
+		if apierrs.IsNotFound(err) {
+			contextLogger.Warning(
+				"Quorum check failed because no synchronous metadata is available. Denying the failover request")
+			return false, nil
+		}
+
+		contextLogger.Error(err,
+			"Quorum check failed because the synchronous replica metadata couldn't be read")
+		return false, err
+	}
+
+	return r.evaluateQuorumCheckWithStatus(ctx, &failoverQuorum, statusList)
+}
+
+// evaluateQuorumCheckWithStatus is used internally by evaluateQuorumCheck,
+// primarily at the benefit of the unit tests
+func (r *ClusterReconciler) evaluateQuorumCheckWithStatus(
+	ctx context.Context,
+	failoverQuorum *apiv1.FailoverQuorum,
+	statusList postgres.PostgresqlStatusList,
+) (bool, error) {
+	contextLogger := log.FromContext(ctx).WithValues("tag", "quorumCheck")
+
+	syncStatus := failoverQuorum.Status
+	contextLogger.Trace("Dumping latest synchronous replication status", "syncStatus", syncStatus)
+
+	// Step 1: coherence check of the synchrouous replication information
+	if syncStatus.StandbyNumber <= 0 {
+		contextLogger.Warning(
+			"Quorum check failed a unsupported synchronous nodes number")
+		return false, nil
+	}
+
+	if len(syncStatus.StandbyNames) == 0 {
+		contextLogger.Warning(
+			"Quorum check failed because the list of synchronous replicas is empty")
+		return false, nil
+	}
+
+	// Step 2: detect promotable replicas
+	candidateReplicas := stringset.New()
+	for _, record := range statusList.Items {
+		if record.Error == nil && record.IsPodReady {
+			candidateReplicas.Put(record.Pod.Name)
+		}
+	}
+
+	// Step 3: evaluate quorum check algorithm
+	//
+	// Important: R + W > N <==> strong consistency
+	// With:
+	// N = the cardinality of the synchronous_standby_names set
+	// W = the sync number or 0 if we're changing a replica configuration.
+	// R = the cardinality of the set of promotable replicas within the
+	//     synchronous_standby_names set
+	//
+	// When this criteria is satisfied we surely have a node containing
+	// the latest transaction.
+	//
+	// The case having W == 0 has been already sorted out in the coherence check.
+
+	nodeSet := stringset.From(syncStatus.StandbyNames)
+	writeSetCardinality := syncStatus.StandbyNumber
+	readSet := nodeSet.Intersect(candidateReplicas)
+
+	nodeSetCardinality := nodeSet.Len()
+	readSetCardinality := readSet.Len()
+
+	isStronglyConsistent := (readSetCardinality + writeSetCardinality) > nodeSetCardinality
+
+	contextLogger.Info(
+		"Quorum check algorithm results",
+		"isStronglyConsistent", isStronglyConsistent,
+		"readSetCardinality", readSetCardinality,
+		"readSet", readSet.ToSortedList(),
+		"writeSetCardinality", writeSetCardinality,
+		"nodeSet", nodeSet.ToSortedList(),
+		"nodeSetCardinality", nodeSetCardinality,
+	)
+
+	if !isStronglyConsistent {
+		contextLogger.Info("Strong consistency check failed. Preventing failover.")
+	}
+
+	return isStronglyConsistent, nil
+}
+
+func (r *ClusterReconciler) reconcileFailoverQuorumObject(ctx context.Context, cluster *apiv1.Cluster) error {
+	contextLogger := log.FromContext(ctx).WithValues("tag", "quorumCheck")
+
+	syncConfig := cluster.Spec.PostgresConfiguration.Synchronous
+	failoverQuorumActive, err := cluster.IsFailoverQuorumActive()
+	if err != nil {
+		contextLogger.Error(err, "Failed to determine if failover quorum is active")
+	}
+	if syncConfig != nil && failoverQuorumActive {
+		return r.ensureFailoverQuorumObjectExists(ctx, cluster)
+	}
+
+	return r.ensureFailoverQuorumObjectDoesNotExist(ctx, cluster)
+}
+
+func (r *ClusterReconciler) ensureFailoverQuorumObjectExists(ctx context.Context, cluster *apiv1.Cluster) error {
+	failoverQuorum := apiv1.FailoverQuorum{
+		TypeMeta: metav1.TypeMeta{
+			Kind:       "FailoverQuorum",
+			APIVersion: apiv1.SchemeGroupVersion.String(),
+		},
+		ObjectMeta: metav1.ObjectMeta{
+			Namespace: cluster.Namespace,
+			Name:      cluster.Name,
+		},
+	}
+	cluster.SetInheritedDataAndOwnership(&failoverQuorum.ObjectMeta)
+
+	err := r.Create(ctx, &failoverQuorum)
+	if err != nil && !apierrs.IsAlreadyExists(err) {
+		log.FromContext(ctx).Error(err, "Unable to create the FailoverQuorum", "object", failoverQuorum)
+		return err
+	}
+
+	return nil
+}
+
+func (r *ClusterReconciler) ensureFailoverQuorumObjectDoesNotExist(ctx context.Context, cluster *apiv1.Cluster) error {
+	var failoverQuorum apiv1.FailoverQuorum
+
+	if err := r.Get(ctx, client.ObjectKeyFromObject(cluster), &failoverQuorum); err != nil {
+		if apierrs.IsNotFound(err) {
+			return nil
+		}
+
+		return err
+	}
+
+	return r.Delete(ctx, &failoverQuorum)
+}
diff --git a/internal/management/controller/instance_controller.go b/internal/management/controller/instance_controller.go
index 454b9247..e77399fa 100644
--- a/internal/management/controller/instance_controller.go
+++ b/internal/management/controller/instance_controller.go
@@ -151,7 +151,7 @@ func (r *InstanceReconciler) Reconcile(
 	requeueOnMissingPermissions := r.updateCacheFromCluster(ctx, cluster)
 
 	// Reconcile monitoring section
-	r.reconcileMetrics(cluster)
+	r.reconcileMetrics(ctx, cluster)
 	r.reconcileMonitoringQueries(ctx, cluster)
 
 	// Verify that the promotion token is usable before changing the archive mode and triggering restarts
@@ -252,6 +252,18 @@ func (r *InstanceReconciler) Reconcile(
 
 	if reloadNeeded && !restarted {
 		contextLogger.Info("reloading the instance")
+
+		// IMPORTANT
+		//
+		// We are unsure of the state of the PostgreSQL configuration
+		// meanwhile a new configuration is applied.
+		//
+		// For this reason, before applying a new configuration we
+		// reset the FailoverQuorum object - de facto preventing any failover -
+		// and we update it after.
+		if err = r.resetFailoverQuorumObject(ctx, cluster); err != nil {
+			return reconcile.Result{}, err
+		}
 		if err = r.instance.Reload(ctx); err != nil {
 			return reconcile.Result{}, fmt.Errorf("while reloading the instance: %w", err)
 		}
@@ -260,6 +272,10 @@ func (r *InstanceReconciler) Reconcile(
 		}
 	}
 
+	if err = r.updateFailoverQuorumObject(ctx, cluster); err != nil {
+		return reconcile.Result{}, err
+	}
+
 	// IMPORTANT
 	// From now on, the database can be assumed as running. Every operation
 	// needing the database to be up should be put below this line.
@@ -807,6 +823,7 @@ func (r *InstanceReconciler) reconcileClusterRoleWithoutDB(
 
 // reconcileMetrics updates any required metrics
 func (r *InstanceReconciler) reconcileMetrics(
+	ctx context.Context,
 	cluster *apiv1.Cluster,
 ) {
 	exporter := r.metricsServerExporter
@@ -823,7 +840,7 @@ func (r *InstanceReconciler) reconcileMetrics(
 	exporter.Metrics.SyncReplicas.WithLabelValues("min").Set(float64(cluster.Spec.MinSyncReplicas))
 	exporter.Metrics.SyncReplicas.WithLabelValues("max").Set(float64(cluster.Spec.MaxSyncReplicas))
 
-	syncReplicas := replication.GetExpectedSyncReplicasNumber(cluster)
+	syncReplicas := replication.GetExpectedSyncReplicasNumber(ctx, cluster)
 	exporter.Metrics.SyncReplicas.WithLabelValues("expected").Set(float64(syncReplicas))
 
 	if cluster.IsReplica() {
diff --git a/internal/management/controller/instance_sync.go b/internal/management/controller/instance_sync.go
new file mode 100644
index 00000000..6d9809fc
--- /dev/null
+++ b/internal/management/controller/instance_sync.go
@@ -0,0 +1,123 @@
+/*
+Copyright © contributors to CloudNativePG, established as
+CloudNativePG a Series of LF Projects, LLC.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+SPDX-License-Identifier: Apache-2.0
+*/
+
+package controller
+
+import (
+	"context"
+
+	"github.com/cloudnative-pg/machinery/pkg/log"
+	"k8s.io/apimachinery/pkg/api/equality"
+	"k8s.io/client-go/util/retry"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	apiv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1"
+)
+
+// resetFailoverQuorumObject resets the content of the sync quorum object
+// to prevent unsafe failovers when we are changing the configuration
+func (r *InstanceReconciler) resetFailoverQuorumObject(ctx context.Context, cluster *apiv1.Cluster) error {
+	if !r.shouldManageFailoverQuorumObject(ctx, cluster) {
+		return nil
+	}
+
+	return retry.RetryOnConflict(retry.DefaultBackoff, func() error {
+		var livingQuorumStatus apiv1.FailoverQuorum
+
+		err := r.client.Get(ctx, client.ObjectKeyFromObject(cluster), &livingQuorumStatus)
+		if err != nil {
+			return err
+		}
+
+		livingQuorumStatus.Status = apiv1.FailoverQuorumStatus{}
+		return r.client.Status().Update(ctx, &livingQuorumStatus)
+	})
+}
+
+// updateFailoverQuorumObject updates the sync quorum object reading the
+// current synchronous replica metadata from the PG instance
+func (r *InstanceReconciler) updateFailoverQuorumObject(ctx context.Context, cluster *apiv1.Cluster) error {
+	if !r.shouldManageFailoverQuorumObject(ctx, cluster) {
+		return nil
+	}
+
+	metadata, err := r.Instance().GetSynchronousReplicationMetadata(ctx)
+	if err != nil {
+		return err
+	}
+
+	newStatus := apiv1.FailoverQuorumStatus{}
+	if metadata != nil {
+		newStatus.Method = metadata.Method
+		newStatus.Primary = r.instance.GetPodName()
+		newStatus.StandbyNumber = metadata.NumSync
+
+		// We ensure the primary is not included in the standby names
+		newStatus.StandbyNames = make([]string, 0, len(metadata.StandbyNames))
+		for _, name := range metadata.StandbyNames {
+			if name == newStatus.Primary {
+				continue
+			}
+			newStatus.StandbyNames = append(newStatus.StandbyNames, name)
+		}
+	}
+
+	return retry.RetryOnConflict(retry.DefaultBackoff, func() error {
+		var livingQuorumStatus apiv1.FailoverQuorum
+
+		err := r.client.Get(ctx, client.ObjectKeyFromObject(cluster), &livingQuorumStatus)
+		if err != nil {
+			return err
+		}
+
+		if equality.Semantic.DeepEqual(livingQuorumStatus.Status, newStatus) {
+			return nil
+		}
+
+		updatedQuorumStatus := livingQuorumStatus.DeepCopy()
+		updatedQuorumStatus.Status = newStatus
+		return r.client.Status().Update(ctx, updatedQuorumStatus)
+	})
+}
+
+func (r *InstanceReconciler) shouldManageFailoverQuorumObject(ctx context.Context, cluster *apiv1.Cluster) bool {
+	contextLogger := log.FromContext(ctx)
+
+	if cluster.Status.TargetPrimary != r.instance.GetPodName() {
+		return false
+	}
+	if cluster.Status.CurrentPrimary != cluster.Status.TargetPrimary {
+		return false
+	}
+	if cluster.Spec.PostgresConfiguration.Synchronous == nil {
+		return false
+	}
+
+	failoverQuorumActive, err := cluster.IsFailoverQuorumActive()
+	if err != nil {
+		contextLogger.Error(err, "Failed to determine if sync quorum is active")
+		failoverQuorumActive = false
+	}
+
+	if !failoverQuorumActive {
+		return false
+	}
+
+	return true
+}
diff --git a/internal/webhook/v1/cluster_webhook.go b/internal/webhook/v1/cluster_webhook.go
index 29ab7a5a..e82890f7 100644
--- a/internal/webhook/v1/cluster_webhook.go
+++ b/internal/webhook/v1/cluster_webhook.go
@@ -207,6 +207,7 @@ func (v *ClusterCustomValidator) validate(r *apiv1.Cluster) (allErrs field.Error
 		v.validateRetentionPolicy,
 		v.validateConfiguration,
 		v.validateSynchronousReplicaConfiguration,
+		v.validateFailoverQuorum,
 		v.validateLDAP,
 		v.validateReplicationSlots,
 		v.validateSynchronizeLogicalDecoding,
@@ -976,12 +977,13 @@ func (v *ClusterCustomValidator) validateSynchronousReplicaConfiguration(r *apiv
 
 	var result field.ErrorList
 
-	if r.Spec.PostgresConfiguration.Synchronous.Number >= (r.Spec.Instances +
-		len(r.Spec.PostgresConfiguration.Synchronous.StandbyNamesPost) +
-		len(r.Spec.PostgresConfiguration.Synchronous.StandbyNamesPre)) {
+	cfg := r.Spec.PostgresConfiguration.Synchronous
+	if cfg.Number >= (r.Spec.Instances +
+		len(cfg.StandbyNamesPost) +
+		len(cfg.StandbyNamesPre)) {
 		err := field.Invalid(
 			field.NewPath("spec", "postgresql", "synchronous"),
-			r.Spec.PostgresConfiguration.Synchronous,
+			cfg,
 			"Invalid synchronous configuration: the number of synchronous replicas must be less than the "+
 				"total number of instances and the provided standby names.",
 		)
@@ -991,6 +993,57 @@ func (v *ClusterCustomValidator) validateSynchronousReplicaConfiguration(r *apiv
 	return result
 }
 
+func (v *ClusterCustomValidator) validateFailoverQuorum(r *apiv1.Cluster) field.ErrorList {
+	var result field.ErrorList
+
+	failoverQuorumActive, err := r.IsFailoverQuorumActive()
+	if err != nil {
+		err := field.Invalid(
+			field.NewPath("metadata", "annotations", utils.FailoverQuorumAnnotationName),
+			r.Annotations[utils.FailoverQuorumAnnotationName],
+			"Invalid failoverQuorum annotation value, expected boolean.",
+		)
+		result = append(result, err)
+		return result
+	}
+	if !failoverQuorumActive {
+		return nil
+	}
+
+	cfg := r.Spec.PostgresConfiguration.Synchronous
+	if cfg == nil {
+		err := field.Required(
+			field.NewPath("spec", "postgresql", "synchronous"),
+			"Invalid failoverQuorum configuration: synchronous replication configuration "+
+				"is required.",
+		)
+		result = append(result, err)
+		return result
+	}
+
+	if cfg.Number <= len(cfg.StandbyNamesPost)+len(cfg.StandbyNamesPre) {
+		err := field.Invalid(
+			field.NewPath("spec", "postgresql", "synchronous"),
+			cfg,
+			"Invalid failoverQuorum configuration: spec.postgresql.synchronous.number must the greater than "+
+				"the total number of instances in spec.postgresql.synchronous.standbyNamesPre and "+
+				"spec.postgresql.synchronous.standbyNamesPost to allow automatic failover.",
+		)
+		result = append(result, err)
+	}
+
+	if r.Spec.Instances <= 2 {
+		err := field.Invalid(
+			field.NewPath("spec", "instances"),
+			r.Spec.Instances,
+			"failoverQuorum requires more than 2 instances.",
+		)
+		result = append(result, err)
+	}
+
+	return result
+}
+
 // validateConfiguration determines whether a PostgreSQL configuration is valid
 func (v *ClusterCustomValidator) validateConfiguration(r *apiv1.Cluster) field.ErrorList {
 	var result field.ErrorList
diff --git a/pkg/management/postgres/configuration.go b/pkg/management/postgres/configuration.go
index 3d577588..6ee4ec1b 100644
--- a/pkg/management/postgres/configuration.go
+++ b/pkg/management/postgres/configuration.go
@@ -78,10 +78,7 @@ func (instance *Instance) RefreshConfigurationFilesFromCluster(
 	}
 
 	postgresConfiguration, sha256, err := createPostgresqlConfiguration(
-		ctx,
-		cluster,
-		preserveUserSettings,
-		pgMajor,
+		ctx, cluster, preserveUserSettings, pgMajor,
 		operationType,
 	)
 	if err != nil {
@@ -406,7 +403,7 @@ func createPostgresqlConfiguration(
 		IsReplicaCluster:                 cluster.IsReplica(),
 		IsWalArchivingDisabled:           utils.IsWalArchivingDisabled(&cluster.ObjectMeta),
 		IsAlterSystemEnabled:             cluster.Spec.PostgresConfiguration.EnableAlterSystem,
-		SynchronousStandbyNames:          replication.GetSynchronousStandbyNames(cluster),
+		SynchronousStandbyNames:          replication.GetSynchronousStandbyNames(ctx, cluster),
 	}
 
 	if preserveUserSettings {
diff --git a/pkg/management/postgres/instance.go b/pkg/management/postgres/instance.go
index 0011a676..74f0caee 100644
--- a/pkg/management/postgres/instance.go
+++ b/pkg/management/postgres/instance.go
@@ -23,6 +23,7 @@ import (
 	"context"
 	"crypto/tls"
 	"database/sql"
+	"encoding/json"
 	"errors"
 	"fmt"
 	"io/fs"
@@ -1010,6 +1011,37 @@ func (instance *Instance) WaitForConfigReload(ctx context.Context) (*postgres.Po
 	return status, nil
 }
 
+// GetSynchronousReplicationMetadata reads the current PostgreSQL configuration
+// and extracts the parameters that were used to compute the synchronous_standby_names
+// GUC.
+func (instance *Instance) GetSynchronousReplicationMetadata(
+	ctx context.Context,
+) (*postgres.SynchronousStandbyNamesConfig, error) {
+	db, err := instance.GetSuperUserDB()
+	if err != nil {
+		return nil, err
+	}
+
+	var metadata string
+	row := db.QueryRowContext(
+		ctx, fmt.Sprintf("SHOW %s", postgres.CNPGSynchronousStandbyNamesMetadata))
+	err = row.Scan(&metadata)
+	if err != nil {
+		return nil, err
+	}
+
+	if len(metadata) == 0 {
+		return nil, nil
+	}
+
+	var result postgres.SynchronousStandbyNamesConfig
+	if err := json.Unmarshal([]byte(metadata), &result); err != nil {
+		return nil, fmt.Errorf("while decoding synchronous_standby_names metadata: %w", err)
+	}
+
+	return &result, nil
+}
+
 // waitForStreamingConnectionAvailable waits until we can connect to the passed
 // sql.DB connection using streaming protocol
 func waitForStreamingConnectionAvailable(ctx context.Context, db *sql.DB) error {
diff --git a/pkg/postgres/configuration.go b/pkg/postgres/configuration.go
index 7e960240..84ad8c46 100644
--- a/pkg/postgres/configuration.go
+++ b/pkg/postgres/configuration.go
@@ -22,6 +22,7 @@ package postgres
 import (
 	"bytes"
 	"crypto/sha256"
+	"encoding/json"
 	"fmt"
 	"iter"
 	"math"
@@ -31,6 +32,8 @@ import (
 	"strings"
 	"text/template"
 	"time"
+
+	"github.com/cloudnative-pg/machinery/pkg/log"
 )
 
 // WalLevelValue a value that is assigned to the 'wal_level' configuration field
@@ -249,6 +252,12 @@ cnpg_pooler_pgbouncer cnpg_pooler_pgbouncer cnpg_pooler_pgbouncer
 	// config in the custom.conf file
 	CNPGConfigSha256 = "cnpg.config_sha256"
 
+	// CNPGSynchronousStandbyNamesMetadata is used to inject inside PG the parameters
+	// that were used to calculate synchronous_standby_names. With this data we're
+	// able to know the actual settings without parsing back the
+	// synchronous_standby_names GUC
+	CNPGSynchronousStandbyNamesMetadata = "cnpg.synchronous_standby_names_metadata"
+
 	// SharedPreloadLibraries shared preload libraries key in the config
 	SharedPreloadLibraries = "shared_preload_libraries"
 
@@ -303,6 +312,21 @@ type ConfigurationSettings struct {
 	PgAuditSettings SettingsCollection
 }
 
+// SynchronousStandbyNamesConfig is the parameters that are needed
+// to create the synchronous_standby_names GUC
+type SynchronousStandbyNamesConfig struct {
+	// Method accepts 'any' (quorum-based synchronous replication)
+	// or 'first' (priority-based synchronous replication) as values.
+	Method string `json:"method"`
+
+	// NumSync is the number of synchronous standbys that transactions
+	// need to wait for replies from
+	NumSync int `json:"number"`
+
+	// StandbyNames is the list of standby servers
+	StandbyNames []string `json:"standbyNames"`
+}
+
 // ConfigurationInfo contains the required information to create a PostgreSQL
 // configuration
 type ConfigurationInfo struct {
@@ -319,7 +343,7 @@ type ConfigurationInfo struct {
 	UserSettings map[string]string
 
 	// The synchronous_standby_names configuration to be applied
-	SynchronousStandbyNames string
+	SynchronousStandbyNames SynchronousStandbyNamesConfig
 
 	// The synchronized_standby_slots configuration to be applied
 	SynchronizedStandbySlots []string
@@ -709,9 +733,17 @@ func CreatePostgresqlConfiguration(info ConfigurationInfo) *PgConfiguration {
 	}
 
 	// Apply the synchronous replication settings
-	syncStandbyNames := info.SynchronousStandbyNames
+	syncStandbyNames := info.SynchronousStandbyNames.String()
 	if len(syncStandbyNames) > 0 {
 		configuration.OverwriteConfig(SynchronousStandbyNames, syncStandbyNames)
+
+		if metadata, err := json.Marshal(info.SynchronousStandbyNames); err != nil {
+			log.Error(err,
+				"Error while serializing streaming configuration parameters",
+				"synchronousStandbyNames", info.SynchronousStandbyNames)
+		} else {
+			configuration.OverwriteConfig(CNPGSynchronousStandbyNamesMetadata, string(metadata))
+		}
 	}
 
 	if len(info.SynchronizedStandbySlots) > 0 {
@@ -944,3 +976,31 @@ func (p *PgConfiguration) setDynamicLibraryPath(info ConfigurationInfo) {
 
 	p.OverwriteConfig(DynamicLibraryPath, strings.Join(dynamicLibraryPath, ":"))
 }
+
+// String creates the synchronous_standby_names PostgreSQL GUC
+// with the passed members
+func (s *SynchronousStandbyNamesConfig) String() string {
+	if s.IsZero() {
+		return ""
+	}
+
+	escapePostgresConfLiteral := func(value string) string {
+		return fmt.Sprintf("\"%v\"", strings.ReplaceAll(value, "\"", "\"\""))
+	}
+
+	escapedReplicas := make([]string, len(s.StandbyNames))
+	for idx, name := range s.StandbyNames {
+		escapedReplicas[idx] = escapePostgresConfLiteral(name)
+	}
+
+	return fmt.Sprintf(
+		"%s %v (%v)",
+		s.Method,
+		s.NumSync,
+		strings.Join(escapedReplicas, ","))
+}
+
+// IsZero is true when synchronour replication is disabled
+func (s SynchronousStandbyNamesConfig) IsZero() bool {
+	return len(s.StandbyNames) == 0
+}
diff --git a/pkg/postgres/replication/explicit.go b/pkg/postgres/replication/explicit.go
index 443d633e..8772f880 100644
--- a/pkg/postgres/replication/explicit.go
+++ b/pkg/postgres/replication/explicit.go
@@ -20,12 +20,11 @@ SPDX-License-Identifier: Apache-2.0
 package replication
 
 import (
-	"fmt"
 	"slices"
 	"sort"
-	"strings"
 
 	apiv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1"
+	"github.com/cloudnative-pg/cloudnative-pg/pkg/postgres"
 )
 
 // placeholderInstanceNameSuffix is the name of the suffix to be added to the
@@ -33,7 +32,7 @@ import (
 // `synchronous_stanby_names` when the replica list would be empty.
 const placeholderInstanceNameSuffix = "-placeholder"
 
-func explicitSynchronousStandbyNames(cluster *apiv1.Cluster) string {
+func explicitSynchronousStandbyNames(cluster *apiv1.Cluster) postgres.SynchronousStandbyNamesConfig {
 	switch cluster.Spec.PostgresConfiguration.Synchronous.DataDurability {
 	case apiv1.DataDurabilityLevelPreferred:
 		return explicitSynchronousStandbyNamesDataDurabilityPreferred(cluster)
@@ -43,7 +42,9 @@ func explicitSynchronousStandbyNames(cluster *apiv1.Cluster) string {
 	}
 }
 
-func explicitSynchronousStandbyNamesDataDurabilityRequired(cluster *apiv1.Cluster) string {
+func explicitSynchronousStandbyNamesDataDurabilityRequired(
+	cluster *apiv1.Cluster,
+) postgres.SynchronousStandbyNamesConfig {
 	config := cluster.Spec.PostgresConfiguration.Synchronous
 
 	// Create the list of pod names
@@ -71,20 +72,16 @@ func explicitSynchronousStandbyNamesDataDurabilityRequired(cluster *apiv1.Cluste
 		}
 	}
 
-	// Escape the pod list
-	escapedReplicas := make([]string, len(instancesList))
-	for idx, name := range instancesList {
-		escapedReplicas[idx] = escapePostgresConfLiteral(name)
+	return postgres.SynchronousStandbyNamesConfig{
+		Method:       config.Method.ToPostgreSQLConfigurationKeyword(),
+		NumSync:      config.Number,
+		StandbyNames: instancesList,
 	}
-
-	return fmt.Sprintf(
-		"%s %v (%v)",
-		config.Method.ToPostgreSQLConfigurationKeyword(),
-		config.Number,
-		strings.Join(escapedReplicas, ","))
 }
 
-func explicitSynchronousStandbyNamesDataDurabilityPreferred(cluster *apiv1.Cluster) string {
+func explicitSynchronousStandbyNamesDataDurabilityPreferred(
+	cluster *apiv1.Cluster,
+) postgres.SynchronousStandbyNamesConfig {
 	config := cluster.Spec.PostgresConfiguration.Synchronous
 
 	// Create the list of healthy replicas
@@ -95,12 +92,6 @@ func explicitSynchronousStandbyNamesDataDurabilityPreferred(cluster *apiv1.Clust
 		instancesList = instancesList[:*config.MaxStandbyNamesFromCluster]
 	}
 
-	// Escape the pod list
-	escapedReplicas := make([]string, len(instancesList))
-	for idx, name := range instancesList {
-		escapedReplicas[idx] = escapePostgresConfLiteral(name)
-	}
-
 	// If data durability is not enforced, we cap the number of synchronous
 	// replicas to be required to the number or available replicas.
 	syncReplicaNumber := config.Number
@@ -110,14 +101,18 @@ func explicitSynchronousStandbyNamesDataDurabilityPreferred(cluster *apiv1.Clust
 
 	// An empty instances list is not allowed in synchronous_standby_names
 	if len(instancesList) == 0 {
-		return ""
+		return postgres.SynchronousStandbyNamesConfig{
+			Method:       "",
+			NumSync:      0,
+			StandbyNames: []string{},
+		}
 	}
 
-	return fmt.Sprintf(
-		"%s %v (%v)",
-		config.Method.ToPostgreSQLConfigurationKeyword(),
-		syncReplicaNumber,
-		strings.Join(escapedReplicas, ","))
+	return postgres.SynchronousStandbyNamesConfig{
+		Method:       config.Method.ToPostgreSQLConfigurationKeyword(),
+		NumSync:      syncReplicaNumber,
+		StandbyNames: instancesList,
+	}
 }
 
 // getSortedInstanceNames gets a list of all the known PostgreSQL instances in a
diff --git a/pkg/postgres/replication/legacy.go b/pkg/postgres/replication/legacy.go
index cd56d8c6..b3e55219 100644
--- a/pkg/postgres/replication/legacy.go
+++ b/pkg/postgres/replication/legacy.go
@@ -20,38 +20,44 @@ SPDX-License-Identifier: Apache-2.0
 package replication
 
 import (
-	"fmt"
+	"context"
 	"sort"
-	"strings"
 
 	"github.com/cloudnative-pg/machinery/pkg/log"
 
 	apiv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1"
+	"github.com/cloudnative-pg/cloudnative-pg/pkg/postgres"
 )
 
 // legacySynchronousStandbyNames sets the standby node list with the
 // legacy API
-func legacySynchronousStandbyNames(cluster *apiv1.Cluster) string {
-	syncReplicas, syncReplicasElectable := getSyncReplicasData(cluster)
+func legacySynchronousStandbyNames(ctx context.Context, cluster *apiv1.Cluster) postgres.SynchronousStandbyNamesConfig {
+	syncReplicas, syncReplicasElectable := getSyncReplicasData(ctx, cluster)
 
 	if syncReplicasElectable != nil && syncReplicas > 0 {
 		escapedReplicas := make([]string, len(syncReplicasElectable))
 		for idx, name := range syncReplicasElectable {
 			escapedReplicas[idx] = escapePostgresConfLiteral(name)
 		}
-		return fmt.Sprintf(
-			"ANY %v (%v)",
-			syncReplicas,
-			strings.Join(escapedReplicas, ","))
+		return postgres.SynchronousStandbyNamesConfig{
+			Method:       "ANY",
+			NumSync:      syncReplicas,
+			StandbyNames: syncReplicasElectable,
+		}
 	}
 
-	return ""
+	return postgres.SynchronousStandbyNamesConfig{}
 }
 
 // getSyncReplicasData computes the actual number of required synchronous replicas and the names of
 // the electable sync replicas given the requested min, max, the number of ready replicas in the cluster and the sync
 // replicas constraints (if any)
-func getSyncReplicasData(cluster *apiv1.Cluster) (syncReplicas int, electableSyncReplicas []string) {
+func getSyncReplicasData(
+	ctx context.Context,
+	cluster *apiv1.Cluster,
+) (syncReplicas int, electableSyncReplicas []string) {
+	contextLogger := log.FromContext(ctx)
+
 	// We start with the number of healthy replicas (healthy pods minus one)
 	// and verify it is greater than 0 and between minSyncReplicas and maxSyncReplicas.
 	// Formula: 1 <= minSyncReplicas <= SyncReplicas <= maxSyncReplicas < readyReplicas
@@ -76,16 +82,16 @@ func getSyncReplicasData(cluster *apiv1.Cluster) (syncReplicas int, electableSyn
 	// temporarily unresponsive system)
 	if readyReplicas < cluster.Spec.MinSyncReplicas {
 		syncReplicas = readyReplicas
-		log.Warning("Ignore minSyncReplicas to enforce self-healing",
+		contextLogger.Warning("Ignore minSyncReplicas to enforce self-healing",
 			"syncReplicas", readyReplicas,
 			"minSyncReplicas", cluster.Spec.MinSyncReplicas,
 			"maxSyncReplicas", cluster.Spec.MaxSyncReplicas)
 	}
 
-	electableSyncReplicas = getElectableSyncReplicas(cluster)
+	electableSyncReplicas = getElectableSyncReplicas(ctx, cluster)
 	numberOfElectableSyncReplicas := len(electableSyncReplicas)
 	if numberOfElectableSyncReplicas < syncReplicas {
-		log.Warning("lowering sync replicas due to not enough electable instances for sync replication "+
+		contextLogger.Warning("lowering sync replicas due to not enough electable instances for sync replication "+
 			"given the constraints",
 			"electableSyncReplicasWithoutConstraints", syncReplicas,
 			"electableSyncReplicasWithConstraints", numberOfElectableSyncReplicas,
@@ -97,7 +103,9 @@ func getSyncReplicasData(cluster *apiv1.Cluster) (syncReplicas int, electableSyn
 }
 
 // getElectableSyncReplicas computes the names of the instances that can be elected to sync replicas
-func getElectableSyncReplicas(cluster *apiv1.Cluster) []string {
+func getElectableSyncReplicas(ctx context.Context, cluster *apiv1.Cluster) []string {
+	contextLogger := log.FromContext(ctx)
+
 	nonPrimaryInstances := getSortedNonPrimaryHealthyInstanceNames(cluster)
 
 	topology := cluster.Status.Topology
@@ -111,20 +119,20 @@ func getElectableSyncReplicas(cluster *apiv1.Cluster) []string {
 	// The same happens if we have failed to extract topology, we want to preserve the current status by adding all the
 	// electable instances.
 	if !topology.SuccessfullyExtracted {
-		log.Warning("topology data not extracted, falling back to all electable sync replicas")
+		contextLogger.Warning("topology data not extracted, falling back to all electable sync replicas")
 		return nonPrimaryInstances
 	}
 
 	currentPrimary := apiv1.PodName(cluster.Status.CurrentPrimary)
 	// given that the constraints are based off the primary instance if we still don't have one we cannot continue
 	if currentPrimary == "" {
-		log.Warning("no primary elected, cannot compute electable sync replicas")
+		contextLogger.Warning("no primary elected, cannot compute electable sync replicas")
 		return nil
 	}
 
 	currentPrimaryTopology, ok := topology.Instances[currentPrimary]
 	if !ok {
-		log.Warning("current primary topology not yet extracted, cannot computed electable sync replicas",
+		contextLogger.Warning("current primary topology not yet extracted, cannot computed electable sync replicas",
 			"instanceName", currentPrimary)
 		return nil
 	}
@@ -136,7 +144,7 @@ func getElectableSyncReplicas(cluster *apiv1.Cluster) []string {
 		instanceTopology, ok := topology.Instances[name]
 		// if we still don't have the topology data for the node we skip it from inserting it in the electable pool
 		if !ok {
-			log.Warning("current instance topology not found", "instanceName", name)
+			contextLogger.Warning("current instance topology not found", "instanceName", name)
 			continue
 		}
 
diff --git a/pkg/postgres/replication/replication.go b/pkg/postgres/replication/replication.go
index 1864f81d..e791731d 100644
--- a/pkg/postgres/replication/replication.go
+++ b/pkg/postgres/replication/replication.go
@@ -20,25 +20,28 @@ SPDX-License-Identifier: Apache-2.0
 package replication
 
 import (
+	"context"
+
 	apiv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1"
+	"github.com/cloudnative-pg/cloudnative-pg/pkg/postgres"
 )
 
 // GetExpectedSyncReplicasNumber computes the actual number of required synchronous replicas
-func GetExpectedSyncReplicasNumber(cluster *apiv1.Cluster) int {
+func GetExpectedSyncReplicasNumber(ctx context.Context, cluster *apiv1.Cluster) int {
 	if cluster.Spec.PostgresConfiguration.Synchronous != nil {
 		return cluster.Spec.PostgresConfiguration.Synchronous.Number
 	}
 
-	syncReplicas, _ := getSyncReplicasData(cluster)
+	syncReplicas, _ := getSyncReplicasData(ctx, cluster)
 	return syncReplicas
 }
 
 // GetSynchronousStandbyNames gets the value to be applied
 // to synchronous_standby_names
-func GetSynchronousStandbyNames(cluster *apiv1.Cluster) string {
+func GetSynchronousStandbyNames(ctx context.Context, cluster *apiv1.Cluster) postgres.SynchronousStandbyNamesConfig {
 	if cluster.Spec.PostgresConfiguration.Synchronous != nil {
 		return explicitSynchronousStandbyNames(cluster)
 	}
 
-	return legacySynchronousStandbyNames(cluster)
+	return legacySynchronousStandbyNames(ctx, cluster)
 }
diff --git a/pkg/specs/roles.go b/pkg/specs/roles.go
index 096b2d10..4567307d 100644
--- a/pkg/specs/roles.go
+++ b/pkg/specs/roles.go
@@ -213,6 +213,39 @@ func CreateRole(cluster apiv1.Cluster, backupOrigin *apiv1.Backup) rbacv1.Role {
 				"update",
 			},
 		},
+		{
+			APIGroups: []string{
+				"postgresql.cnpg.io",
+			},
+			Resources: []string{
+				"failoverquorums",
+			},
+			Verbs: []string{
+				"get",
+				"list",
+				"watch",
+			},
+			ResourceNames: []string{
+				cluster.Name,
+			},
+		},
+		{
+			APIGroups: []string{
+				"postgresql.cnpg.io",
+			},
+			Resources: []string{
+				"failoverquorums/status",
+			},
+			Verbs: []string{
+				"get",
+				"patch",
+				"update",
+				"watch",
+			},
+			ResourceNames: []string{
+				cluster.Name,
+			},
+		},
 	}
 
 	return rbacv1.Role{
diff --git a/pkg/utils/labels_annotations.go b/pkg/utils/labels_annotations.go
index 1ce7eddf..f6cbb713 100644
--- a/pkg/utils/labels_annotations.go
+++ b/pkg/utils/labels_annotations.go
@@ -257,6 +257,13 @@ const (
 	// WebhookValidationAnnotationName is the name of the annotation describing if
 	// the validation webhook should be enabled or disabled
 	WebhookValidationAnnotationName = MetadataNamespace + "/validation"
+
+	// FailoverQuorumAnnotationName is the name of the annotation that allows the
+	// user to enable synchronous quorum failover protection.
+	//
+	// This feature enables quorum-based check before failover, ensuring
+	// no data loss at the expense of availability.
+	FailoverQuorumAnnotationName = MetadataNamespace + "/failoverQuorum"
 )
 
 type annotationStatus string



Test patch:
diff --git a/api/v1/cluster_funcs_test.go b/api/v1/cluster_funcs_test.go
index 01799bd6..5e92d4ea 100644
--- a/api/v1/cluster_funcs_test.go
+++ b/api/v1/cluster_funcs_test.go
@@ -1749,3 +1749,33 @@ var _ = Describe("Probes configuration", func() {
 			"configured probe should not be modified with zero values")
 	})
 })
+
+var _ = Describe("Failover quorum annotation", func() {
+	clusterWithAnnotation := func(v string) *Cluster {
+		return &Cluster{
+			ObjectMeta: metav1.ObjectMeta{
+				Annotations: map[string]string{
+					utils.FailoverQuorumAnnotationName: v,
+				},
+			},
+		}
+	}
+
+	DescribeTable(
+		"annotation parsing",
+		func(cluster *Cluster, valueIsCorrect, expected bool) {
+			actual, err := cluster.IsFailoverQuorumActive()
+			if valueIsCorrect {
+				Expect(err).ToNot(HaveOccurred())
+			} else {
+				Expect(err).To(HaveOccurred())
+			}
+			Expect(actual).To(Equal(expected))
+		},
+		Entry("with no annotation", &Cluster{}, true, false),
+		Entry("with empty annotation", clusterWithAnnotation(""), true, false),
+		Entry("with true annotation", clusterWithAnnotation("t"), true, true),
+		Entry("with false annotation", clusterWithAnnotation("f"), true, false),
+		Entry("with invalid annotation", clusterWithAnnotation("xxx"), false, false),
+	)
+})
diff --git a/internal/controller/replicas_quorum_test.go b/internal/controller/replicas_quorum_test.go
new file mode 100644
index 00000000..b2bda01f
--- /dev/null
+++ b/internal/controller/replicas_quorum_test.go
@@ -0,0 +1,141 @@
+/*
+Copyright © contributors to CloudNativePG, established as
+CloudNativePG a Series of LF Projects, LLC.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+SPDX-License-Identifier: Apache-2.0
+*/
+
+package controller
+
+import (
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	apiv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1"
+	"github.com/cloudnative-pg/cloudnative-pg/pkg/postgres"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+)
+
+var _ = Describe("quorum promotion control", func() {
+	r := &ClusterReconciler{}
+
+	When("the information is not consistent because the number of synchronous standbies is zero", func() {
+		sync := &apiv1.FailoverQuorum{
+			Status: apiv1.FailoverQuorumStatus{
+				StandbyNumber: 0,
+			},
+		}
+
+		statusList := postgres.PostgresqlStatusList{}
+
+		It("denies a failover", func(ctx SpecContext) {
+			status, err := r.evaluateQuorumCheckWithStatus(ctx, sync, statusList)
+			Expect(err).ToNot(HaveOccurred())
+			Expect(status).To(BeFalse())
+		})
+	})
+
+	When("the information is not consistent because the standby list is empty", func() {
+		sync := &apiv1.FailoverQuorum{
+			Status: apiv1.FailoverQuorumStatus{
+				StandbyNumber: 3,
+				StandbyNames:  nil,
+			},
+		}
+
+		statusList := postgres.PostgresqlStatusList{}
+
+		It("denies a failover", func(ctx SpecContext) {
+			status, err := r.evaluateQuorumCheckWithStatus(ctx, sync, statusList)
+			Expect(err).ToNot(HaveOccurred())
+			Expect(status).To(BeFalse())
+		})
+	})
+
+	When("there is no quorum", func() {
+		sync := &apiv1.FailoverQuorum{
+			Status: apiv1.FailoverQuorumStatus{
+				StandbyNumber: 1,
+				StandbyNames: []string{
+					"postgres-2",
+					"postgres-3",
+				},
+			},
+		}
+
+		statusList := postgres.PostgresqlStatusList{
+			Items: []postgres.PostgresqlStatus{
+				{
+					Pod: &corev1.Pod{
+						ObjectMeta: metav1.ObjectMeta{
+							Name: "postgres-3",
+						},
+					},
+					Error:      nil,
+					IsPodReady: true,
+				},
+			},
+		}
+
+		It("denies a failover", func(ctx SpecContext) {
+			status, err := r.evaluateQuorumCheckWithStatus(ctx, sync, statusList)
+			Expect(err).ToNot(HaveOccurred())
+			Expect(status).To(BeFalse())
+		})
+	})
+
+	When("there is quorum", func() {
+		sync := &apiv1.FailoverQuorum{
+			Status: apiv1.FailoverQuorumStatus{
+				StandbyNumber: 1,
+				StandbyNames: []string{
+					"postgres-2",
+					"postgres-3",
+				},
+			},
+		}
+
+		statusList := postgres.PostgresqlStatusList{
+			Items: []postgres.PostgresqlStatus{
+				{
+					Pod: &corev1.Pod{
+						ObjectMeta: metav1.ObjectMeta{
+							Name: "postgres-2",
+						},
+					},
+					Error:      nil,
+					IsPodReady: true,
+				},
+				{
+					Pod: &corev1.Pod{
+						ObjectMeta: metav1.ObjectMeta{
+							Name: "postgres-3",
+						},
+					},
+					Error:      nil,
+					IsPodReady: true,
+				},
+			},
+		}
+
+		It("denies a failover", func(ctx SpecContext) {
+			status, err := r.evaluateQuorumCheckWithStatus(ctx, sync, statusList)
+			Expect(err).ToNot(HaveOccurred())
+			Expect(status).To(BeTrue())
+		})
+	})
+})
diff --git a/internal/webhook/v1/cluster_webhook_test.go b/internal/webhook/v1/cluster_webhook_test.go
index f06a2075..a59bab25 100644
--- a/internal/webhook/v1/cluster_webhook_test.go
+++ b/internal/webhook/v1/cluster_webhook_test.go
@@ -5795,3 +5795,80 @@ var _ = Describe("getStorageWarnings", func() {
 		Expect(getStorageWarnings(cluster)).To(BeEmpty())
 	})
 })
+
+var _ = Describe("failoverQuorum validation", func() {
+	var v *ClusterCustomValidator
+	BeforeEach(func() {
+		v = &ClusterCustomValidator{}
+	})
+
+	It("fails if it is active but no synchronous replication is configured", func() {
+		cluster := &apiv1.Cluster{
+			ObjectMeta: metav1.ObjectMeta{
+				Annotations: map[string]string{
+					utils.FailoverQuorumAnnotationName: "t",
+				},
+			},
+			Spec: apiv1.ClusterSpec{
+				Instances: 3,
+			},
+		}
+
+		errList := v.validateFailoverQuorum(cluster)
+		Expect(errList).To(HaveLen(1))
+	})
+
+	It("requires at least three instances", func() {
+		cluster := &apiv1.Cluster{
+			ObjectMeta: metav1.ObjectMeta{
+				Annotations: map[string]string{
+					utils.FailoverQuorumAnnotationName: "t",
+				},
+			},
+			Spec: apiv1.ClusterSpec{
+				Instances: 3,
+				PostgresConfiguration: apiv1.PostgresConfiguration{
+					Synchronous: &apiv1.SynchronousReplicaConfiguration{
+						Number: 1,
+					},
+				},
+			},
+		}
+
+		errList := v.validateFailoverQuorum(cluster)
+		Expect(errList).To(BeEmpty())
+
+		cluster.Spec.Instances = 2
+		errList = v.validateFailoverQuorum(cluster)
+		Expect(errList).To(HaveLen(1))
+	})
+
+	It("check if the number of external synchronous replicas is coherent", func() {
+		cluster := &apiv1.Cluster{
+			ObjectMeta: metav1.ObjectMeta{
+				Annotations: map[string]string{
+					utils.FailoverQuorumAnnotationName: "t",
+				},
+			},
+			Spec: apiv1.ClusterSpec{
+				Instances: 3,
+				PostgresConfiguration: apiv1.PostgresConfiguration{
+					Synchronous: &apiv1.SynchronousReplicaConfiguration{
+						Number: 1,
+						StandbyNamesPre: []string{
+							"one",
+							"two",
+						},
+						StandbyNamesPost: []string{
+							"three",
+							"four",
+						},
+					},
+				},
+			},
+		}
+
+		errList := v.validateFailoverQuorum(cluster)
+		Expect(errList).To(HaveLen(1))
+	})
+})
diff --git a/pkg/management/postgres/configuration_test.go b/pkg/management/postgres/configuration_test.go
index 38ffa901..48cd5984 100644
--- a/pkg/management/postgres/configuration_test.go
+++ b/pkg/management/postgres/configuration_test.go
@@ -178,10 +178,7 @@ var _ = Describe("Test building of the list of temporary tablespaces", func() {
 
 	It("doesn't set temp_tablespaces if there are no declared tablespaces", func(ctx SpecContext) {
 		config, _, err := createPostgresqlConfiguration(
-			ctx,
-			&clusterWithoutTablespaces,
-			true,
-			defaultMajor,
+			ctx, &clusterWithoutTablespaces, true, defaultMajor,
 			postgres.OperationType_TYPE_UNSPECIFIED,
 		)
 		Expect(err).ToNot(HaveOccurred())
@@ -190,10 +187,7 @@ var _ = Describe("Test building of the list of temporary tablespaces", func() {
 
 	It("doesn't set temp_tablespaces if there are no temporary tablespaces", func(ctx SpecContext) {
 		config, _, err := createPostgresqlConfiguration(
-			ctx,
-			&clusterWithoutTemporaryTablespaces,
-			true,
-			defaultMajor,
+			ctx, &clusterWithoutTemporaryTablespaces, true, defaultMajor,
 			postgres.OperationType_TYPE_UNSPECIFIED,
 		)
 		Expect(err).ToNot(HaveOccurred())
@@ -202,10 +196,7 @@ var _ = Describe("Test building of the list of temporary tablespaces", func() {
 
 	It("sets temp_tablespaces when there are temporary tablespaces", func(ctx SpecContext) {
 		config, _, err := createPostgresqlConfiguration(
-			ctx,
-			&clusterWithTemporaryTablespaces,
-			true,
-			defaultMajor,
+			ctx, &clusterWithTemporaryTablespaces, true, defaultMajor,
 			postgres.OperationType_TYPE_UNSPECIFIED,
 		)
 		Expect(err).ToNot(HaveOccurred())
@@ -267,10 +258,7 @@ var _ = Describe("recovery_min_apply_delay", func() {
 		Expect(primaryCluster.IsReplica()).To(BeFalse())
 
 		config, _, err := createPostgresqlConfiguration(
-			ctx,
-			&primaryCluster,
-			true,
-			defaultMajor,
+			ctx, &primaryCluster, true, defaultMajor,
 			postgres.OperationType_TYPE_UNSPECIFIED,
 		)
 		Expect(err).ToNot(HaveOccurred())
@@ -281,10 +269,7 @@ var _ = Describe("recovery_min_apply_delay", func() {
 		Expect(replicaCluster.IsReplica()).To(BeTrue())
 
 		config, _, err := createPostgresqlConfiguration(
-			ctx,
-			&replicaCluster,
-			true,
-			defaultMajor,
+			ctx, &replicaCluster, true, defaultMajor,
 			postgres.OperationType_TYPE_UNSPECIFIED,
 		)
 		Expect(err).ToNot(HaveOccurred())
@@ -295,10 +280,7 @@ var _ = Describe("recovery_min_apply_delay", func() {
 		Expect(replicaClusterWithNoDelay.IsReplica()).To(BeTrue())
 
 		config, _, err := createPostgresqlConfiguration(
-			ctx,
-			&replicaClusterWithNoDelay,
-			true,
-			defaultMajor,
+			ctx, &replicaClusterWithNoDelay, true, defaultMajor,
 			postgres.OperationType_TYPE_UNSPECIFIED,
 		)
 		Expect(err).ToNot(HaveOccurred())
diff --git a/pkg/postgres/replication/explicit_test.go b/pkg/postgres/replication/explicit_test.go
index c2a12a9a..8dc7d9ce 100644
--- a/pkg/postgres/replication/explicit_test.go
+++ b/pkg/postgres/replication/explicit_test.go
@@ -23,6 +23,7 @@ import (
 	"k8s.io/utils/ptr"
 
 	apiv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1"
+	"github.com/cloudnative-pg/cloudnative-pg/pkg/postgres"
 
 	. "github.com/onsi/ginkgo/v2"
 	. "github.com/onsi/gomega"
@@ -46,7 +47,11 @@ var _ = Describe("synchronous replica configuration with the new API", func() {
 				},
 			}
 
-			Expect(explicitSynchronousStandbyNames(cluster)).To(Equal("ANY 2 (\"three\",\"two\",\"one\")"))
+			Expect(explicitSynchronousStandbyNames(cluster)).To(Equal(postgres.SynchronousStandbyNamesConfig{
+				Method:       "ANY",
+				NumSync:      2,
+				StandbyNames: []string{"three", "two", "one"},
+			}))
 		})
 
 		It("creates configuration with the FIRST clause", func() {
@@ -65,7 +70,11 @@ var _ = Describe("synchronous replica configuration with the new API", func() {
 				},
 			}
 
-			Expect(explicitSynchronousStandbyNames(cluster)).To(Equal("FIRST 2 (\"three\",\"two\",\"one\")"))
+			Expect(explicitSynchronousStandbyNames(cluster)).To(Equal(postgres.SynchronousStandbyNamesConfig{
+				Method:       "FIRST",
+				NumSync:      2,
+				StandbyNames: []string{"three", "two", "one"},
+			}))
 		})
 
 		It("considers the maximum number of standby names", func() {
@@ -84,7 +93,11 @@ var _ = Describe("synchronous replica configuration with the new API", func() {
 				},
 			}
 
-			Expect(explicitSynchronousStandbyNames(cluster)).To(Equal("FIRST 2 (\"three\")"))
+			Expect(explicitSynchronousStandbyNames(cluster)).To(Equal(postgres.SynchronousStandbyNamesConfig{
+				Method:       "FIRST",
+				NumSync:      2,
+				StandbyNames: []string{"three"},
+			}))
 		})
 
 		It("prepends the prefix and append the suffix", func() {
@@ -103,8 +116,11 @@ var _ = Describe("synchronous replica configuration with the new API", func() {
 				},
 			}
 
-			Expect(explicitSynchronousStandbyNames(cluster)).To(
-				Equal("FIRST 2 (\"prefix\",\"here\",\"three\",\"suffix\",\"there\")"))
+			Expect(explicitSynchronousStandbyNames(cluster)).To(Equal(postgres.SynchronousStandbyNamesConfig{
+				Method:       "FIRST",
+				NumSync:      2,
+				StandbyNames: []string{"prefix", "here", "three", "suffix", "there"},
+			}))
 		})
 
 		It("enforce synchronous replication even if there are no healthy replicas", func() {
@@ -116,8 +132,11 @@ var _ = Describe("synchronous replica configuration with the new API", func() {
 			}
 			cluster.Status = apiv1.ClusterStatus{}
 
-			Expect(explicitSynchronousStandbyNames(cluster)).To(
-				Equal("FIRST 2 (\"example-placeholder\")"))
+			Expect(explicitSynchronousStandbyNames(cluster)).To(Equal(postgres.SynchronousStandbyNamesConfig{
+				Method:       "FIRST",
+				NumSync:      2,
+				StandbyNames: []string{"example-placeholder"},
+			}))
 		})
 
 		It("includes pods that do not report the status", func() {
@@ -136,7 +155,12 @@ var _ = Describe("synchronous replica configuration with the new API", func() {
 				},
 				InstanceNames: []string{"one", "two", "three"},
 			}
-			Expect(explicitSynchronousStandbyNames(cluster)).To(Equal("FIRST 2 (\"three\",\"two\",\"one\")"))
+
+			Expect(explicitSynchronousStandbyNames(cluster)).To(Equal(postgres.SynchronousStandbyNamesConfig{
+				Method:       "FIRST",
+				NumSync:      2,
+				StandbyNames: []string{"three", "two", "one"},
+			}))
 		})
 	})
 
@@ -159,7 +183,11 @@ var _ = Describe("synchronous replica configuration with the new API", func() {
 			}
 
 			// Important: the name of the primary is not included in the list
-			Expect(explicitSynchronousStandbyNames(cluster)).To(Equal("ANY 2 (\"three\",\"two\")"))
+			Expect(explicitSynchronousStandbyNames(cluster)).To(Equal(postgres.SynchronousStandbyNamesConfig{
+				Method:       "ANY",
+				NumSync:      2,
+				StandbyNames: []string{"three", "two"},
+			}))
 		})
 
 		It("creates configuration with the FIRST clause", func() {
@@ -180,7 +208,11 @@ var _ = Describe("synchronous replica configuration with the new API", func() {
 			}
 
 			// Important: the name of the primary is not included in the list
-			Expect(explicitSynchronousStandbyNames(cluster)).To(Equal("FIRST 2 (\"three\",\"two\")"))
+			Expect(explicitSynchronousStandbyNames(cluster)).To(Equal(postgres.SynchronousStandbyNamesConfig{
+				Method:       "FIRST",
+				NumSync:      2,
+				StandbyNames: []string{"three", "two"},
+			}))
 		})
 
 		It("considers the maximum number of standby names", func() {
@@ -200,7 +232,11 @@ var _ = Describe("synchronous replica configuration with the new API", func() {
 				},
 			}
 
-			Expect(explicitSynchronousStandbyNames(cluster)).To(Equal("FIRST 1 (\"three\")"))
+			Expect(explicitSynchronousStandbyNames(cluster)).To(Equal(postgres.SynchronousStandbyNamesConfig{
+				Method:       "FIRST",
+				NumSync:      1,
+				StandbyNames: []string{"three"},
+			}))
 		})
 
 		It("ignores the prefix and the suffix", func() {
@@ -219,8 +255,11 @@ var _ = Describe("synchronous replica configuration with the new API", func() {
 				},
 			}
 
-			Expect(explicitSynchronousStandbyNames(cluster)).To(
-				Equal("FIRST 2 (\"three\",\"two\")"))
+			Expect(explicitSynchronousStandbyNames(cluster)).To(Equal(postgres.SynchronousStandbyNamesConfig{
+				Method:       "FIRST",
+				NumSync:      2,
+				StandbyNames: []string{"three", "two"},
+			}))
 		})
 
 		It("disables synchronous replication when no instance is available", func() {
@@ -233,7 +272,7 @@ var _ = Describe("synchronous replica configuration with the new API", func() {
 			}
 			cluster.Status = apiv1.ClusterStatus{}
 
-			Expect(explicitSynchronousStandbyNames(cluster)).To(Equal(""))
+			Expect(explicitSynchronousStandbyNames(cluster).IsZero()).To(BeTrue())
 		})
 
 		It("does not include pods that do not report the status", func() {
@@ -253,7 +292,12 @@ var _ = Describe("synchronous replica configuration with the new API", func() {
 				},
 				InstanceNames: []string{"one", "two", "three"},
 			}
-			Expect(explicitSynchronousStandbyNames(cluster)).To(Equal("FIRST 1 (\"three\")"))
+
+			Expect(explicitSynchronousStandbyNames(cluster)).To(Equal(postgres.SynchronousStandbyNamesConfig{
+				Method:       "FIRST",
+				NumSync:      1,
+				StandbyNames: []string{"three"},
+			}))
 		})
 	})
 })
diff --git a/pkg/postgres/replication/legacy_test.go b/pkg/postgres/replication/legacy_test.go
index 3f87f075..c29a099a 100644
--- a/pkg/postgres/replication/legacy_test.go
+++ b/pkg/postgres/replication/legacy_test.go
@@ -21,20 +21,21 @@ package replication
 
 import (
 	apiv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1"
+	"github.com/cloudnative-pg/cloudnative-pg/pkg/postgres"
 
 	. "github.com/onsi/ginkgo/v2"
 	. "github.com/onsi/gomega"
 )
 
 var _ = Describe("ensuring the correctness of synchronous replica data calculation", func() {
-	It("should return all the non primary pods as electable", func() {
+	It("should return all the non primary pods as electable", func(ctx SpecContext) {
 		cluster := createFakeCluster("example")
-		number, names := getSyncReplicasData(cluster)
+		number, names := getSyncReplicasData(ctx, cluster)
 		Expect(number).To(Equal(2))
 		Expect(names).To(Equal([]string{"example-2", "example-3"}))
 	})
 
-	It("should return only the pod in the different AZ", func() {
+	It("should return only the pod in the different AZ", func(ctx SpecContext) {
 		const (
 			primaryPod     = "exampleAntiAffinity-1"
 			sameZonePod    = "exampleAntiAffinity-2"
@@ -61,13 +62,13 @@ var _ = Describe("ensuring the correctness of synchronous replica data calculati
 			},
 		}
 
-		number, names := getSyncReplicasData(cluster)
+		number, names := getSyncReplicasData(ctx, cluster)
 
 		Expect(number).To(Equal(1))
 		Expect(names).To(Equal([]string{differentAZPod}))
 	})
 
-	It("should lower the synchronous replica number to enforce self-healing", func() {
+	It("should lower the synchronous replica number to enforce self-healing", func(ctx SpecContext) {
 		cluster := createFakeCluster("exampleOnePod")
 		cluster.Status = apiv1.ClusterStatus{
 			CurrentPrimary: "exampleOnePod-1",
@@ -76,14 +77,14 @@ var _ = Describe("ensuring the correctness of synchronous replica data calculati
 				apiv1.PodFailed:  {"exampleOnePod-2", "exampleOnePod-3"},
 			},
 		}
-		number, names := getSyncReplicasData(cluster)
+		number, names := getSyncReplicasData(ctx, cluster)
 
 		Expect(number).To(BeZero())
 		Expect(names).To(BeEmpty())
 		Expect(cluster.Spec.MinSyncReplicas).To(Equal(1))
 	})
 
-	It("should behave correctly if there is no ready host", func() {
+	It("should behave correctly if there is no ready host", func(ctx SpecContext) {
 		cluster := createFakeCluster("exampleNoPods")
 		cluster.Status = apiv1.ClusterStatus{
 			CurrentPrimary: "example-1",
@@ -91,7 +92,7 @@ var _ = Describe("ensuring the correctness of synchronous replica data calculati
 				apiv1.PodFailed: {"exampleNoPods-1", "exampleNoPods-2", "exampleNoPods-3"},
 			},
 		}
-		number, names := getSyncReplicasData(cluster)
+		number, names := getSyncReplicasData(ctx, cluster)
 
 		Expect(number).To(BeZero())
 		Expect(names).To(BeEmpty())
@@ -99,7 +100,7 @@ var _ = Describe("ensuring the correctness of synchronous replica data calculati
 })
 
 var _ = Describe("legacy synchronous_standby_names configuration", func() {
-	It("generate the correct value for the synchronous_standby_names parameter", func() {
+	It("generate the correct value for the synchronous_standby_names parameter", func(ctx SpecContext) {
 		cluster := createFakeCluster("exampleNoPods")
 		cluster.Spec.MinSyncReplicas = 2
 		cluster.Spec.MaxSyncReplicas = 2
@@ -109,8 +110,14 @@ var _ = Describe("legacy synchronous_standby_names configuration", func() {
 				apiv1.PodHealthy: {"one", "two", "three"},
 			},
 		}
-		synchronousStandbyNames := legacySynchronousStandbyNames(cluster)
-		Expect(synchronousStandbyNames).
-			To(Equal("ANY 2 (\"one\",\"three\",\"two\")"))
+		synchronousStandbyNames := legacySynchronousStandbyNames(ctx, cluster)
+
+		Expect(synchronousStandbyNames).To(Equal(
+			postgres.SynchronousStandbyNamesConfig{
+				Method:       "ANY",
+				NumSync:      2,
+				StandbyNames: []string{"one", "three", "two"},
+			},
+		))
 	})
 })
diff --git a/pkg/specs/roles_test.go b/pkg/specs/roles_test.go
index 98ce4213..8808163d 100644
--- a/pkg/specs/roles_test.go
+++ b/pkg/specs/roles_test.go
@@ -168,7 +168,7 @@ var _ = Describe("Roles", func() {
 		serviceAccount := CreateRole(cluster, nil)
 		Expect(serviceAccount.Name).To(Equal(cluster.Name))
 		Expect(serviceAccount.Namespace).To(Equal(cluster.Namespace))
-		Expect(serviceAccount.Rules).To(HaveLen(13))
+		Expect(serviceAccount.Rules).To(HaveLen(15))
 	})
 
 	It("should contain every secret of the origin backup and backup configuration of every external cluster", func() {